8.0 KiB
Text-Layer Grounding — Design Spec
Date: 2026-08-12 · Branch: agent-mode · Status: approved by user (2026-08-12)
Problem
The pipeline is vision-only for extraction, but most CAD-produced drawing sets carry a real vector text layer. Two worst documented failure modes are text problems being solved with pixels:
- Wave-1 text misreads propagate immutably — e.g. job
959e16407573: vision read "(2) 2x6 STUD PACK" where the sheet says "(5)"; text-only downstream specialists treated the misread as ground truth → confident false-positive findings. - Silent extraction loss — failed/under-extracted pages are invisible
(job
475a6f184dd1: 42% extraction loss), producing falsemissing_expected_sheetswarnings and missed conflicts.
Priority (user, 2026-08-12): reduce false positives and missed items; more accurate conflicts.
Approach
Extract the PDF text layer deterministically (PyMuPDF) once per job, and make it a first-class citizen at three points: extractor grounding, the grounding guard, and the wave-5b verifier (as text oracle + high-DPI evidence crops).
Inspired by hamzaabduljabbar/construction-drawing-analyzer (patterns only —
its license is source-available/no-resale; all code here is original).
Components
1. New module backend/text_layer.py (deterministic, no LLM)
extract_text_layers(pdf_path) -> Dict[int, dict]— per 1-based page:{"text": str, "words": [{"text", "bbox": (x0,y0,x1,y1)}, ...], "has_text_layer": bool}. Pages with <TEXT_LAYER_MIN_CHARSof text arehas_text_layer=False(scanned/raster sheets stay vision-only; logged).find_evidence_bbox(words, needle) -> bbox | None— best-effort fuzzy substring match of an evidencesource_textagainst word sequence; returns union rect of matched words.render_crop(pdf_path, page_number, bbox, dpi, margin_pts) -> bytes— PyMuPDFpage.get_pixmap(clip=rect, dpi=dpi)→ JPEG bytes.
Both runners call extract_text_layers right after convert_pdf_to_images
and attach page["text_layer"] = <text or None> to each page dict. Word
positions stay in a separate page_words: Dict[int, list] runner-local map
(not attached to page dicts — they get serialized).
2. Extractor grounding (both pipelines)
- Static paragraph added to
_EXTRACTOR_SYSTEM_TEMPLATEinbackend/prompts.py(no new placeholder): when a TEXT LAYER block is present in the user message it is authoritative for alphanumeric content (counts, dimensions, member tags, notes); the image is for geometry, symbols, linework, and anything absent from the text layer. - Text-layer content is appended programmatically at each extractor call
site (classic
extractor.py::_extract_one, agentextractors.py::SheetExtractorAgent.run) — NOT a new{placeholder}in the shared template (two-render-path trap:render()silently leaves missing keys as literals). Block capped atTEXT_LAYER_MAX_CHARS. Format:\n\nTEXT LAYER (authoritative for alphanumeric content — trust it over the image for numbers, tags, and note text):\n<text>
3. Grounding guard rescue tier (pipeline/extractor.py::_normalize_sheet)
Current guard drops an object when its primary value's digit-runs aren't in
its own source_text. New tier, only when a text layer exists for the page:
- digits ⊆ source_text → keep (unchanged)
- digits ⊆ page text layer but ⊄ source_text → keep, stamp
grounding: "text_layer"on the assertion (recall rescue — vision quoted imperfectly but the value is real page text) - otherwise → drop (unchanged)
_is_grounded gains an optional page_text param; existing callers/tests
unaffected. Dropped/ rescued counts logged per page.
4. Verifier: text oracle + high-DPI crops (wave 5b)
Wherever verify scopes are built (agent runner confirmed; classic runner to be checked — integrate at both if present):
- Scope payload gains
text_layer_excerpt: concatenated text of the finding's cited sheets, capped atVERIFY_TEXT_MAX_CHARS.VERIFY_USER_INSTRUCTIONgains a{text_layer}placeholder with instructions to treat it as deterministic page text (verdicts may cite it asactual_text). Both render sites (agent verifier + any classic-path render) must substitute it — grep the template name acrossbackend/agents/andbackend/pipeline/. - When
VERIFY_HI_DPI_CROPSand the page has words: for each evidence item,find_evidence_bboxon the cited page's words; on hit,render_cropatVERIFY_CROP_DPIwith margin → crop images replace full-page images (up toAGENT_CONFLICT_MAX_IMAGES). On any miss/failure → fall back to the current full-page image. Zero-resolved-images ⇒ scope skipped (I2 guard preserved).
5. Coverage signal (recall)
After extraction in both runners: for each page with has_text_layer=True
whose extraction failed or returned 0 objects, log
[TextLayer] Page N: text layer present (M chars) but no objects extracted — possible extraction gap and add the page to the existing gap-finding path
(agent: orchestrator.stats.failed_scopes-style finding; classic: log only).
Config knobs (backend/config.py, env-overridable, documented in .env.example)
| Key | Default | Effect |
|---|---|---|
TEXT_LAYER_ENABLED |
true |
Master switch |
TEXT_LAYER_MIN_CHARS |
20 |
Below this per page → has_text_layer=False |
TEXT_LAYER_MAX_CHARS |
12000 |
Cap per sheet injected into extractor prompt |
VERIFY_TEXT_MAX_CHARS |
8000 |
Cap of text-layer excerpt in verify scope |
VERIFY_HI_DPI_CROPS |
true |
Evidence-located crops in verifier |
VERIFY_CROP_DPI |
300 |
Crop render DPI |
VERIFY_CROP_MARGIN_PTS |
36 |
Padding around evidence bbox (PDF points) |
Known traps (from project history — designed around)
- Two render paths: no new
{placeholder}in extractor templates; the one new placeholder ({text_layer}in VERIFY_USER_INSTRUCTION) substituted at every render site; a render test asserts no{...}literals remain. - ProjectMemory closed registry: no new memory keys. Text artifacts dump
via plain file writes under
outputs/<job>/text/(agent: underagent/). slim_clusters: no new cluster fields — unchanged.- I2 zero-image path: crops replace full-page images only on confident bbox match; never reduce image count to zero.
- Base64 hygiene: page dicts already carry base64;
text_layerstrings must not leak intoclusters.jsondumps — reuse_without_base64pattern if assertions ever carry page refs (they don't today).
Dependencies
PyMuPDF>=1.23 added to requirements.txt (Docker image rebuild picks it up;
pdf2image/poppler unchanged).
Testing
tests/test_text_layer.py— build tiny PDFs with PyMuPDF in-test: extraction,has_text_layerthresholds,find_evidence_bboxhit/miss,render_cropdimensions.- Extractor guard: rescue-tier unit tests (keep-with-flag, still-drop, unchanged behavior without text layer).
- Prompt render test: extractor + verify instructions fully substituted at every site (both pipelines).
- Runner-level (pattern from
tests/agents/test_wave5b_suppression.py): stubbed waves, assert text layer reaches extract scopes and verify scopes (excerpt present, crop fallback on no-match), fullrun_agent_pipeline. - Full
pytest tests/green before push.
Validation (post-deploy)
Re-run the Cypress set (source PDF persists at
/app/backend/outputs/959e16407573/source.pdf on sits-docker) per the
documented re-run workflow. Success criteria:
- The "(2) vs (5)"-class findings are not generated, or are verifier-refuted with text-layer evidence cited.
- Coverage-gap log lines appear for any page with text but no objects.
- No new
finish_reason=lengthin waves 1/4; cost delta reported vs baseline job.
Out of scope (future PRs)
- Legend/symbol-library wave injected into extractor + critic prompts.
- Deterministic schedule-row recall pass (text-layer tables → assertions).
- pdf-markup export for the review UI.
- Takeoff/polygon geometry (belongs to AI_Takeoffs, not this product).