phase: 125_image_anchor_context
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
"\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"
|
||||
"\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)
|
||||
@@ -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=(
|
||||
"\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="\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"
|
||||
)
|
||||
Reference in New Issue
Block a user