phase: 125_image_anchor_context
Build and Push Containers / build-and-push-app (push) Successful in 2m8s
Build and Push Containers / build-and-push-db (push) Failing after 1m38s

All completion criteria verified. The repository is left functional with all changes in the working tree (no commits made, per the harness protocol).

## Final report — phase 125 `image_anchor_context` (verification pass)

All 6 tasks were already complete; this pass verified every criterion and ran the full gates:

- **Migration 0023** `documents.image_context` (TEXT nullable, reversible) — applied (`alembic current` = 0023 head); round-trip test (4 passed) exercises real upgrade → downgrade 0022 → upgrade.
- **Criterion 1** (referenced image enriched + context line in retrieval): integration `test_markdown_referenced_image_gets_context_and_enriched_chunks` + E2E `test_sync_enriches_the_image_doc_with_markdown_context`, `test_chat_answer_quotes_the_context_line` (wire prompt pinned: `<document>` block leads with the one-line `Referenced in …: "alt"` line) — verified present and passing.
- **Criterion 2** (standalone image byte-identical): E2E `test_standalone_*` (NULL `image_context`, block body exactly the bare description, no `Referenced in` anywhere in the prompt) + unit `test_describe_image_without_context_is_byte_identical` — passing.
- **Criterion 3** (markdown-first: record stored on markdown doc, image picks up context later): integration `test_markdown_indexed_before_image_stores_record_and_image_picks_it_up` + post-index enrichment pass test (markdown file gone from disk) — passing.
- **Gates:** `uv run pytest` → 2938 passed, exit 0 · `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL **99%** (>90%) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov` → 4 passed.
- **Mock-LLM change regression check:** neighboring image E2E suites green in isolation — `test_smoke` (3), `test_image_documents` (16), `test_chat_image_questions` (17), `test_answer_images` (17).
- No defects found; no code changes needed. No live-infrastructure changes (local dev DB only). No commit made (harness commits).
- Next pending phase: check `.agents/phases/todo/` after the harness moves this phase.
This commit is contained in:
2026-09-27 05:53:12 -04:00
parent 62e4f6b572
commit e0307fe3ff
60 changed files with 6583 additions and 82 deletions
+99
View File
@@ -2790,3 +2790,102 @@ def test_zero_read_text_only_turn_still_chips_nothing(
assert done["sources"] == [] # phase-119 read-docs-only rule intact
for ref in done["sources"] + done["related"]:
assert "image_url" not in ref
# ---------------------------------------------------------------------------
# Phase 125, task 05 — retrieval display: the suggested tier's ENRICHED
# image doc (non-NULL image_context, the task-03/04 block shape) leads
# its <document> block with the one-line context line (the referring
# markdown's identity + the reference's alt) before the description;
# the non-enriched image doc (NULL image_context) stays byte-identical
# to pre-phase (LOCKED A4).
# ---------------------------------------------------------------------------
def test_grounded_turn_suggested_enriched_image_doc_carries_context_line(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""LOCKED A4 end-to-end: a grounded turn whose suggested tier
contains an enriched image doc (the task-03/04 ``Referenced in``
block stored on ``image_context``) → the system prompt's
``<document>`` block for that doc leads with the short context
line, a blank line, then the description — and the ``Context:``
paragraphs of the stored block never reach the prompt (the line is
the locator + purpose, not a duplicate of the paragraphs)."""
doc = _seed_image_doc_with_chunk(db)
doc.image_context = (
"Referenced in docs/homelab/kubernetes.md:\n"
'- Alt text: "Cluster layout"\n'
" Context: The diagram shows the three Talos nodes.\n"
"It is referenced again from the runbook."
)
db.commit()
scripted = FakeRagLLM() # zero-read grounded turn (the image seed block IS its content)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["deflected"] is False
system = scripted.seen_messages[0][0]["content"]
# The doc is in the suggested tier (retrieval is unchanged by the
# phase — the line rides on a doc the retrieval already picked).
block_start = system.index(f'<document source="docs" path="{doc.path}"')
block = system[
block_start : system.index("</document>", block_start) + len("</document>")
]
line = 'Referenced in docs/homelab/kubernetes.md: "Cluster layout"'
# The exact block shape: identity attrs (phase 106 date), the line,
# a blank line, the description (the phase-122 content == summary
# == vision description invariant).
assert block == (
f'<document source="docs" path="pic.png" title="pic" '
f'date="{_FIXTURE_CREATED_AT:%Y-%m-%d}">\n'
f"{line}\n"
"\n"
f"{doc.summary}\n"
"</document>"
)
# The line is the FIRST thing in the block body (before the
# description), inside <documents>.
assert system.index("<documents>") < system.index(line)
assert system.index(line) < system.index(doc.summary or "")
# The Context: body stays out of the whole prompt.
assert "The diagram shows the three Talos nodes." not in system
assert "referenced again from the runbook" not in system
assert "Context:" not in system
def test_grounded_turn_suggested_plain_image_doc_block_is_byte_identical(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""LOCKED A4 (the byte-identity criterion) end-to-end: the SAME
flow with ``image_context`` left NULL (the standalone / pre-phase
image doc — the phase's default) → the ``<document>`` block is
EXACTLY the pre-phase block (the summary alone, no context line,
no stray blank) and the marker string does not appear anywhere in
the system prompt."""
doc = _seed_image_doc_with_chunk(db)
assert doc.image_context is None # the seed leaves it NULL (standalone)
scripted = FakeRagLLM()
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["deflected"] is False
system = scripted.seen_messages[0][0]["content"]
block_start = system.index(f'<document source="docs" path="{doc.path}"')
block = system[
block_start : system.index("</document>", block_start) + len("</document>")
]
# The pre-phase block: identity attrs + the description, verbatim.
assert block == (
f'<document source="docs" path="pic.png" title="pic" '
f'date="{_FIXTURE_CREATED_AT:%Y-%m-%d}">\n'
f"{doc.summary}\n"
"</document>"
)
assert "Referenced in" not in system
+6 -2
View File
@@ -869,9 +869,13 @@ def _image_llm[
def _patch_description(monkeypatch, description) -> None:
"""Pin the task-02 seam (``rag_importer._describe_or_skip``) to
*description*. Task 03 fills the seam with the CHAT model's vision
call — these end-to-end mechanics do not change with it."""
call — these end-to-end mechanics do not change with it. Phase 125
(task 03) widened the seam with the optional ``context`` kwarg (the
markdown reference context) — the fake accepts (and ignores) it:
these fixtures carry no image references, so the seam gets
``context=None``."""
async def _fake(llm, *, data, source, rel, full_path):
async def _fake(llm, *, data, source, rel, full_path, context=None):
return description
monkeypatch.setattr(rag_importer, "_describe_or_skip", _fake)
+446
View File
@@ -0,0 +1,446 @@
"""Integration: phase 125, task 03 — the image-anchor enrichment through
the REAL import pipeline (real compose Postgres, the real
``_describe_or_skip`` → ``describe_image`` seam, the mock vision
client — NO seam patch).
A markdown file referencing an image in the SAME source → the vision
call receives the markdown context (appended after the locked
``DESCRIBE_PROMPT``), the image doc's ``image_context`` is set, its
``content``/``summary`` is ``<context>\\n\\n<description>``, and its
chunks are embedded from the enriched content. A standalone image (no
markdown reference) → ``image_context`` NULL, ``content`` the bare
description, the bare locked prompt — byte-identical to pre-phase-125.
Phase 125, task 04 (the markdown-FIRST case) — a markdown indexed while
its referenced image has no document yet stores the reference on the
markdown doc's ``image_context`` (the ``IMAGE_CONTEXT:`` record); the
image that appears later picks the context up at index time (the
task-03 disk scan) or, when the markdown file is gone from disk by
then, through the post-index enrichment pass (the stored doc rows —
record as the index, stored content as the source). Pinned here: the
record-then-image sync pair (the record stored, not cleared), the
pass-only path (markdown file deleted from disk, ``prune=False``), and
the existing-image case (the markdown's record stays NULL — the
task-03 path owns that case).
The unit-level mechanics (the prefix shape, the scan's combined string,
the seam forwarding, the changed-image re-scan, the record formatter +
parser, the pass's idempotency/fail-soft) are pinned in
``tests/unit/test_image_anchor_context.py``; the task-06 story suite
pins the E2E surface.
"""
from __future__ import annotations
import asyncio
import base64
from sqlalchemy import select
from app.config import Settings
from app.models import Chunk, Document
from app.rag.importer import import_sources
from app.rag.summarizer import DESCRIBE_PROMPT
from tests.fakes import FakeEmbedder
#: A real 1×1 transparent PNG (the phase-122 fixture — the importer is
#: content-agnostic, the bytes just need to be plausible image bytes).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
DESCRIPTION = (
"A network diagram of the homelab VLANs: the core switch, the "
"router, and three labeled subnets."
)
#: The exact context prefix the task-03 extension appends to the locked
#: prompt (``app.rag.summarizer.describe_image``).
CONTEXT_PREFIX = "\n\nThis image is referenced in documentation with the following context:\n"
class _MockVisionLLM(FakeEmbedder):
"""The phase-122 mock VISION client: the CHAT model answers the
multimodal describe call with the fixed :data:`DESCRIPTION` (the
text part is recorded — ``text_parts``); text (``lite``) calls keep
the ``FakeEmbedder`` behaviour. The REAL
``import_sources`` → ``_describe_or_skip`` → ``describe_image``
chain runs against it (no seam patch)."""
DESCRIPTION = DESCRIPTION
def __init__(self) -> None:
super().__init__()
self.chat_models: list[str | None] = []
self.text_parts: list[str] = []
async def chat(self, messages, model=None):
self.chat_calls.append(list(messages))
self.chat_models.append(model)
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
self.text_parts.append(user[0]["text"])
return self.DESCRIPTION
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
def _image_llm[
LLM: FakeEmbedder
](tmp_path, llm_cls: type[LLM] = _MockVisionLLM, **kwargs) -> LLM:
"""A fake LLM with the phase-122 image knobs (toggle ON; the image
dir defaults under *tmp_path*). *llm_cls* may be the mock vision
client (the default — ``text_parts``/``chat_models`` state)."""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("images", True)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
llm = llm_cls()
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return llm
def _cleanup_source(db, source: str) -> None:
for doc in db.scalars(select(Document).where(Document.source == source)).all():
db.delete(doc)
db.commit()
#: A markdown file that references ``pic.png`` on line 3 — one context
#: paragraph before and one after (the task-02 window).
GUIDE_MD = (
"The restore order diagram explains the backup procedure.\n"
"\n"
"![Restore order](pic.png)\n"
"\n"
"Only run the restore script after the checks pass.\n"
)
def _expected_block(source: str) -> str:
"""The task-03-shape combined string *GUIDE_MD* yields for
``<source>/guide.md`` (the disk scan AND the task-04 pass build
exactly this)."""
return (
f"Referenced in {source}/guide.md:\n"
'- Alt text: "Restore order"\n'
" Context: The restore order diagram explains the backup procedure.\n"
"\n"
"Only run the restore script after the checks pass."
)
#: The ``IMAGE_CONTEXT:`` record the markdown-side bookkeeping (phase
#: 125, task 04, work 1) stores for *GUIDE_MD* — the reference on line
#: 3, 2 context paragraphs.
RECORD = (
"IMAGE_CONTEXT:\n"
'- [pic.png] (line 3): "Restore order" — 2 paragraphs of context'
)
def test_markdown_referenced_image_gets_context_and_enriched_chunks(
db, tmp_path
) -> None:
"""A markdown file referencing an image in the same source → sync
with ``images=True`` (the REAL seam — no patch) → the vision call
carries the context after the locked prompt, the image doc's
``image_context`` is set, its ``content`` includes the context
(context first, then the description), and its chunks are embedded
from the enriched content (the ``is_summary`` chunk mirrors it)."""
source_root = tmp_path / "anchoredkb"
source_root.mkdir()
(source_root / "guide.md").write_text(
"The backup procedure diagram below shows the restore order.\n"
"\n"
"![Backup procedure diagram](diagram.png)\n"
"\n"
"Run the restore script only after the diagram's checks pass.\n",
encoding="utf-8",
)
(source_root / "diagram.png").write_bytes(PNG_1X1)
llm = _image_llm(tmp_path)
try:
summary = asyncio.run(import_sources([source_root], llm, session=db))
assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0)
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "diagram.png"
)
)
assert doc is not None and doc.is_image is True and doc.image_path is not None
expected_context = (
f"Referenced in {source_root.name}/guide.md:\n"
'- Alt text: "Backup procedure diagram"\n'
" Context: The backup procedure diagram below shows the restore order.\n"
"\n"
"Run the restore script only after the diagram's checks pass."
)
# The vision model received the context — appended AFTER the
# locked prompt (the ``IMAGE_DESCRIPTION_MODE`` marker is
# untouched). The describe call (the ONLY multimodal one) went
# to the CHAT model (``chat_models`` also carries the markdown
# doc's ``lite`` summary call — the phase-30 path).
assert llm.chat_models[0] == llm.settings.llm_chat_model
assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"]
# ``image_context`` stores the combined context (task 05's
# retrieval display reads from here).
assert doc.image_context == expected_context
# The enriched content — context FIRST, then the description —
# IS the summary (the phase-122 convention) and the chunks are
# embedded from it (768 dims, the summary chunk mirrors it). The
# plain-text packer joins the enriched paragraphs with single
# newlines (the chunker's normalization, PLAN §5), so the content
# chunk carries the packed form and the summary chunk the raw
# text.
expected_content = f"{expected_context}\n\n{DESCRIPTION}"
assert doc.content == expected_content
assert doc.summary == expected_content
packed_content = "\n".join(expected_content.split("\n\n"))
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
content_chunks = [c for c in chunks if not c.is_summary]
assert [c.content for c in content_chunks] == [packed_content]
summary_chunks = [c for c in chunks if c.is_summary]
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
assert summary_chunks[0].content == expected_content
# Both enriched forms reached the embedder (the markdown doc's
# two texts complete the four embedded strings).
embedded = [t for batch in llm.calls for t in batch]
assert len(embedded) == 4
assert packed_content in embedded and expected_content in embedded
for c in chunks:
assert c.embedding is not None and len(c.embedding) == 768
# The referencing markdown stays a plain text doc (the
# ``image_context`` column is an image-doc field — NULL).
md = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "guide.md"
)
)
assert md is not None and md.is_image is False and md.image_context is None
finally:
_cleanup_source(db, source_root.name)
def test_standalone_image_keeps_null_context_and_bare_description(db, tmp_path) -> None:
"""A standalone image (no markdown reference in the source — a
sibling markdown exists but references nothing) → ``image_context``
NULL, ``content`` the bare description, the vision message the bare
locked prompt — byte-identical to pre-phase-125 (the chunk set and
the embedded text are exactly the phase-122 shape)."""
source_root = tmp_path / "standalonekb"
source_root.mkdir()
(source_root / "notes.md").write_text("# Notes\n\nBody text.\n", encoding="utf-8")
(source_root / "diagram.png").write_bytes(PNG_1X1)
llm = _image_llm(tmp_path)
try:
summary = asyncio.run(import_sources([source_root], llm, session=db))
assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0)
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "diagram.png"
)
)
assert doc is not None and doc.is_image is True
assert doc.image_context is None, "standalone: the context stays NULL"
assert doc.content == DESCRIPTION, "standalone: the bare description"
assert doc.summary == DESCRIPTION
assert llm.text_parts == [DESCRIBE_PROMPT], "standalone: the bare prompt"
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
assert all(c.content == DESCRIPTION for c in chunks)
for c in chunks:
assert c.embedding is not None and len(c.embedding) == 768
finally:
_cleanup_source(db, source_root.name)
# ---------------------------------------------------------------------------
# Phase 125, task 04 — the markdown-FIRST case: the ``IMAGE_CONTEXT:``
# record on the markdown doc + the post-index enrichment pass.
# ---------------------------------------------------------------------------
def test_markdown_indexed_before_image_stores_record_and_image_picks_it_up(
db, tmp_path
) -> None:
"""Task 04's primary scenario — a markdown is indexed while the
image it references does not yet exist: the reference is stored on
the markdown doc's ``image_context`` (the ``IMAGE_CONTEXT:``
record). The image file then appears and is synced: the image doc
is enriched with the markdown's context (the task-03 disk scan —
the markdown is still on disk), and the markdown's record is NOT
cleared (ASSUMPTION 4)."""
source_root = tmp_path / "beforekb"
source_root.mkdir()
(source_root / "guide.md").write_text(GUIDE_MD, encoding="utf-8")
llm = _image_llm(tmp_path)
try:
# Run 1 — the markdown alone: the record is stored, no image
# doc exists.
s1 = asyncio.run(import_sources([source_root], llm, session=db))
assert (s1.files, s1.added) == (1, 1)
md = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "guide.md"
)
)
assert md is not None and md.image_context == RECORD
assert (
db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "pic.png"
)
)
is None
)
# Run 2 — the image appears: enriched at index time (the
# task-03 disk scan sees the on-disk markdown), the record
# survives.
(source_root / "pic.png").write_bytes(PNG_1X1)
s2 = asyncio.run(import_sources([source_root], llm, session=db))
assert (s2.added, s2.unchanged) == (1, 1)
block = _expected_block(source_root.name)
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "pic.png"
)
)
assert doc is not None and doc.is_image is True
assert doc.image_context == block
expected_content = f"{block}\n\n{DESCRIPTION}"
assert doc.content == expected_content
assert doc.summary == expected_content
# The describe call carried the context after the locked prompt.
assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{block}"]
md2 = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "guide.md"
)
)
assert md2 is not None and md2.image_context == RECORD, "the record is NOT cleared"
finally:
_cleanup_source(db, source_root.name)
def test_enrichment_pass_enriches_when_markdown_file_is_gone_from_disk(
db, tmp_path
) -> None:
"""The post-index enrichment pass (task 04, work 3) — the case the
task-03 disk scan CANNOT cover: the markdown is indexed first (the
record lands on its doc row), its file is then removed from disk
(``prune=False`` keeps the row), and only then does the image
appear. The image is indexed BARE (the disk scan finds no
reference), and the pass enriches it from the markdown doc's
STORED rows: the record identifies the referencing markdown, the
reference is re-extracted from the stored content, and the
re-describe goes out WITH the context (a second vision call — the
first, at index time, was bare)."""
source_root = tmp_path / "passkb"
source_root.mkdir()
(source_root / "guide.md").write_text(GUIDE_MD, encoding="utf-8")
llm = _image_llm(tmp_path)
try:
s1 = asyncio.run(import_sources([source_root], llm, session=db))
assert s1.added == 1
md = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "guide.md"
)
)
assert md is not None and md.image_context == RECORD
# The markdown file disappears from disk (``prune=False`` keeps
# the row); the image appears.
(source_root / "guide.md").unlink()
(source_root / "pic.png").write_bytes(PNG_1X1)
s2 = asyncio.run(import_sources([source_root], llm, session=db))
# The image is added (bare) and then enriched by the pass
# (updated) — both in this run.
assert (s2.files, s2.added, s2.updated) == (1, 1, 1)
block = _expected_block(source_root.name)
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "pic.png"
)
)
assert doc is not None and doc.is_image is True
assert doc.image_context == block
expected_content = f"{block}\n\n{DESCRIPTION}"
assert doc.content == expected_content
assert doc.summary == expected_content
# The re-embedded pipeline (the pass replaced the bare chunks).
packed = "\n".join(expected_content.split("\n\n"))
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
assert [c.content for c in chunks if not c.is_summary] == [packed]
assert [c.content for c in chunks if c.is_summary] == [expected_content]
# Two vision calls: the bare index-time describe (the disk scan
# saw no reference) and the pass's context-carrying re-describe.
assert llm.text_parts == [DESCRIBE_PROMPT, f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{block}"]
# The markdown doc's record survives (ASSUMPTION 4).
md2 = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "guide.md"
)
)
assert md2 is not None and md2.image_context == RECORD
finally:
_cleanup_source(db, source_root.name)
def test_markdown_referencing_existing_image_keeps_null_record(db, tmp_path) -> None:
"""An EXISTING image doc (indexed before the markdown ever
existed): the markdown's record stays NULL when it is indexed (the
task-03 path owns that case — enrichment at IMAGE-index time), the
unchanged image is not re-scanned in that run, and the image picks
up the context on its NEXT re-index (bytes changed) via the
task-03 disk scan — the markdown's record is NULL throughout."""
source_root = tmp_path / "existingkb"
source_root.mkdir()
(source_root / "pic.png").write_bytes(PNG_1X1)
llm = _image_llm(tmp_path)
try:
s1 = asyncio.run(import_sources([source_root], llm, session=db))
assert s1.added == 1
# Run 2 — the markdown appears (the image is unchanged).
(source_root / "guide.md").write_text(GUIDE_MD, encoding="utf-8")
s2 = asyncio.run(import_sources([source_root], llm, session=db))
assert (s2.added, s2.unchanged) == (1, 1)
md = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "guide.md"
)
)
assert md is not None and md.image_context is None
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "pic.png"
)
)
assert doc is not None and doc.image_context is None and doc.content == DESCRIPTION
# Run 3 — the image changes: the re-index re-scans the on-disk
# markdown (task 03) and enriches; the record is still NULL.
(source_root / "pic.png").write_bytes(PNG_1X1 + b"changed")
s3 = asyncio.run(import_sources([source_root], llm, session=db))
assert (s3.added, s3.updated, s3.unchanged) == (0, 1, 1)
block = _expected_block(source_root.name)
doc3 = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "pic.png"
)
)
assert doc3 is not None and doc3.image_context == block
assert doc3.content == f"{block}\n\n{DESCRIPTION}"
md3 = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "guide.md"
)
)
assert md3 is not None and md3.image_context is None
finally:
_cleanup_source(db, source_root.name)
+335
View File
@@ -0,0 +1,335 @@
"""Integration: migration 0023 (documents.image_context) schema contract
(phase 125, task 01).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0022.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0022 → 0023 step
explicitly so later migrations cannot break the pins:
* upgrade 0022 → 0023 → ``image_context`` exists with the full
contract — TEXT, NULLABLE, no server default (NULL is the
standalone-image / pre-phase value, LOCKED A1/A4) — while the 0022
``documents`` schema (``is_image`` + ``image_path`` from phase 122,
``content``/``content_hash`` NOT NULL, the (source, path) unique
constraint) survives;
* a ``documents`` row inserted while the DB is at 0022 (a pre-phase
image doc) reads ``image_context IS NULL`` after the upgrade — the
pre-phase round-trip pin;
* the ORM contract agrees: a freshly inserted image ``Document`` WITH
``image_context`` round-trips it through a fresh session, and one
WITHOUT (standalone image / text doc) reads ``image_context is None``;
* downgrade to 0022 → the column is GONE (A13) while the row and its
0022 columns survive; upgrade back to 0023 → 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 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 = "mig0023"
PATH_TEXT = "notes/readme.md"
PATH_IMAGE = "notes/diagram.png"
@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`` (0023) 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) for one documents
column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" 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 _insert_sql(db: Session, path: str) -> uuid.UUID:
"""Insert one documents row with the PRE-0023 column set (the 0022
shape — ``image_context``, when present, is omitted so its NULL
backfill is what the row reads)."""
row_id = uuid.uuid4()
db.execute(
text(
"INSERT INTO documents (id, source, path, full_path, title,"
" content, content_hash)"
" VALUES (:id, :s, :p, :f, :t, :c, :h)"
),
{
"id": row_id,
"s": SOURCE,
"p": path,
"f": f"/tmp/{SOURCE}/{path}",
"t": path.rsplit("/", 1)[-1],
"c": "content",
"h": "0" * 64,
},
)
db.commit()
return row_id
def _delete(db: Session, row_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM documents WHERE id = :id"), {"id": row_id})
db.commit()
def _unique_column_sets(db: Session) -> set[tuple[str, ...]]:
"""The column tuples of every UNIQUE constraint on ``documents``.
Column-based (not name-based): the integration suite's table
self-heal (``tests/integration/conftest.py``) rewrites bloated
tables via ``CREATE TABLE (LIKE …)``, which renames copied
constraints (PG auto-names them) — the (source, path) uniqueness
contract is what must hold, not the original name.
"""
rows = db.execute(
text(
"SELECT (SELECT string_agg(a.attname, ',' ORDER BY k.ord)"
" FROM unnest(c.conkey) WITH ORDINALITY k(attnum, ord)"
" JOIN pg_attribute a"
" ON a.attrelid = c.conrelid AND a.attnum = k.attnum)"
" FROM pg_constraint c"
" WHERE c.contype = 'u' AND c.conrelid = 'documents'::regclass"
)
).fetchall()
return {tuple(r[0].split(",")) for r in rows}
def test_upgrade_to_0023_adds_image_context(db: Session, alembic: Config) -> None:
"""Upgrade 0022 → 0023: ``image_context`` exists with the full
contract (TEXT, NULLABLE, no server default — NULL is the
standalone-image / pre-phase value, LOCKED A1) and is ABSENT at
0022; a pre-0023 row (a phase-122 image doc) backfills to NULL; and
the 0022 table contract survives the additive upgrade."""
command.downgrade(alembic, "0022") # start from the pre-0023 state
assert _version(db) == "0022"
assert _column(db, "image_context") is None, (
"image_context must be absent at 0022"
)
pre_id = _insert_sql(db, PATH_IMAGE) # the 0022 column set (a pre-125 row)
try:
command.upgrade(alembic, "0023")
assert _version(db) == "0023", "alembic_version must be at 0023"
context = _column(db, "image_context")
assert context is not None, "documents.image_context is missing"
assert context[0] == "text", "image_context must be TEXT"
assert context[1] == "YES", "image_context must be NULLABLE"
assert context[2] is None, (
"image_context must carry NO server default — NULL is the"
" standalone-image / pre-phase value (A1, A4)"
)
# The pre-0023 row (inserted with the 0022 column set) reads
# NULL — every pre-phase row keeps its byte-identical state.
row = db.execute(
text("SELECT image_context FROM documents WHERE id = :id"),
{"id": pre_id},
).fetchone()
assert row is not None, "the pre-0023 row must survive the upgrade"
assert row[0] is None, (
"a pre-phase image doc must backfill image_context to NULL"
)
# A row written without the column reads the same (an omitted
# image_context inserts NULL — the text-doc / standalone value).
new_id = _insert_sql(db, PATH_TEXT)
try:
backfilled = db.execute(
text("SELECT image_context FROM documents WHERE id = :id"),
{"id": new_id},
).fetchone()
assert backfilled == (None,), (
"an omitted image_context must read NULL"
)
finally:
_delete(db, new_id)
# The 0022 schema survives the additive upgrade.
is_image = _column(db, "is_image")
assert (
is_image is not None
and is_image[0] == "boolean"
and is_image[1] == "NO"
and is_image[2] == "false"
), "documents.is_image (0022) must keep its 0022 contract"
image_path = _column(db, "image_path")
assert (
image_path is not None
and image_path[0] == "text"
and image_path[1] == "YES"
), "documents.image_path (0022) must keep its 0022 contract"
content = _column(db, "content")
assert content is not None and content[0] == "text" and content[1] == "NO", (
"documents.content (0001) must keep its 0022 contract"
)
assert ("source", "path") in _unique_column_sets(db), (
"the (source, path) unique constraint must survive the upgrade"
)
finally:
_delete(db, pre_id)
def test_orm_image_context_round_trip(db: Session, alembic: Config) -> None:
"""The ORM contract agrees with the column contract: a freshly
inserted image ``Document`` WITH ``image_context`` round-trips the
string through a FRESH session, and one WITHOUT it (a standalone
image, LOCKED A1/A4) reads ``image_context is None``."""
command.upgrade(alembic, "head")
enriched = Document(
source=SOURCE,
path=PATH_IMAGE,
full_path=f"/tmp/{SOURCE}/{PATH_IMAGE}",
title="diagram",
content="A description of the diagram.",
content_hash="1" * 64,
is_image=True,
image_path="/srv/bor-images/diagram.png",
image_context=(
"![Deployment diagram](diagram.png)\n\nThe diagram\n"
"shows the cluster layout and how the VMs connect."
),
)
standalone = Document(
source=SOURCE,
path="standalone.png",
full_path=f"/tmp/{SOURCE}/standalone.png",
title="standalone",
content="A description of a standalone image.",
content_hash="2" * 64,
is_image=True,
image_path="/srv/bor-images/standalone.png",
)
db.add(enriched)
db.add(standalone)
db.commit()
try:
with SessionLocal() as fresh:
reloaded_enriched = fresh.get(Document, enriched.id)
assert reloaded_enriched is not None, "the image row must be readable"
assert reloaded_enriched.image_context == enriched.image_context, (
"image_context must round-trip the exact context text"
)
reloaded_standalone = fresh.get(Document, standalone.id)
assert reloaded_standalone is not None
assert reloaded_standalone.image_context is None, (
"an omitted image_context must read NULL (standalone)"
)
finally:
_delete(db, enriched.id)
_delete(db, standalone.id)
def test_downgrade_to_0022_drops_the_column(db: Session, alembic: Config) -> None:
"""Downgrade 0023 → 0022: the ``image_context`` column is gone
(A13 — fully reversible) while the row, its 0022 columns (including
the phase-122 image pair) and the table's unique constraint
survive."""
command.upgrade(alembic, "head")
row = Document(
source=SOURCE,
path=PATH_IMAGE,
full_path=f"/tmp/{SOURCE}/{PATH_IMAGE}",
title="diagram",
content="A description of the diagram.",
content_hash="3" * 64,
is_image=True,
image_path="/srv/bor-images/diagram.png",
image_context="![alt](diagram.png)\n\nSurrounding paragraph.",
)
db.add(row)
db.commit()
try:
command.downgrade(alembic, "0022")
assert _version(db) == "0022"
assert _column(db, "image_context") is None, (
"image_context must be dropped"
)
surviving = db.execute(
text(
"SELECT source, path, content, content_hash, is_image,"
" image_path FROM documents WHERE id = :id"
),
{"id": row.id},
).fetchone()
assert surviving is not None, "the row must survive the column drop"
assert surviving[0] == SOURCE and surviving[1] == PATH_IMAGE
assert surviving[2] == "A description of the diagram."
assert surviving[3] == "3" * 64
assert surviving[4] is True, "is_image (0022) must survive the drop"
assert surviving[5] == "/srv/bor-images/diagram.png"
assert ("source", "path") in _unique_column_sets(db), (
"the (source, path) unique constraint 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 0022, then upgrade back to 0023: the column is back
with the full contract (TEXT, NULLABLE, no server default) — a
clean upgrade → downgrade → upgrade round-trip (A13)."""
command.downgrade(alembic, "0022")
command.upgrade(alembic, "head")
assert _version(db) == "0023", (
"round-trip upgrade must land at head (0023)"
)
context = _column(db, "image_context")
assert context is not None, "documents.image_context must be back"
assert context[0] == "text", "image_context must be TEXT after the round-trip"
assert context[1] == "YES", (
"image_context must be NULLABLE after the round-trip"
)
assert context[2] is None, (
"image_context must still carry NO server default"
)