- backend/text_layer.py: PyMuPDF text-layer extraction, fuzzy evidence
bbox matching, 300-DPI crop rendering, coverage-gap signal
- extractor (classic + agent): TEXT LAYER block appended at call sites;
grounding guard gains text-layer rescue tier (grounding=text_layer stamp)
- verifier: {text_layer} oracle excerpt + evidence-located hi-DPI crops
replacing full-page images (fallback preserved, I2 guard intact)
- coverage gaps: text-bearing pages with zero extraction -> failed-scope
gap findings (agent) / log-only (classic)
- config knobs: TEXT_LAYER_ENABLED/MIN_CHARS/MAX_CHARS, VERIFY_TEXT_MAX_CHARS,
VERIFY_HI_DPI_CROPS, VERIFY_CROP_DPI, VERIFY_CROP_MARGIN_PTS
- tests: 22 new (text_layer unit, grounding/render, runner-level flow)
Spec: docs/superpowers/specs/2026-08-12-text-layer-grounding-design.md
129 lines
4.9 KiB
Python
129 lines
4.9 KiB
Python
"""Runner-level text-layer flow: excerpt into verify scopes, hi-DPI crop
|
|
replacement with full-page fallback, and coverage-gap findings."""
|
|
|
|
import pytest
|
|
|
|
fitz = pytest.importorskip("pymupdf")
|
|
|
|
import backend.agents.runner as runner_mod
|
|
from backend.agents.base import AgentResult
|
|
from backend.agents.runner import run_agent_pipeline
|
|
|
|
PAGE_TEXT = "(5) 2X6 STUD PACK AT BEARING"
|
|
|
|
|
|
def _make_pdf(path):
|
|
doc = fitz.open()
|
|
page = doc.new_page(width=612, height=792)
|
|
page.insert_text((72, 72), PAGE_TEXT, fontsize=11)
|
|
doc.save(str(path))
|
|
doc.close()
|
|
return str(path)
|
|
|
|
|
|
def _finding(sheets, evidence_text):
|
|
return {
|
|
"issue_id": "C1", "severity": "critical", "confidence": "high",
|
|
"source_stage": "constructability", "sheets": sheets,
|
|
"description": "stud pack conflict",
|
|
"evidence": [{"sheet": sheets[0], "source_text": evidence_text}],
|
|
}
|
|
|
|
|
|
def _stub_agent(artifacts):
|
|
return lambda usage: type("S", (), {
|
|
"name": "stub",
|
|
"run": lambda self, scope: AgentResult(
|
|
scope_id=scope.scope_id, artifacts=list(artifacts)),
|
|
})()
|
|
|
|
|
|
def _patch_pipeline(monkeypatch, finding, verify_sink):
|
|
monkeypatch.setattr(
|
|
runner_mod, "convert_pdf_to_images",
|
|
lambda path: [{"page_number": 1, "base64": "QUJD"}])
|
|
monkeypatch.setattr(runner_mod, "SheetExtractorAgent", _stub_agent([
|
|
{"sheet_number": "S401", "page_number": 1, "level": "roof",
|
|
"discipline": "S", "assertions": [
|
|
{"text": "(5) 2X6 STUD PACK", "object_type": "framing"},
|
|
{"text": "HSS16X4 beam", "object_type": "framing"},
|
|
]},
|
|
]))
|
|
monkeypatch.setattr(runner_mod, "SheetIndexAgent", _stub_agent([{}]))
|
|
monkeypatch.setattr(runner_mod, "JurisdictionAgent", _stub_agent([{}]))
|
|
monkeypatch.setattr(runner_mod, "LinkerAgent", _stub_agent([
|
|
{"key": "c1", "location": "roof beam pocket", "assertions": []},
|
|
]))
|
|
monkeypatch.setattr(runner_mod, "ConflictCriticAgent", _stub_agent([]))
|
|
monkeypatch.setattr(runner_mod, "CodeAgent", _stub_agent([]))
|
|
monkeypatch.setattr(runner_mod, "ConstructabilityAgent",
|
|
_stub_agent([finding]))
|
|
monkeypatch.setattr(runner_mod, "CompletenessAgent", _stub_agent([]))
|
|
monkeypatch.setattr(
|
|
runner_mod, "BrainAgent",
|
|
lambda usage: type("B", (), {
|
|
"run": lambda self, findings, sheet_index, jurisdiction:
|
|
(list(findings), [])})())
|
|
|
|
class _RecordingVerifier:
|
|
name = "verify"
|
|
|
|
def __init__(self, usage):
|
|
pass
|
|
|
|
def run(self, scope):
|
|
verify_sink.append(scope.payload)
|
|
return AgentResult(scope_id=scope.scope_id, artifacts=[{
|
|
"finding_index": scope.payload["finding_index"],
|
|
"status": "confirmed",
|
|
"verdicts": [],
|
|
}])
|
|
|
|
monkeypatch.setattr(runner_mod, "EvidenceVerifierAgent",
|
|
lambda usage: _RecordingVerifier(usage))
|
|
|
|
|
|
def test_verify_scope_carries_text_excerpt_and_crop(monkeypatch, tmp_path):
|
|
"""Evidence text matches the page text layer -> excerpt present and the
|
|
full-page image is replaced by a hi-DPI crop."""
|
|
sink = []
|
|
_patch_pipeline(monkeypatch,
|
|
_finding(["S401"], "(5) 2X6 STUD PACK AT BEARING"), sink)
|
|
pdf = _make_pdf(tmp_path / "set.pdf")
|
|
run_agent_pipeline(pdf, out_dir=str(tmp_path), require_review=False)
|
|
assert len(sink) == 1
|
|
payload = sink[0]
|
|
assert "2X6 STUD PACK" in payload["text_layer_excerpt"]
|
|
assert payload["images_b64"], "crop must never drop all images"
|
|
assert payload["images_b64"][0] != "QUJD", "expected crop, not full page"
|
|
|
|
|
|
def test_verify_scope_falls_back_to_full_page(monkeypatch, tmp_path):
|
|
"""Evidence text not in the text layer -> keep the full-page image."""
|
|
sink = []
|
|
_patch_pipeline(monkeypatch,
|
|
_finding(["S401"], "PENTHOUSE EXHAUST FAN EF-9"), sink)
|
|
pdf = _make_pdf(tmp_path / "set.pdf")
|
|
run_agent_pipeline(pdf, out_dir=str(tmp_path), require_review=False)
|
|
assert len(sink) == 1
|
|
assert sink[0]["images_b64"] == ["QUJD"]
|
|
|
|
|
|
def test_coverage_gap_becomes_gap_finding(monkeypatch, tmp_path):
|
|
"""Text layer present but zero objects extracted -> failed-scope gap
|
|
finding survives into the report."""
|
|
sink = []
|
|
_patch_pipeline(monkeypatch, _finding(["S401"], PAGE_TEXT), sink)
|
|
# Extractor returns a sheet with NO objects despite a real text layer.
|
|
monkeypatch.setattr(runner_mod, "SheetExtractorAgent", _stub_agent([
|
|
{"sheet_number": "S401", "page_number": 1, "level": "roof",
|
|
"discipline": "S", "assertions": []},
|
|
]))
|
|
pdf = _make_pdf(tmp_path / "set.pdf")
|
|
report = run_agent_pipeline(pdf, out_dir=str(tmp_path),
|
|
require_review=False)
|
|
gaps = [f for f in (report.get("validated_issues") or [])
|
|
if f.get("category") == "analysis_gap"]
|
|
assert any("extraction gap" in (g.get("description") or "")
|
|
for g in gaps)
|