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.
276 lines
9.6 KiB
Python
276 lines
9.6 KiB
Python
"""Unit tests: markdown image-reference extraction (phase 125, task 02).
|
|
|
|
``extract_image_refs`` is a pure function (LOCKED A1 — the storage
|
|
content of ``Document.image_context``), so the tests pin the exact
|
|
contract the enrichment pipeline (tasks 03/04) will rely on:
|
|
|
|
* one :class:`ImageRef` per ```` reference outside code
|
|
fences (multiple per line extracted separately, same window);
|
|
* the context window — up to *N* paragraphs before and AFTER the
|
|
reference paragraph, joined with blank lines, the reference
|
|
paragraph itself excluded (the reference is already ``alt_text`` +
|
|
``path``);
|
|
* fence semantics identical to the chunker (PLAN §5) — fences are
|
|
ATOMIC paragraphs (one context unit, blank lines inside don't split
|
|
it) and references inside a fence are literal text — skipped;
|
|
* edge cases: empty alt, start/end of file, headings, paths with
|
|
spaces, nested parens (accepted miss), empty content, no refs,
|
|
1-based ``line_number``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.rag.md_images import CONTEXT_PARAGRAPHS, ImageRef, extract_image_refs
|
|
|
|
|
|
def test_default_constant() -> None:
|
|
assert CONTEXT_PARAGRAPHS == 2
|
|
doc = "P1.\n\nP2.\n\nP3.\n\n\n\nQ1.\n\nQ2.\n\nQ3.\n"
|
|
assert extract_image_refs(doc) == extract_image_refs(doc, context_paragraphs=2)
|
|
|
|
|
|
def test_single_reference_with_surrounding_paragraphs() -> None:
|
|
doc = (
|
|
"# Title\n"
|
|
"\n"
|
|
"First paragraph.\n"
|
|
"\n"
|
|
"Second paragraph.\n"
|
|
"\n"
|
|
"\n"
|
|
"\n"
|
|
"Third paragraph.\n"
|
|
"\n"
|
|
"Fourth paragraph.\n"
|
|
)
|
|
refs = extract_image_refs(doc)
|
|
assert refs == [
|
|
ImageRef(
|
|
path="image.png",
|
|
alt_text="alt",
|
|
context=(
|
|
"First paragraph.\n\n"
|
|
"Second paragraph.\n\n"
|
|
"Third paragraph.\n\n"
|
|
"Fourth paragraph."
|
|
),
|
|
line_number=7,
|
|
)
|
|
]
|
|
|
|
|
|
def test_multiple_references_on_different_lines() -> None:
|
|
doc = (
|
|
"Intro.\n"
|
|
"\n"
|
|
"Para A.\n"
|
|
"\n"
|
|
"\n"
|
|
"\n"
|
|
"Para B.\n"
|
|
"\n"
|
|
"Para C.\n"
|
|
"\n"
|
|
"Para D.\n"
|
|
"\n"
|
|
"\n"
|
|
"\n"
|
|
"Outro.\n"
|
|
)
|
|
refs = extract_image_refs(doc)
|
|
assert [r.path for r in refs] == ["one.png", "two.png"]
|
|
assert [r.alt_text for r in refs] == ["one", "two"]
|
|
assert [r.line_number for r in refs] == [5, 13]
|
|
# Each gets its OWN window (N=2 around its own paragraph — spaced
|
|
# apart so the windows don't contain the other reference line).
|
|
assert refs[0].context == "Intro.\n\nPara A.\n\nPara B.\n\nPara C."
|
|
assert refs[1].context == "Para C.\n\nPara D.\n\nOutro."
|
|
|
|
|
|
def test_multiple_references_on_same_line_share_window() -> None:
|
|
doc = "Before.\n\nSee  and  here.\n\nAfter.\n"
|
|
refs = extract_image_refs(doc)
|
|
assert [r.path for r in refs] == ["a.png", "b.png"]
|
|
assert [r.alt_text for r in refs] == ["a", "b"]
|
|
assert [r.line_number for r in refs] == [3, 3]
|
|
# Same reference paragraph → same context, reference line excluded.
|
|
assert refs[0].context == "Before.\n\nAfter."
|
|
assert refs[1].context == "Before.\n\nAfter."
|
|
|
|
|
|
def test_reference_in_code_fence_is_skipped() -> None:
|
|
doc = (
|
|
"Intro.\n"
|
|
"\n"
|
|
"```\n"
|
|
"\n"
|
|
"```\n"
|
|
"\n"
|
|
"\n"
|
|
)
|
|
refs = extract_image_refs(doc)
|
|
assert [r.path for r in refs] == ["real.png"]
|
|
# The fence is an ATOMIC paragraph and counts as one context unit
|
|
# inside the N=2 window (chunker semantics, PLAN §5).
|
|
assert refs[0].context == "Intro.\n\n```\n\n```"
|
|
|
|
|
|
def test_fence_with_blank_lines_inside_is_one_atomic_block() -> None:
|
|
doc = (
|
|
"P1.\n"
|
|
"\n"
|
|
"```\n"
|
|
"line one\n"
|
|
"\n"
|
|
"line three\n"
|
|
"```\n"
|
|
"\n"
|
|
"\n"
|
|
)
|
|
refs = extract_image_refs(doc)
|
|
assert len(refs) == 1
|
|
# The fence (with its inner blank line) is ONE atomic context unit,
|
|
# occupying a single slot of the N=2 window before the reference
|
|
# (if it split on the inner blank line, the window would hold only
|
|
# "P1" + the fence's first lines instead of P1 + the whole fence).
|
|
assert refs[0].context == "P1.\n\n```\nline one\n\nline three\n```"
|
|
|
|
|
|
def test_tilde_fence_and_long_fence_markers() -> None:
|
|
doc = "~~~\n\n~~~\n\n\n"
|
|
assert [r.path for r in extract_image_refs(doc)] == ["keep.png"]
|
|
# Four-backtick fence (marker = first three chars), inner blank line.
|
|
doc = "````\n\n\n````\n\n\n"
|
|
assert [r.path for r in extract_image_refs(doc)] == ["keep2.png"]
|
|
|
|
|
|
def test_unclosed_fence_skips_rest_of_file() -> None:
|
|
doc = "```\n\nmore text after an unclosed fence\n"
|
|
assert extract_image_refs(doc) == []
|
|
|
|
|
|
def test_reference_with_no_alt_text() -> None:
|
|
refs = extract_image_refs("")
|
|
assert refs == [
|
|
ImageRef(path="image.png", alt_text="", context="", line_number=1)
|
|
]
|
|
|
|
|
|
def test_reference_at_start_of_file() -> None:
|
|
doc = "\n\nP1.\n\nP2.\n\nP3.\n"
|
|
refs = extract_image_refs(doc)
|
|
assert len(refs) == 1
|
|
# Only paragraphs after exist; the N=2 cap still applies (P3 left out).
|
|
assert refs[0].context == "P1.\n\nP2."
|
|
assert refs[0].line_number == 1
|
|
|
|
|
|
def test_reference_at_end_of_file() -> None:
|
|
doc = "P1.\n\nP2.\n\nP3.\n\nP4.\n\n\n"
|
|
refs = extract_image_refs(doc)
|
|
assert len(refs) == 1
|
|
# Only paragraphs before exist; the N=2 window is the two paragraphs
|
|
# immediately before the reference (P1 and P2 left out).
|
|
assert refs[0].context == "P3.\n\nP4."
|
|
assert refs[0].line_number == 9
|
|
|
|
|
|
def test_reference_in_heading_line() -> None:
|
|
doc = (
|
|
"Intro paragraph.\n"
|
|
"\n"
|
|
"## \n"
|
|
"\n"
|
|
"Outro paragraph.\n"
|
|
)
|
|
refs = extract_image_refs(doc)
|
|
assert len(refs) == 1
|
|
assert refs[0].alt_text == "alt"
|
|
assert refs[0].path == "image.png"
|
|
assert refs[0].line_number == 3
|
|
# The heading line is the reference paragraph — excluded from the
|
|
# context; only the surrounding text remains.
|
|
assert refs[0].context == "Intro paragraph.\n\nOutro paragraph."
|
|
assert "##" not in refs[0].context
|
|
|
|
|
|
def test_context_paragraphs_zero_and_custom() -> None:
|
|
doc = "P1.\n\nP2.\n\nP3.\n\n\n\nQ1.\n\nQ2.\n\nQ3.\n"
|
|
assert extract_image_refs(doc, context_paragraphs=0)[0].context == ""
|
|
assert extract_image_refs(doc, context_paragraphs=1)[0].context == "P3.\n\nQ1."
|
|
assert (
|
|
extract_image_refs(doc, context_paragraphs=3)[0].context
|
|
== "P1.\n\nP2.\n\nP3.\n\nQ1.\n\nQ2.\n\nQ3."
|
|
)
|
|
# A window LARGER than the file gets whatever is available.
|
|
assert (
|
|
extract_image_refs(doc, context_paragraphs=10)[0].context
|
|
== "P1.\n\nP2.\n\nP3.\n\nQ1.\n\nQ2.\n\nQ3."
|
|
)
|
|
|
|
|
|
def test_negative_context_paragraphs_raises() -> None:
|
|
with pytest.raises(ValueError, match="context_paragraphs must be >= 0"):
|
|
extract_image_refs("", context_paragraphs=-1)
|
|
|
|
|
|
def test_empty_and_reference_free_content() -> None:
|
|
assert extract_image_refs("") == []
|
|
assert extract_image_refs("\n\n \n") == []
|
|
assert extract_image_refs("# No images here\n\nJust plain text.\n") == []
|
|
# A link is not an image reference; an empty target is not a ref.
|
|
assert extract_image_refs("[a link](http://x.example/) ![empty]()\n") == []
|
|
|
|
|
|
def test_image_nested_in_link() -> None:
|
|
refs = extract_image_refs(" wrapped: [](http://x/)\n")
|
|
assert [r.path for r in refs] == ["img.png", "img2.png"]
|
|
|
|
|
|
def test_alt_text_with_nested_brackets() -> None:
|
|
# The alt field is ``[^]]*`` — it may OPEN a bracket (``[``) but
|
|
# the first CLOSING bracket (``]``) ends it: an alt with a nested
|
|
# ``[`` (``a [1``) still captures the path, while an alt that
|
|
# itself CARRIES a ``]`` (``a [1] v2``) breaks the whole match
|
|
# (the ``]`` ends the alt field, and no ``(`` follows) — an
|
|
# accepted miss, the locked regex's limitation (task 02), the
|
|
# alt-side twin of the nested-parens path miss above.
|
|
refs = extract_image_refs("\n")
|
|
assert refs == [ImageRef(path="img.png", alt_text="a [1", context="", line_number=1)]
|
|
assert extract_image_refs("![a [1] v2](img.png)\n") == []
|
|
|
|
|
|
def test_paths_with_spaces_and_parens() -> None:
|
|
# Spaces are fine; surrounding whitespace in the target is stripped.
|
|
refs = extract_image_refs(" and \n")
|
|
assert [r.path for r in refs] == ["diagram v2.png", "x.png"]
|
|
# Nested parens: the regex stops at the first ')' — an accepted
|
|
# miss (rare in practice, phase 125 task 02).
|
|
refs = extract_image_refs(".png)\n")
|
|
assert refs[0].path == "a(b"
|
|
|
|
|
|
def test_line_numbers_are_one_based() -> None:
|
|
doc = "\n\n\n# Head\n\n\n\n"
|
|
refs = extract_image_refs(doc)
|
|
# Three leading blanks + "# Head" + two blanks = lines 1..6; the
|
|
# reference is on line 7.
|
|
assert refs[0].line_number == 7
|
|
|
|
|
|
def test_multi_line_reference_paragraph_contributes_nothing() -> None:
|
|
# The reference sits in the MIDDLE of a paragraph (no blank lines):
|
|
# the whole reference paragraph is excluded from the context.
|
|
doc = (
|
|
"Before.\n"
|
|
"\n"
|
|
"Line one.\n"
|
|
"Line two with \n"
|
|
"Line three.\n"
|
|
"\n"
|
|
"After.\n"
|
|
)
|
|
refs = extract_image_refs(doc)
|
|
assert refs[0].context == "Before.\n\nAfter."
|