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.
163 lines
6.4 KiB
Python
163 lines
6.4 KiB
Python
"""Markdown image-reference extraction (phase 125, LOCKED A1).
|
|
|
|
Pure functions, no I/O — fully unit-testable. Stdlib only.
|
|
|
|
Markdown documents reference images inline (````). The
|
|
paragraphs AROUND the reference carry the *why* — what the diagram
|
|
illustrates, which procedure it belongs to — context that the
|
|
standalone vision description of the image itself (phase 122) does not
|
|
have. :func:`extract_image_refs` finds every image reference and
|
|
returns it with its alt text, its 1-based line number, and the
|
|
surrounding paragraph context: up to *n* paragraphs before and after
|
|
the reference paragraph (default :data:`CONTEXT_PARAGRAPHS`).
|
|
|
|
Paragraph semantics mirror the chunker (PLAN §5): paragraphs are
|
|
separated by blank lines and code fences are **atomic** — a fenced
|
|
block (possibly containing blank lines) is ONE paragraph, so it counts
|
|
as a single unit of the context window. Only the *references* inside a
|
|
fence are skipped: there they are literal text, not actual images.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
|
|
#: Opening/closing code fence (three or more backticks or tildes,
|
|
#: optionally indented) — the SAME semantics as the chunker's fence
|
|
#: handling (PLAN §5), so "fence" means the same thing here as in
|
|
#: chunking.
|
|
_FENCE_RE = re.compile(r"^(`{3,}|~{3,})")
|
|
|
|
#: Markdown image reference ````. The alt may be EMPTY
|
|
#: (````); the path may contain spaces but not ``)`` — nested
|
|
#: parentheses inside a path are accepted as a miss (rare in practice,
|
|
#: phase 125 task 02).
|
|
_IMAGE_REF_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
|
|
|
|
#: Default number of context paragraphs BEFORE and AFTER the reference
|
|
#: paragraph (LOCKED A1: "alt text + N paragraphs before/after the
|
|
#: reference") — 2 gives ~2-4 sentences of surrounding text.
|
|
CONTEXT_PARAGRAPHS: int = 2
|
|
|
|
|
|
@dataclass
|
|
class ImageRef:
|
|
"""One extracted ```` reference with its context window.
|
|
|
|
* ``path`` — the image path (relative or absolute; surrounding
|
|
whitespace stripped);
|
|
* ``alt_text`` — the alt text (may be empty for ````);
|
|
* ``context`` — the surrounding paragraphs: up to *n* before and
|
|
*n* after the reference paragraph, joined with blank lines. The
|
|
reference paragraph itself is NOT included (the reference is
|
|
already captured as ``alt_text`` + ``path``);
|
|
* ``line_number`` — 1-based line number of the reference.
|
|
"""
|
|
|
|
path: str
|
|
alt_text: str
|
|
context: str
|
|
line_number: int
|
|
|
|
|
|
def _fence_flags(lines: Sequence[str]) -> list[bool]:
|
|
"""Per-line flags: ``True`` on a fence delimiter or inside a fence.
|
|
|
|
Mirrors the chunker (PLAN §5): an opener is three or more backticks
|
|
or tildes (indented allowed); while open, EVERY line — including
|
|
blank ones — is in-fence until the first line starting with the
|
|
same three-character marker.
|
|
"""
|
|
flags: list[bool] = []
|
|
marker: str | None = None
|
|
for line in lines:
|
|
stripped = line.lstrip()
|
|
if marker is None:
|
|
m = _FENCE_RE.match(stripped)
|
|
if m:
|
|
marker = m.group(1)[:3]
|
|
flags.append(True)
|
|
else:
|
|
flags.append(False)
|
|
else:
|
|
flags.append(True)
|
|
if stripped.startswith(marker):
|
|
marker = None
|
|
return flags
|
|
|
|
|
|
def _paragraph_blocks(
|
|
lines: Sequence[str], flags: Sequence[bool]
|
|
) -> tuple[list[tuple[int, int]], list[int]]:
|
|
"""Paragraph blocks as half-open (start, end) line ranges, plus a
|
|
per-line block-index map (``-1`` on blank outside-fence lines).
|
|
|
|
Blocks are separated by blank lines *outside* fences; a fenced code
|
|
block (possibly containing blank lines) is ONE block — atomic (the
|
|
same paragraph semantics the chunker chunks with, PLAN §5).
|
|
"""
|
|
blocks: list[tuple[int, int]] = []
|
|
start: int | None = None
|
|
for i, (line, in_fence) in enumerate(zip(lines, flags, strict=True)):
|
|
if in_fence or line.strip():
|
|
if start is None:
|
|
start = i
|
|
elif start is not None:
|
|
blocks.append((start, i))
|
|
start = None
|
|
if start is not None:
|
|
blocks.append((start, len(lines)))
|
|
line_block = [-1] * len(lines)
|
|
for bi, (s, e) in enumerate(blocks):
|
|
line_block[s:e] = [bi] * (e - s)
|
|
return blocks, line_block
|
|
|
|
|
|
def extract_image_refs(
|
|
content: str, context_paragraphs: int = CONTEXT_PARAGRAPHS
|
|
) -> list[ImageRef]:
|
|
"""Extract every markdown image reference in *content*, with context.
|
|
|
|
Each ```` reference OUTSIDE a code fence becomes one
|
|
:class:`ImageRef` (multiple references on one line are extracted
|
|
separately, each with the same context window). ``context`` joins
|
|
the up-to-*context_paragraphs* paragraphs BEFORE and AFTER the
|
|
reference paragraph with blank lines; the reference paragraph
|
|
itself is excluded (the reference is already captured as
|
|
``alt_text`` + ``path``), and a reference at the start/end of the
|
|
file gets whatever paragraphs are available. Fences are atomic
|
|
paragraphs (one fence block = one context unit), and references
|
|
inside one are literal text — skipped.
|
|
|
|
A negative *context_paragraphs* raises :class:`ValueError` (fail
|
|
loud); 0 means "no surrounding context".
|
|
"""
|
|
if context_paragraphs < 0:
|
|
raise ValueError("context_paragraphs must be >= 0")
|
|
lines = content.splitlines()
|
|
if not lines:
|
|
return []
|
|
flags = _fence_flags(lines)
|
|
blocks, line_block = _paragraph_blocks(lines, flags)
|
|
refs: list[ImageRef] = []
|
|
for lineno, (line, in_fence) in enumerate(zip(lines, flags, strict=True), start=1):
|
|
if in_fence:
|
|
continue
|
|
for m in _IMAGE_REF_RE.finditer(line):
|
|
# The reference line is non-blank, outside-fence content, so
|
|
# it always belongs to a paragraph block (>= 0).
|
|
bi = line_block[lineno - 1]
|
|
before = blocks[max(0, bi - context_paragraphs) : bi]
|
|
after = blocks[bi + 1 : bi + 1 + context_paragraphs]
|
|
context = "\n\n".join("\n".join(lines[s:e]) for s, e in before + after)
|
|
refs.append(
|
|
ImageRef(
|
|
path=m.group(2).strip(),
|
|
alt_text=m.group(1).strip(),
|
|
context=context,
|
|
line_number=lineno,
|
|
)
|
|
)
|
|
return refs
|