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:
File diff suppressed because it is too large
Load Diff
@@ -283,11 +283,21 @@ def _patch_description(
|
||||
"""Pin the task-02 seam (``importer._describe_or_skip``) to
|
||||
*description* and record the BYTES it is handed (the digest-rule
|
||||
pin). Task 03 replaces the seam's body — the importer-side
|
||||
mechanics under test here do not change with it."""
|
||||
mechanics under test here 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``."""
|
||||
handed: list[bytes] = []
|
||||
|
||||
async def _fake(
|
||||
llm: importer.Embedder, *, data: bytes, source: str, rel: str, full_path: Path
|
||||
llm: importer.Embedder,
|
||||
*,
|
||||
data: bytes,
|
||||
source: str,
|
||||
rel: str,
|
||||
full_path: Path,
|
||||
context: str | None = None,
|
||||
) -> str | None:
|
||||
handed.append(data)
|
||||
return description
|
||||
@@ -524,7 +534,13 @@ def test_changed_image_deletes_the_stale_copy_before_replacing(
|
||||
state = {"desc": "v1 description"}
|
||||
|
||||
async def _fake(
|
||||
llm: importer.Embedder, *, data: bytes, source: str, rel: str, full_path: Path
|
||||
llm: importer.Embedder,
|
||||
*,
|
||||
data: bytes,
|
||||
source: str,
|
||||
rel: str,
|
||||
full_path: Path,
|
||||
context: str | None = None,
|
||||
) -> str | None:
|
||||
handed.append(data)
|
||||
return state["desc"]
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""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."
|
||||
@@ -69,6 +69,54 @@ def test_chunks_reference_documents_cascade() -> None:
|
||||
assert fkc.ondelete == "CASCADE"
|
||||
|
||||
|
||||
def test_documents_image_context_column_contract() -> None:
|
||||
"""Phase 125 (task 01, LOCKED A1): ``documents.image_context`` —
|
||||
TEXT, NULLABLE, NO server default. NULL is the standalone-image /
|
||||
pre-phase-125 value (no backfill — A4 keeps those rows
|
||||
byte-identical)."""
|
||||
from sqlalchemy import Text as SAText
|
||||
|
||||
documents = Base.metadata.tables["documents"]
|
||||
col = documents.c["image_context"]
|
||||
assert col.nullable is True, "image_context must be NULLABLE (pre-phase rows)"
|
||||
assert isinstance(col.type, SAText), "image_context must be TEXT"
|
||||
assert col.server_default is None, (
|
||||
"image_context must carry NO server default — NULL is the"
|
||||
" standalone/pre-phase value"
|
||||
)
|
||||
assert col.default is None, (
|
||||
"image_context must carry NO ORM default clause either (an"
|
||||
" omitted column inserts NULL, the text-doc value)"
|
||||
)
|
||||
|
||||
|
||||
def test_document_image_context_orm_parses() -> None:
|
||||
"""A freshly constructed ``Document`` (no DB) accepts the field in
|
||||
both states: a set context string round-trips as-is, and
|
||||
``None`` (and the omitted kwarg) read NULL — the standalone-image
|
||||
/ pre-phase state."""
|
||||
from app.models import Document
|
||||
|
||||
base = dict(
|
||||
source="s",
|
||||
path="p.md",
|
||||
full_path="/tmp/s/p.md",
|
||||
title="p",
|
||||
content="c",
|
||||
content_hash="1" * 64,
|
||||
)
|
||||
enriched = Document(**base, image_context="some context")
|
||||
assert enriched.image_context == "some context"
|
||||
|
||||
explicit_null = Document(**base, image_context=None)
|
||||
assert explicit_null.image_context is None
|
||||
|
||||
omitted = Document(**base)
|
||||
assert omitted.image_context is None, (
|
||||
"an omitted image_context must read NULL (the text-doc value)"
|
||||
)
|
||||
|
||||
|
||||
def test_documents_unique_source_path() -> None:
|
||||
documents = Base.metadata.tables["documents"]
|
||||
uq = [
|
||||
|
||||
@@ -48,6 +48,8 @@ from app.rag.prompts import (
|
||||
SUGGEST_INTRO,
|
||||
TOOLS_SECTION,
|
||||
_base,
|
||||
_document_body,
|
||||
_extract_context_line,
|
||||
build_deflect_prompt,
|
||||
build_high_prompt,
|
||||
build_kb_section,
|
||||
@@ -74,6 +76,9 @@ def _doc(
|
||||
content: str,
|
||||
title: str,
|
||||
summary: str | None = None,
|
||||
*,
|
||||
is_image: bool = False,
|
||||
image_context: str | None = None,
|
||||
) -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
@@ -85,6 +90,9 @@ def _doc(
|
||||
content_hash="0" * 64,
|
||||
created_at=_FIXTURE_CREATED_AT,
|
||||
summary=summary,
|
||||
is_image=is_image,
|
||||
image_path="/tmp/pic.png" if is_image else None,
|
||||
image_context=image_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -1141,3 +1149,301 @@ def test_show_image_clause_is_last_for_every_high_shape() -> None:
|
||||
assert on.index(section) < on.index("<tools>")
|
||||
assert on.index("K8s deploy notes.") < on.index("<tools>") # the summary still seeds
|
||||
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it
|
||||
|
||||
|
||||
# ---------- phase 125 (task 05, LOCKED A4): the image context line ----------
|
||||
# An enriched image doc (tasks 03/04: non-NULL ``image_context`` in the
|
||||
# "Referenced in …" block shape) leads its ``<document>`` suggestion
|
||||
# block with ONE short context line — the referring markdown's identity
|
||||
# + the reference's alt text — before the description, so the LLM knows
|
||||
# WHY the image exists, not only what it shows. Every other doc (every
|
||||
# text doc, every non-enriched image doc) is byte-identical to the
|
||||
# pre-phase body.
|
||||
|
||||
#: The exact stored block shape (tasks 03/04): one "Referenced in"
|
||||
#: block per referring markdown file, blank-line joined; the
|
||||
#: ``Context:`` body may span many lines (the surrounding paragraphs).
|
||||
CTX_SINGLE = (
|
||||
"Referenced in Homelab/homelab/kubernetes.md:\n"
|
||||
'- Alt text: "Cluster layout"\n'
|
||||
" Context: The diagram shows the three Talos nodes and Cilium.\n"
|
||||
"It is referenced again from the runbook."
|
||||
)
|
||||
CTX_TWO_BLOCKS = CTX_SINGLE + "\n\nReferenced in Homelab/ops/runbook.md:\n- Alt text: \"\""
|
||||
CTX_ALT_EMPTY = "Referenced in Homelab/ops/runbook.md:\n- Alt text: \"\""
|
||||
|
||||
#: The task-04 pending record — stored on MARKDOWN (text) docs, with no
|
||||
#: ``Referenced in`` header at all.
|
||||
MD_PENDING_RECORD = (
|
||||
'IMAGE_CONTEXT:\n- [image.png] (line 42): "Alt text here" — 2 paragraphs of context'
|
||||
)
|
||||
|
||||
|
||||
def _image_doc(
|
||||
image_context: str | None = None, summary: str | None = "A diagram of the cluster."
|
||||
) -> Document:
|
||||
return _doc(
|
||||
"cluster-diagram.png",
|
||||
"A diagram of the cluster.",
|
||||
"cluster-diagram",
|
||||
summary=summary,
|
||||
is_image=True,
|
||||
image_context=image_context,
|
||||
)
|
||||
|
||||
|
||||
# ---------- _extract_context_line: the parser contract ----------
|
||||
|
||||
|
||||
def test_extract_context_line_single_block_with_alt() -> None:
|
||||
"""One block with an alt → the short one-line locator: the stored
|
||||
identity (``<source>/<path>`` exactly as the header carries it) +
|
||||
the reference's alt between quotes."""
|
||||
assert _extract_context_line(CTX_SINGLE) == (
|
||||
'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"'
|
||||
)
|
||||
|
||||
|
||||
def test_extract_context_line_context_body_never_renders() -> None:
|
||||
"""The ``Context:`` paragraphs (LOCKED A3: they already reached the
|
||||
vision model at enrichment time) stay out of the line — the line is
|
||||
the locator + purpose, short enough not to bloat the prompt."""
|
||||
line = _extract_context_line(CTX_SINGLE)
|
||||
assert line is not None
|
||||
assert "The diagram shows" not in line
|
||||
assert "referenced again" not in line
|
||||
assert "Context:" not in line
|
||||
assert line.count("\n") == 0 # genuinely ONE line
|
||||
|
||||
|
||||
def test_extract_context_line_empty_alt_renders_the_bare_identity() -> None:
|
||||
"""A reference without an alt (````) → the clause is the
|
||||
bare identity — nothing to quote after the colon."""
|
||||
assert _extract_context_line(CTX_ALT_EMPTY) == "Referenced in Homelab/ops/runbook.md"
|
||||
|
||||
|
||||
def test_extract_context_line_two_blocks_join_with_semicolon() -> None:
|
||||
"""One clause per referring file after a single prefix — the second
|
||||
block's empty alt renders bare."""
|
||||
assert _extract_context_line(CTX_TWO_BLOCKS) == (
|
||||
'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"; '
|
||||
"Homelab/ops/runbook.md"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_context_line_uses_the_first_reference_only() -> None:
|
||||
"""A block with two references (two ``- Alt text:`` bullets) → the
|
||||
FIRST reference's alt is the purpose; the second stays out."""
|
||||
text = (
|
||||
"Referenced in Homelab/a.md:\n"
|
||||
'- Alt text: "first purpose"\n'
|
||||
" Context: some paragraph.\n"
|
||||
'- Alt text: "second purpose"'
|
||||
)
|
||||
assert _extract_context_line(text) == 'Referenced in Homelab/a.md: "first purpose"'
|
||||
assert "second purpose" not in (_extract_context_line(text) or "")
|
||||
|
||||
|
||||
def test_extract_context_line_multiline_context_body_is_ignored() -> None:
|
||||
"""The ``Context:`` value is paragraph-joined (blank lines inside)
|
||||
— the parser walks it without breaking the block."""
|
||||
text = (
|
||||
"Referenced in Homelab/a.md:\n"
|
||||
'- Alt text: "purpose"\n'
|
||||
" Context: para one.\n"
|
||||
"\n"
|
||||
"para two, and it contains a line that\n"
|
||||
"looks like context continuation."
|
||||
)
|
||||
assert _extract_context_line(text) == 'Referenced in Homelab/a.md: "purpose"'
|
||||
|
||||
|
||||
def test_extract_context_line_alt_with_inner_quotes_survives() -> None:
|
||||
"""The block writer does not escape quotes: the greedy alt capture
|
||||
(between the opening quote and the line's final quote) keeps an
|
||||
alt with inner ``"`` intact."""
|
||||
text = 'Referenced in Homelab/x.md:\n- Alt text: "He said "hi""'
|
||||
assert _extract_context_line(text) == 'Referenced in Homelab/x.md: "He said "hi""'
|
||||
|
||||
|
||||
def test_extract_context_line_spurious_header_is_dropped() -> None:
|
||||
"""A look-alike ``Referenced in …:`` line WITHOUT its ``- Alt text:``
|
||||
bullet (context text that merely looks like a header) parses to
|
||||
``None`` — never a clause from the stray line."""
|
||||
assert _extract_context_line(
|
||||
"Referenced in Homelab/fake.md:\nsome context line that is not a bullet"
|
||||
) is None
|
||||
# A header with NO following line at all (end of value) is spurious too.
|
||||
assert _extract_context_line("Referenced in Homelab/fake.md:") is None
|
||||
# Spurious then REAL: only the real block's clause survives.
|
||||
mixed = (
|
||||
"Referenced in Homelab/fake.md:\nsome context line\n\n"
|
||||
+ CTX_SINGLE
|
||||
)
|
||||
assert _extract_context_line(mixed) == (
|
||||
'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"'
|
||||
)
|
||||
|
||||
|
||||
def test_extract_context_line_header_after_header_drops_the_bulletless_one() -> None:
|
||||
"""Two headers in a row: the first (never followed by its bullet) is
|
||||
dropped and the second block parses normally."""
|
||||
text = "Referenced in Homelab/fake.md:\nReferenced in Homelab/real.md:\n- Alt text: \"alt\""
|
||||
assert _extract_context_line(text) == 'Referenced in Homelab/real.md: "alt"'
|
||||
|
||||
|
||||
def test_extract_context_line_markdown_pending_record_is_none() -> None:
|
||||
"""The task-04 ``IMAGE_CONTEXT:`` record (the MARKDOWN doc's stored
|
||||
``image_context``) has no ``Referenced in`` header — it parses to
|
||||
``None`` (and it rides a text doc, which the ``is_image`` guard
|
||||
excludes from the line anyway)."""
|
||||
assert _extract_context_line(MD_PENDING_RECORD) is None
|
||||
|
||||
|
||||
def test_extract_context_line_blank_and_empty_are_none() -> None:
|
||||
assert _extract_context_line("") is None
|
||||
assert _extract_context_line(" \n \t ") is None
|
||||
# Garbage that contains the keyword somewhere is not a block.
|
||||
assert _extract_context_line("I was referenced in Homelab/a.md: yesterday") is None
|
||||
|
||||
|
||||
# ---------- _document_body: the block-body contract ----------
|
||||
|
||||
|
||||
def test_document_body_enriched_image_doc_leads_with_the_context_line() -> None:
|
||||
"""The completion criterion at the body level: the context line
|
||||
leads, a blank line, then the description — the exact two-part
|
||||
shape."""
|
||||
body = _document_body(_image_doc(image_context=CTX_SINGLE))
|
||||
assert body == (
|
||||
'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n\n'
|
||||
"A diagram of the cluster."
|
||||
)
|
||||
|
||||
|
||||
def test_document_body_image_doc_null_context_is_byte_identical_to_pre_phase() -> None:
|
||||
"""LOCKED A4 (the byte-identity criterion): a standalone / pre-phase
|
||||
image doc (NULL ``image_context``) → the body is EXACTLY the
|
||||
stripped summary — no line, no blank, no drift."""
|
||||
doc = _image_doc(image_context=None)
|
||||
assert _document_body(doc) == "A diagram of the cluster."
|
||||
# And the built prompt matches a text twin (same fields, not an
|
||||
# image) to the byte.
|
||||
twin = _doc("cluster-diagram.png", "A diagram of the cluster.", "cluster-diagram")
|
||||
assert build_high_prompt([doc]) == build_high_prompt([twin])
|
||||
|
||||
|
||||
def test_document_body_text_doc_with_image_context_is_unchanged() -> None:
|
||||
"""The guard is ``is_image``: a TEXT doc whose ``image_context``
|
||||
carries the task-04 pending record (the markdown case) keeps the
|
||||
pre-phase body — the record never renders."""
|
||||
doc = _doc("guide.md", "CONTENT", "Guide", summary="Guide summary.",
|
||||
image_context=MD_PENDING_RECORD)
|
||||
assert _document_body(doc) == "Guide summary."
|
||||
assert "IMAGE_CONTEXT" not in build_high_prompt([doc])
|
||||
assert "Referenced in" not in build_high_prompt([doc])
|
||||
|
||||
|
||||
def test_document_body_image_doc_unparsable_context_is_unchanged() -> None:
|
||||
"""Graceful degradation: an image doc whose ``image_context`` parses
|
||||
to nothing (owner-edited / unknown shape) → the description only,
|
||||
exactly pre-phase — the prompt build never raises."""
|
||||
for junk in ("", " ", "referenced in lowercase.md: no header shape", MD_PENDING_RECORD):
|
||||
assert _document_body(_image_doc(image_context=junk)) == "A diagram of the cluster."
|
||||
|
||||
|
||||
def test_document_body_enriched_image_doc_without_summary_precedes_the_preview() -> None:
|
||||
"""The line leads the fallback body too (the preview + marker of
|
||||
LOCKED A5) — the "context before the description" contract holds on
|
||||
both body shapes."""
|
||||
doc = _image_doc(image_context=CTX_SINGLE, summary=None)
|
||||
doc.content = "A" * 400 + "B" * 600 # past the 400-char preview cap
|
||||
body = _document_body(doc)
|
||||
line = 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"'
|
||||
assert body.startswith(line + "\n\n")
|
||||
assert body[len(line) + 2 :] == "A" * 400 + "\n" + TRUNCATION_MARKER
|
||||
assert "B" * 40 not in body # beyond the cut, as pre-phase
|
||||
|
||||
|
||||
def test_enriched_image_doc_with_summary_makes_no_settings_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""House pattern (the LOCKED A5 preview-cap contract): the
|
||||
context-line path for a summary-bearing doc is pure — a dead
|
||||
``get_settings`` must not break such a build (extraction is pure;
|
||||
the cap is read on the fallback path only)."""
|
||||
from app.rag import prompts as prompts_mod
|
||||
|
||||
doc = _image_doc(image_context=CTX_SINGLE)
|
||||
|
||||
def _dead() -> None: # pragma: no cover - must never be called
|
||||
raise AssertionError("get_settings() called for a summarized doc")
|
||||
|
||||
monkeypatch.setattr(prompts_mod, "get_settings", _dead)
|
||||
assert _document_body(doc) == (
|
||||
'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n\n'
|
||||
"A diagram of the cluster."
|
||||
)
|
||||
|
||||
|
||||
# ---------- build_high_prompt: the line in the built block ----------
|
||||
|
||||
|
||||
def test_high_prompt_enriched_image_doc_block_exact_shape() -> None:
|
||||
"""The full block in the built prompt: identity attributes
|
||||
unchanged (phase 106 date appended after title), then the context
|
||||
line, a blank line, the description — the pre-phase block shape
|
||||
with the line spliced in."""
|
||||
doc = _image_doc(image_context=CTX_SINGLE)
|
||||
prompt = build_high_prompt([doc])
|
||||
assert (
|
||||
'<document source="Homelab" path="cluster-diagram.png" '
|
||||
'title="cluster-diagram" date="2024-06-15">\n'
|
||||
'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n'
|
||||
"\n"
|
||||
"A diagram of the cluster.\n"
|
||||
"</document>"
|
||||
) in prompt
|
||||
# The line is the FIRST thing in the block body (before the
|
||||
# description), inside <documents>.
|
||||
line = 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"'
|
||||
block_start = prompt.index('<document source="Homelab"')
|
||||
assert prompt.index("<documents>") < prompt.index(line)
|
||||
assert prompt.index(line) < prompt.index("A diagram of the cluster.")
|
||||
assert block_start < prompt.index(line) < prompt.index("</document>")
|
||||
# The Context: body stays out of the whole prompt.
|
||||
assert "The diagram shows the three Talos nodes" not in prompt
|
||||
|
||||
|
||||
def test_high_prompt_enriched_image_doc_mixed_with_text_docs() -> None:
|
||||
"""A suggested tier with both text docs and one enriched image doc:
|
||||
the line rides ONLY the image doc's block; the text blocks are
|
||||
byte-identical to their summaries (no line, no drift)."""
|
||||
text_doc = _doc("kubernetes.md", "CONTENT", "K8s", summary="K8s summary.")
|
||||
img = _image_doc(image_context=CTX_SINGLE)
|
||||
prompt = build_high_prompt([text_doc, img])
|
||||
assert prompt.count('Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"') == 1
|
||||
# The text block is the bare summary, exactly as pre-phase.
|
||||
assert (
|
||||
'<document source="Homelab" path="kubernetes.md" '
|
||||
'title="K8s" date="2024-06-15">\nK8s summary.\n</document>'
|
||||
) in prompt
|
||||
# The image block carries the line + description.
|
||||
assert (
|
||||
'title="cluster-diagram" date="2024-06-15">\n'
|
||||
'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n\n'
|
||||
"A diagram of the cluster.\n</document>"
|
||||
) in prompt
|
||||
# Ordering unchanged: the text block (first doc) still precedes the
|
||||
# image block (second doc) — the line never re-orders the tier.
|
||||
assert prompt.index('path="kubernetes.md"') < prompt.index('path="cluster-diagram.png"')
|
||||
|
||||
|
||||
def test_high_prompt_image_doc_without_context_is_byte_identical_to_pre_phase() -> None:
|
||||
"""LOCKED A4 at the prompt level: an image doc with NULL
|
||||
``image_context`` builds the EXACT pre-phase prompt (the text-twin
|
||||
equality — no context line anywhere, not even a stray blank)."""
|
||||
img = _image_doc(image_context=None)
|
||||
twin = _doc("cluster-diagram.png", "A diagram of the cluster.", "cluster-diagram")
|
||||
assert build_high_prompt([img]) == build_high_prompt([twin])
|
||||
assert "Referenced in" not in build_high_prompt([img])
|
||||
|
||||
Reference in New Issue
Block a user