feat: refocus on drawings — code/ADA gated off, drawing-integrity wave, Brain-directed clarification
- ENABLE_CODE_REVIEW flag (default off): skips code/ADA/jurisdiction review path in both pipelines; nothing deleted, one env flag to restore. - Per-sheet Drawing Integrity QA wave (agent + classic, default on): dangling refs, on-sheet contradictions, dimension sanity, missing sheet essentials, tag hygiene. New DrawingIntegrityAgent + classic stage. - Broadened conflict critic: intra-sheet + same-discipline contradictions, not just cross-discipline. - Wave 6.5 Brain-directed clarification (bounded hub-and-spoke): Brain names uncertain findings, verify_evidence requests route through the wave-5b verifier; refuted findings suppressed. One planning call + capped verifies, single iteration. Shared _build_verify_scopes across 5b and 6.5. - Config knobs, .env.example, frontend copy, tests (182 passing).
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
# Extraction Coverage Guarantee — Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Eliminate dark sheets (missed pages) and vision-misread content by making wave-1 extraction coverage-guaranteed: deterministic coverage measurement, a text-first retry ladder, deterministic fallback extraction, and text-layer sheet identity recovery.
|
||||
|
||||
**Architecture:** For text-bearing sheets the authoritative alphanumeric content already exists in the PyMuPDF text layer (backend/text_layer.py). Today the LLM transcribes from pixels and we merely *detect* failure post-hoc (coverage_gaps logs; nothing retries). This plan flips wave 1 to: run the vision pass (unchanged, always, on every page) → measure text coverage deterministically per page → if below floor, ADD a text-only structuring pass (LLM segments the text layer, no image, no misreads possible) and MERGE its objects into the vision results — vision keeps everything it found, text structuring fills what it missed → if still below floor, emit deterministic stub objects straight from the text layer so NO text-bearing page ever contributes zero objects. Sheet identity is recovered from the text layer when the LLM drops the header. Vision stays the only source for graphical content (symbols, geometry, line work) and the only path for scanned pages.
|
||||
|
||||
**Tech Stack:** Python 3.14, PyMuPDF (already a dep), existing call_json LLM plumbing, pytest.
|
||||
|
||||
---
|
||||
|
||||
## Root-Cause Diagnosis (why this keeps happening)
|
||||
|
||||
Confirmed against Cypress job 3e01d5baba32 (38-page Verizon set) and code:
|
||||
|
||||
**Missed sheets (pages 8 = S202 wood notes, 10 = S204 lap-splice tables, 18 = A102 REFLECTED CEILING PLAN — zero assertions each):**
|
||||
1. The extractor prompt (backend/prompts.py:277) is biased toward physical "construction objects" (rooms, doors, fixtures). Notes/table-dense sheets have few, so the model returns a bare array with ONE generic summary object (log: `wrapping bare objects array (1 items, no sheet header)`).
|
||||
2. The grounding guard (backend/pipeline/extractor.py:178 `_is_grounded`) drops that summary object as ungrounded → 0 objects.
|
||||
3. `_wrap_bare_list` (backend/agents/extractors.py:33) converts the 1-item bare array into a valid dict, so the compact retry (extractors.py:71) NEVER fires — it only triggers when parsing fully fails. A 1-object page counts as "success".
|
||||
4. `coverage_gaps()` (backend/text_layer.py:211) only LOGS the gap and adds a failed_scope note. No retry, no fallback. The page is silently dark for every downstream wave.
|
||||
5. Sheet identity comes ONLY from the LLM reading the title block in the image. 7/38 Cypress pages ended with `sheet_number=None` (4 of them WITH assertions: pages 22, 30, 31, 37), so they can't join sheet-keyed scopes and corrupt `missing_expected_sheets` downstream.
|
||||
|
||||
**Completely incorrect information:**
|
||||
1. Vision misreads of dense alphanumeric content (the "(2) vs (5) 2x6 STUD PACK" family). The wave-1.5 rescue tier catches invented numbers but is a SET subset test — it cannot catch SWAPPED numbers (documented in docs/superpowers/specs/2026-08-12-text-layer-grounding-design.md).
|
||||
2. Gemini thinking tokens count against max_tokens → `recovered truncated JSON` silently drops tail objects (bottom/right of sheet vanishes). Nothing flags the page as degraded.
|
||||
3. The model paraphrases `source_text`; the guard only checks digit-run/token overlap, so plausible-but-wrong values pass.
|
||||
4. `JSON parse error (giving up)` → classic path returns an empty "extraction failed" sheet (extractor.py:282-291); the page vanishes from analysis while `sheets_analyzed` still counts it.
|
||||
|
||||
**Cornerstone principles for the fix:**
|
||||
1. If a page has a text layer, the truth is already deterministic and free. The LLM's job on such pages is STRUCTURING, not TRANSCRIPTION. Every extracted alphanumeric claim must trace to the text layer; anything that can't is vision-only and gets stamped as such.
|
||||
2. The vision pass is never skipped and never replaced. These are construction documents: symbols, device/fixture locations, geometry, and line work exist only in the image. The text-only rung and the fallback rung are strictly ADDITIVE — they merge into the vision results (deduped by normalized source_text), so a rescue can only add coverage, never subtract graphical content.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Coverage metric module (backend/text_coverage.py)
|
||||
|
||||
**Objective:** Deterministic per-page coverage measurement: what fraction of the text layer is actually represented in extracted objects.
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/text_coverage.py`
|
||||
- Test: `tests/test_text_coverage.py`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```python
|
||||
# tests/test_text_coverage.py
|
||||
from backend.text_coverage import text_coverage, segment_text_layer, fallback_objects
|
||||
|
||||
def test_coverage_full():
|
||||
text = "NOTE 1\nALL LUMBER NO. 2 SOUTHERN PINE\nNOTE 2\nUSE 5/8\" PLYWOOD"
|
||||
objects = [{"source_text": "ALL LUMBER NO. 2 SOUTHERN PINE"},
|
||||
{"source_text": "USE 5/8\" PLYWOOD"}]
|
||||
cov = text_coverage(text, objects)
|
||||
assert cov["covered_lines"] == 2
|
||||
assert cov["total_lines"] == 2
|
||||
assert cov["ratio"] == 1.0
|
||||
|
||||
def test_coverage_zero_on_empty_objects():
|
||||
cov = text_coverage("LINE A\nLINE B\nLINE C", [])
|
||||
assert cov["ratio"] == 0.0 and cov["total_lines"] == 3
|
||||
|
||||
def test_coverage_ignores_short_and_numeric_noise_lines():
|
||||
text = "15\"\n19\"\nA\nB\nREAL NOTE ABOUT FRAMING HERE"
|
||||
cov = text_coverage(text, [{"source_text": "REAL NOTE ABOUT FRAMING HERE"}])
|
||||
# short/noise lines (< MIN_LINE_CHARS or pure dimension ticks) excluded
|
||||
assert cov["total_lines"] == 1 and cov["ratio"] == 1.0
|
||||
|
||||
def test_segment_notes_and_rows():
|
||||
text = "WOOD CONSTRUCTION\n1. \nALL SAWN LUMBER TO BE SOUTHERN PINE.\n2. \nROOF SHEATHING 5/8\" PLYWOOD."
|
||||
segs = segment_text_layer(text)
|
||||
assert any("ALL SAWN LUMBER" in s for s in segs)
|
||||
assert any("ROOF SHEATHING" in s for s in segs)
|
||||
|
||||
def test_fallback_objects_verbatim_and_stamped():
|
||||
objs = fallback_objects("1. \nALL SAWN LUMBER TO BE SOUTHERN PINE.", page_number=8)
|
||||
assert len(objs) == 1
|
||||
assert objs[0]["source_text"] == "ALL SAWN LUMBER TO BE SOUTHERN PINE."
|
||||
assert objs[0]["grounding"] == "text_layer_fallback"
|
||||
assert objs[0]["confidence"] == "low"
|
||||
|
||||
def test_merge_objects_keeps_vision_and_unions_text():
|
||||
vision = [
|
||||
{"source_text": "2X6 WD STUD @ 16\" O.C.", "object_type": "wall"},
|
||||
{"source_text": None, "graphical_basis": "light fixture symbol, grid C-4",
|
||||
"object_type": "lighting_fixture"}, # graphical: exists only in image
|
||||
]
|
||||
text = [
|
||||
{"source_text": "2X6 WD STUD @ 16\" O.C.", "object_type": "wall"}, # dup
|
||||
{"source_text": "ALL LUMBER NO. 2 SOUTHERN PINE", "object_type": "general_note"},
|
||||
]
|
||||
merged = merge_objects(vision, text)
|
||||
assert len(merged) == 3 # dup dropped, note added
|
||||
assert any(o.get("graphical_basis") for o in merged) # graphical kept
|
||||
assert merged[0]["object_type"] == "wall" # vision order preserved
|
||||
|
||||
def test_merge_objects_dedupes_by_normalized_text():
|
||||
a = [{"source_text": "RTU-1: 5 TON, 1600 CFM"}]
|
||||
b = [{"source_text": "rtu 1 5 ton 1600 cfm"}] # same content, different case/punct
|
||||
assert len(merge_objects(a, b)) == 1
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify failure**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_text_coverage.py -v`
|
||||
Expected: FAIL — ModuleNotFoundError: backend.text_coverage
|
||||
|
||||
**Step 3: Implement `backend/text_coverage.py`**
|
||||
|
||||
```python
|
||||
"""text_coverage.py - deterministic extraction-coverage measurement.
|
||||
|
||||
The coverage guarantee: for any page with a usable text layer, measure how
|
||||
much of that layer ended up represented in extracted objects. Pages below
|
||||
the floor route into the extraction retry ladder (agents/extractors.py and
|
||||
pipeline/extractor.py). fallback_objects() is the last rung: stub objects
|
||||
segmented straight from the text layer so no text-bearing page goes dark.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
# Lines below this many meaningful chars are noise (dimension ticks, grid
|
||||
# bubbles, single letters) and excluded from the coverage denominator.
|
||||
MIN_LINE_CHARS = 12
|
||||
# Pure dimension/elevation ticks like 15" or 8' - 0" carry no prose content.
|
||||
_TICK_RE = re.compile(r"^[\d\s'\"/.,-]+$")
|
||||
_WORD_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
def _meaningful_lines(text: str) -> List[str]:
|
||||
lines = []
|
||||
for raw in (text or "").splitlines():
|
||||
line = " ".join(raw.split())
|
||||
if len(line) < MIN_LINE_CHARS or _TICK_RE.match(line):
|
||||
continue
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return " ".join(_WORD_RE.findall((text or "").lower()))
|
||||
|
||||
|
||||
def text_coverage(page_text: str, objects: List[Dict]) -> Dict:
|
||||
"""Fraction of meaningful text-layer lines whose normalized form appears
|
||||
in the concatenated normalized source_text of extracted objects."""
|
||||
lines = _meaningful_lines(page_text)
|
||||
if not lines:
|
||||
return {"total_lines": 0, "covered_lines": 0, "ratio": 1.0}
|
||||
haystack = " ".join(
|
||||
_norm(str(o.get("source_text") or o.get("object_description")
|
||||
or o.get("value") or ""))
|
||||
for o in objects if isinstance(o, dict)
|
||||
)
|
||||
covered = sum(1 for ln in lines if _norm(ln) and _norm(ln) in haystack)
|
||||
return {
|
||||
"total_lines": len(lines),
|
||||
"covered_lines": covered,
|
||||
"ratio": covered / len(lines) if lines else 1.0,
|
||||
}
|
||||
|
||||
|
||||
def segment_text_layer(text: str) -> List[str]:
|
||||
"""Segment a page text layer into note-sized blocks: numbered notes and
|
||||
contiguous prose runs. PyMuPDF emits each note number on its own line
|
||||
('1. ', '2. ') followed by wrapped text lines; rejoin number->body and
|
||||
merge continuation lines until the next number or blank-line break."""
|
||||
segments: List[str] = []
|
||||
buf: List[str] = []
|
||||
number_re = re.compile(r"^(\d{1,2}[.)]?|[A-Z]\d{0,2}[.)]?)\s*$")
|
||||
|
||||
def flush():
|
||||
joined = " ".join(buf).strip()
|
||||
if len(joined) >= MIN_LINE_CHARS:
|
||||
segments.append(joined)
|
||||
buf.clear()
|
||||
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
flush()
|
||||
continue
|
||||
if number_re.match(line):
|
||||
flush()
|
||||
buf.append(line.rstrip(".)"))
|
||||
continue
|
||||
buf.append(line)
|
||||
# wrapped-note heuristic: a line starting a new sentence after a
|
||||
# period ends the segment
|
||||
if line.endswith(".") and len(" ".join(buf)) > 120:
|
||||
flush()
|
||||
flush()
|
||||
return segments
|
||||
|
||||
|
||||
def fallback_objects(page_text: str, page_number: int,
|
||||
max_objects: int = 200) -> List[Dict]:
|
||||
"""Last-rung deterministic extraction: one stub object per text segment,
|
||||
source_text verbatim from the text layer. confidence=low and
|
||||
grounding=text_layer_fallback make their provenance explicit downstream."""
|
||||
objs = []
|
||||
for idx, seg in enumerate(segment_text_layer(page_text)[:max_objects]):
|
||||
objs.append({
|
||||
"object_id": f"p{page_number}-tl{idx}",
|
||||
"object_type": "general_note",
|
||||
"category": "general",
|
||||
"tag": None,
|
||||
"name": seg[:80],
|
||||
"description": seg,
|
||||
"attributes": {},
|
||||
"location_key": {},
|
||||
"source_text": seg,
|
||||
"graphical_basis": None,
|
||||
"review_uses": ["code_review", "constructability_review"],
|
||||
"confidence": "low",
|
||||
"grounding": "text_layer_fallback",
|
||||
})
|
||||
return objs
|
||||
|
||||
|
||||
def merge_objects(vision_objs: List[Dict], text_objs: List[Dict]) -> List[Dict]:
|
||||
"""Union of vision and text-structured objects. Vision results come first
|
||||
and are never dropped (graphical_basis objects exist only in the image).
|
||||
Text objects are appended unless their normalized source_text is already
|
||||
represented. A merge can only add coverage, never subtract it."""
|
||||
merged = list(vision_objs or [])
|
||||
seen = {_norm(str(o.get("source_text") or ""))
|
||||
for o in merged if isinstance(o, dict)}
|
||||
seen.discard("")
|
||||
for obj in text_objs or []:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
key = _norm(str(obj.get("source_text") or ""))
|
||||
if key and key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(obj)
|
||||
return merged
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify pass**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_text_coverage.py -v`
|
||||
Expected: 7 passed
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/text_coverage.py tests/test_text_coverage.py
|
||||
git commit -m "feat: deterministic text-layer coverage metric + fallback extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Sheet identity recovery from the text layer
|
||||
|
||||
**Objective:** When the LLM drops/misreads the sheet header, recover `sheet_number` (and discipline via existing `discipline_from_sheet_number`) deterministically from the text layer instead of leaving None.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/text_coverage.py` (add `recover_sheet_number`)
|
||||
- Test: `tests/test_text_coverage.py` (add tests)
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```python
|
||||
def test_recover_sheet_number_from_title_block():
|
||||
text = ("WALL SECTIONS\n...\nSheet Information\nS301\n"
|
||||
"Issue Date 05.29.26\nProject Number 25177")
|
||||
assert recover_sheet_number(text) == "S301"
|
||||
|
||||
def test_recover_sheet_number_none_when_absent():
|
||||
assert recover_sheet_number("just some notes about lumber") is None
|
||||
|
||||
def test_recover_prefers_discipline_pattern_over_dates():
|
||||
# 05.29.26 and 25177 must never match
|
||||
text = "Issue Date 05.29.26\nProject Number 25177\nA102 REFLECTED CEILING PLAN"
|
||||
assert recover_sheet_number(text) == "A102"
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_text_coverage.py::test_recover_sheet_number_from_title_block -v`
|
||||
Expected: FAIL — ImportError
|
||||
|
||||
**Step 3: Implement in `backend/text_coverage.py`**
|
||||
|
||||
```python
|
||||
# Sheet ids: 1-2 uppercase letters + 2-3 digits + optional decimal suffix
|
||||
# (S301, A102, M200, E500, LS101, P100, G000). Deliberately excludes pure
|
||||
# numbers (dates, project numbers) and long alphanumerics (member marks).
|
||||
_SHEET_ID_RE = re.compile(r"\b([A-Z]{1,2}\d{2,3}(?:\.\d+)?)\b")
|
||||
_TITLE_HINT_RE = re.compile(
|
||||
r"(?i)sheet\s*(?:information|no|number)?|"
|
||||
r"(floor plan|ceiling plan|elevations?|sections?|details?|schedule|"
|
||||
r"notes|legend|plan)")
|
||||
|
||||
def recover_sheet_number(page_text: str) -> Optional[str]:
|
||||
"""Deterministic sheet id from the text layer. Strategy: collect every
|
||||
sheet-id-shaped token, prefer ones appearing near title words or in the
|
||||
last ~15%% of the page (title block lives at the drawing edge)."""
|
||||
text = page_text or ""
|
||||
cands = _SHEET_ID_RE.findall(text)
|
||||
if not cands:
|
||||
return None
|
||||
tail = text[int(len(text) * 0.85):]
|
||||
for cand in reversed(_SHEET_ID_RE.findall(tail)):
|
||||
return cand
|
||||
return cands[0]
|
||||
```
|
||||
|
||||
(Add `from typing import Optional` to the imports.)
|
||||
|
||||
**Step 4: Run to verify pass**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_text_coverage.py -v`
|
||||
Expected: all pass (10 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/text_coverage.py tests/test_text_coverage.py
|
||||
git commit -m "feat: deterministic sheet-number recovery from text layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Text-only structuring prompt (no image)
|
||||
|
||||
**Objective:** Second rung of the ladder: give the LLM the raw text layer and ask it to segment EVERY note/row/callout into objects with verbatim source_text. No image = no vision misreads for alphanumerics; far cheaper than the vision pass.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/prompts.py` (append after EXTRACTOR_USER_INSTRUCTION, ~line 282)
|
||||
- Test: `tests/agents/test_extraction_ladder.py` (prompt-content assertions only; rendering tested in Task 4)
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```python
|
||||
# tests/agents/test_extraction_ladder.py
|
||||
from backend.prompts import TEXT_STRUCTURING_SYSTEM_PROMPT, TEXT_STRUCTURING_USER_INSTRUCTION
|
||||
|
||||
def test_text_structuring_prompt_demands_verbatim_and_completeness():
|
||||
assert "verbatim" in TEXT_STRUCTURING_USER_INSTRUCTION.lower()
|
||||
assert "every" in TEXT_STRUCTURING_USER_INSTRUCTION.lower()
|
||||
assert "{text_layer}" in TEXT_STRUCTURING_USER_INSTRUCTION
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/agents/test_extraction_ladder.py -v`
|
||||
Expected: FAIL — ImportError
|
||||
|
||||
**Step 3: Append to `backend/prompts.py`**
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2b - text-only structuring (extraction retry ladder, rung 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEXT_STRUCTURING_SYSTEM_PROMPT = """You are a construction document structuring engine.
|
||||
You receive the deterministic text layer extracted from one drawing sheet. It is complete and authoritative.
|
||||
Your ONLY job is to segment it into structured objects. You are NOT reading an image. You must NOT invent, complete, or correct any text.
|
||||
Rules:
|
||||
- Every numbered note, schedule row, callout, tag, legend entry, and title-block field becomes its own object.
|
||||
- source_text must be copied VERBATIM from the input, character-for-character. Never paraphrase.
|
||||
- Cover the ENTIRE input. Omitting a note is a failure. When unsure of an object's type, use general_note with confidence low.
|
||||
- Numbers, model numbers, dimensions, and tags must appear in source_text exactly as in the input.
|
||||
Respond only with valid JSON."""
|
||||
|
||||
TEXT_STRUCTURING_USER_INSTRUCTION = """Segment this sheet's text layer into structured construction objects.
|
||||
Respond ONLY with a valid JSON object - no markdown fences:
|
||||
{ "sheet": { "sheet_number": "string or null", "sheet_title": "string or null", "discipline": "string or null", "drawing_type": "string or null", "level": "string or null", "scale": "string or null" }, "objects": [ { "object_id": "string", "object_type": "room | door | window | wall | finish | ceiling | dimension | grid | callout | keynote | general_note | equipment | plumbing_fixture | mechanical_equipment | electrical_device | lighting_fixture | structural_element | schedule_reference | symbol | abbreviation", "category": "architectural | structural | mechanical | electrical | plumbing | code | general", "tag": "string or null", "name": "string or null", "description": "string or null", "attributes": { "attribute_name": "attribute_value" }, "location_key": { "room_number": "string or null", "grid": "string or null", "detail_reference": "string or null" }, "source_text": "VERBATIM text copied from the input", "graphical_basis": null, "review_uses": [ "schedule_comparison", "cross_discipline_coordination", "code_review", "constructability_review" ], "confidence": "high | medium | low" } ], "unresolved_items": [] }
|
||||
Optional sheet hint: {sheet_hint}
|
||||
|
||||
TEXT LAYER (segment ALL of it):
|
||||
{text_layer}"""
|
||||
```
|
||||
|
||||
NOTE the two render sites you will add in Tasks 4-5 substitute `{sheet_hint}` and `{text_layer}` with str.replace directly (NOT via render()/call_stage) — this matches the wave-1.5 pattern and avoids the classic-path literal-placeholder leak documented in the project pitfalls.
|
||||
|
||||
**Step 4: Run to verify pass**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/agents/test_extraction_ladder.py -v`
|
||||
Expected: 1 passed
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/prompts.py tests/agents/test_extraction_ladder.py
|
||||
git commit -m "feat: text-only structuring prompt for extraction retry ladder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Retry ladder in the AGENT path (SheetExtractorAgent)
|
||||
|
||||
**Objective:** Replace the binary parse-fail retry with a coverage-driven ladder: vision pass → coverage check → text-only structuring pass → deterministic fallback. Also recover sheet identity and mark truncation-degraded pages.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/agents/extractors.py:63-86` (SheetExtractorAgent.run)
|
||||
- Modify: `backend/config.py` (new knobs, below)
|
||||
- Test: `tests/agents/test_extraction_ladder.py`
|
||||
|
||||
**New config knobs (backend/config.py, follow existing env pattern):**
|
||||
|
||||
```python
|
||||
EXTRACT_COVERAGE_FLOOR = float(os.getenv("EXTRACT_COVERAGE_FLOOR", "0.6"))
|
||||
EXTRACT_TEXT_RETRY_ENABLED = os.getenv("EXTRACT_TEXT_RETRY_ENABLED", "true").lower() == "true"
|
||||
EXTRACT_FALLBACK_ENABLED = os.getenv("EXTRACT_FALLBACK_ENABLED", "true").lower() == "true"
|
||||
EXTRACT_FALLBACK_MAX_OBJECTS = int(os.getenv("EXTRACT_FALLBACK_MAX_OBJECTS", "200"))
|
||||
```
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```python
|
||||
from backend.agents.extractors import SheetExtractorAgent
|
||||
from backend.agents.base import AgentScope, AgentUsage
|
||||
|
||||
def _page(n=8, text="1. \nALL SAWN LUMBER IN CONTACT WITH SOIL TO BE SOUTHERN PINE, PRESSURE TREATED.\n2. \nROOF SHEATHING: 5/8\" PLYWOOD, C-D GRADE, STRUCTURAL I."):
|
||||
return {"page_number": n, "base64": "AAAA", "text_layer": text}
|
||||
|
||||
def test_ladder_falls_back_when_vision_returns_nothing(agent_monkeypatch):
|
||||
# vision pass returns 1 summary object that the guard drops;
|
||||
# text-structuring disabled to exercise the deterministic rung
|
||||
agent_monkeypatch.setattr("backend.agents.extractors.call_json",
|
||||
lambda **kw: [{"name": "general notes", "value": "notes"}])
|
||||
agent_monkeypatch.setattr("backend.config.EXTRACT_TEXT_RETRY_ENABLED", False)
|
||||
agent = SheetExtractorAgent(AgentUsage())
|
||||
scope = AgentScope(scope_id="sheet:8", payload={"page": _page(), "sheet_hint": ""})
|
||||
result = agent.run(scope)
|
||||
sheet = result.artifacts[0]
|
||||
assert sheet["assertions"], "dark sheet must be impossible with fallback enabled"
|
||||
assert all(a.get("grounding") == "text_layer_fallback" for a in sheet["assertions"])
|
||||
assert sheet["coverage"]["ratio"] >= 0.6
|
||||
|
||||
def test_ladder_merge_preserves_graphical_objects(agent_monkeypatch):
|
||||
# vision finds a graphical symbol + misreads nothing; text rung adds notes.
|
||||
# The graphical object MUST survive the merge.
|
||||
calls = {"n": 0}
|
||||
def fake_call_json(**kw):
|
||||
calls["n"] += 1
|
||||
if kw.get("images_b64"): # vision pass
|
||||
return {"sheet": {}, "objects": [
|
||||
{"object_id": "g1", "object_type": "lighting_fixture",
|
||||
"name": "pendant at grid C-4", "source_text": None,
|
||||
"graphical_basis": "16in pendant symbol at grid C-4"}]}
|
||||
return {"sheet": {}, "objects": [ # text-structuring pass
|
||||
{"object_id": "t1", "object_type": "general_note",
|
||||
"source_text": "ALL SAWN LUMBER IN CONTACT WITH SOIL TO BE SOUTHERN PINE, PRESSURE TREATED.",
|
||||
"name": "lumber note"}]}
|
||||
agent_monkeypatch.setattr("backend.agents.extractors.call_json", fake_call_json)
|
||||
agent = SheetExtractorAgent(AgentUsage())
|
||||
scope = AgentScope(scope_id="sheet:8", payload={"page": _page(), "sheet_hint": ""})
|
||||
sheet = agent.run(scope).artifacts[0]
|
||||
assert any(a.get("graphical_basis") for a in sheet["assertions"])
|
||||
assert any("SAWN LUMBER" in (a.get("source_text") or "") for a in sheet["assertions"])
|
||||
|
||||
def test_ladder_recovers_sheet_number_from_text_layer(agent_monkeypatch):
|
||||
agent_monkeypatch.setattr(
|
||||
"backend.agents.extractors.call_json",
|
||||
lambda **kw: {"sheet": {}, "objects": [
|
||||
{"object_id": "o1", "name": "RCP note",
|
||||
"source_text": "GYP. BD. CEILING 8'-11 3/8\" A.F.F. TYP. FOR ALL STOREFRONT",
|
||||
"attributes": {"height": "8'-11 3/8\""}}]})
|
||||
agent = SheetExtractorAgent(AgentUsage())
|
||||
scope = AgentScope(scope_id="sheet:18",
|
||||
payload={"page": _page(18, "REFLECTED CEILING PLAN\nA102\nGYP. BD. CEILING 8'-11 3/8\" A.F.F. TYP. FOR ALL STOREFRONT"),
|
||||
"sheet_hint": ""})
|
||||
sheet = agent.run(scope).artifacts[0]
|
||||
assert sheet["sheet_number"] == "A102"
|
||||
```
|
||||
|
||||
(Monkeypatch fixture: plain `unittest.mock.patch` context or pytest `monkeypatch`; follow tests/agents/test_text_layer_flow.py patterns for scope/result construction — check AgentScope/AgentResult signatures in backend/agents/base.py before writing.)
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/agents/test_extraction_ladder.py -v`
|
||||
Expected: FAIL — assertions on coverage/sheet_number fail (ladder not implemented)
|
||||
|
||||
**Step 3: Implement the ladder in `backend/agents/extractors.py`**
|
||||
|
||||
Replace `SheetExtractorAgent.run` (lines 63-86) with:
|
||||
|
||||
```python
|
||||
def _text_structuring_call(self, page, sheet_hint):
|
||||
from backend.prompts import (TEXT_STRUCTURING_SYSTEM_PROMPT,
|
||||
TEXT_STRUCTURING_USER_INSTRUCTION)
|
||||
instruction = (TEXT_STRUCTURING_USER_INSTRUCTION
|
||||
.replace("{sheet_hint}", str(sheet_hint or ""))
|
||||
.replace("{text_layer}",
|
||||
(page.get("text_layer") or "")
|
||||
[:config.TEXT_LAYER_MAX_CHARS]))
|
||||
return call_json(
|
||||
system_prompt=TEXT_STRUCTURING_SYSTEM_PROMPT,
|
||||
user_text=instruction,
|
||||
images_b64=None,
|
||||
max_tokens=config.EXTRACT_MAX_TOKENS,
|
||||
model=config.AGENT_EXTRACT_MODEL,
|
||||
usage_tracker=self.usage,
|
||||
usage_stage="agent.extract_text",
|
||||
reasoning_effort=config.EXTRACT_REASONING_EFFORT or None,
|
||||
reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None,
|
||||
)
|
||||
|
||||
def run(self, scope: AgentScope) -> AgentResult:
|
||||
from backend.text_coverage import (fallback_objects, merge_objects,
|
||||
recover_sheet_number, text_coverage)
|
||||
try:
|
||||
page = scope.payload["page"]
|
||||
hint = scope.payload.get("sheet_hint") or ""
|
||||
page_text = page.get("text_layer")
|
||||
instruction = EXTRACTOR_USER_INSTRUCTION.replace(
|
||||
"{sheet_hint}", str(hint)) + _text_layer_block(page)
|
||||
|
||||
# Rung 1: vision pass (unchanged behaviour, incl. compact retry)
|
||||
parsed = _wrap_bare_list(self._call(instruction, page),
|
||||
page["page_number"])
|
||||
if not isinstance(parsed, dict):
|
||||
print(f"[Extract] Page {page['page_number']}: full extraction "
|
||||
f"failed, retrying compact")
|
||||
parsed = _wrap_bare_list(
|
||||
self._call(instruction + _COMPACT_RETRY_SUFFIX, page),
|
||||
page["page_number"])
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"sheet": {}, "objects": []}
|
||||
|
||||
sheet = _normalize_sheet(parsed, page["page_number"],
|
||||
page_text=page_text)
|
||||
cov = text_coverage(page_text or "", sheet["assertions"])
|
||||
sheet["coverage"] = cov
|
||||
|
||||
# Rung 2: text-only structuring when coverage is below floor.
|
||||
# MERGE, never replace: vision keeps every object it found
|
||||
# (graphical_basis content exists only in the image); the text
|
||||
# pass fills in the text content the vision pass missed.
|
||||
if (page_text and config.EXTRACT_TEXT_RETRY_ENABLED
|
||||
and cov["ratio"] < config.EXTRACT_COVERAGE_FLOOR):
|
||||
print(f"[Extract] Page {page['page_number']}: coverage "
|
||||
f"{cov['ratio']:.0%} < floor - text-only structuring pass")
|
||||
parsed2 = _wrap_bare_list(
|
||||
self._text_structuring_call(page, hint), page["page_number"])
|
||||
if isinstance(parsed2, dict):
|
||||
sheet2 = _normalize_sheet(parsed2, page["page_number"],
|
||||
page_text=page_text)
|
||||
before = len(sheet["assertions"])
|
||||
sheet["assertions"] = merge_objects(sheet["assertions"],
|
||||
sheet2["assertions"])
|
||||
# Fill header gaps the vision pass left null
|
||||
for key in ("sheet_number", "sheet_title", "discipline",
|
||||
"level", "scale", "drawing_type"):
|
||||
if not sheet.get(key) and sheet2.get(key):
|
||||
sheet[key] = sheet2[key]
|
||||
cov = text_coverage(page_text, sheet["assertions"])
|
||||
sheet["coverage"] = cov
|
||||
print(f"[Extract] Page {page['page_number']}: merged "
|
||||
f"{len(sheet['assertions']) - before} text-structured "
|
||||
f"object(s), coverage now {cov['ratio']:.0%}")
|
||||
|
||||
# Rung 3: deterministic fallback - dark sheets are impossible.
|
||||
# Also merged (deduped) so stub notes never double up with
|
||||
# objects the earlier rungs already captured.
|
||||
if (page_text and config.EXTRACT_FALLBACK_ENABLED
|
||||
and cov["ratio"] < config.EXTRACT_COVERAGE_FLOOR):
|
||||
stubs = fallback_objects(page_text, page["page_number"],
|
||||
config.EXTRACT_FALLBACK_MAX_OBJECTS)
|
||||
stubs = _normalize_sheet({"sheet": {}, "objects": stubs},
|
||||
page["page_number"],
|
||||
page_text=page_text)["assertions"]
|
||||
before = len(sheet["assertions"])
|
||||
sheet["assertions"] = merge_objects(sheet["assertions"], stubs)
|
||||
print(f"[Extract] Page {page['page_number']}: fallback merged "
|
||||
f"{len(sheet['assertions']) - before} text-layer stub(s)")
|
||||
sheet["coverage"] = text_coverage(page_text,
|
||||
sheet["assertions"])
|
||||
|
||||
# Identity recovery: never leave a text-bearing page sheet-less
|
||||
if not sheet.get("sheet_number") and page_text:
|
||||
recovered = recover_sheet_number(page_text)
|
||||
if recovered:
|
||||
sheet["sheet_number"] = recovered
|
||||
sheet["discipline"] = (
|
||||
__import__("backend.pipeline.extractor",
|
||||
fromlist=["discipline_from_sheet_number"])
|
||||
.discipline_from_sheet_number(recovered)
|
||||
or sheet.get("discipline") or "Unknown")
|
||||
print(f"[Extract] Page {page['page_number']}: sheet number "
|
||||
f"recovered from text layer -> {recovered}")
|
||||
|
||||
return AgentResult(scope_id=scope.scope_id, artifacts=[sheet])
|
||||
except Exception as exc:
|
||||
return failure(scope, exc)
|
||||
```
|
||||
|
||||
**Step 4: Run to verify pass**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/agents/test_extraction_ladder.py -v`
|
||||
Expected: all pass
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/agents/extractors.py backend/config.py tests/agents/test_extraction_ladder.py
|
||||
git commit -m "feat: coverage-driven extraction retry ladder (agent path)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Same ladder in the CLASSIC path (pipeline/extractor.py)
|
||||
|
||||
**Objective:** The classic pipeline (`_extract_one`, backend/pipeline/extractor.py:273-293) must get the identical ladder — two render paths share everything, per the documented project trap.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pipeline/extractor.py:273-293`
|
||||
- Test: `tests/test_text_layer_flow.py` or new `tests/test_extraction_ladder_classic.py`
|
||||
|
||||
**Step 1: Write failing test** — mirror Task 4's tests against `_extract_one` directly (monkeypatch `backend.pipeline.extractor.call_json`).
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_extraction_ladder_classic.py -v`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Implement** — same ladder shape as Task 4 but inside `_extract_one`; the text-structuring call here uses default model (no `model=` kwarg, matching existing `_extract_one` call_json usage). Keep the existing "extraction failed" empty-sheet shape for pages with NO text layer (scanned pages stay vision-only and may legitimately return empty).
|
||||
|
||||
**Step 4: Run to verify pass**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_extraction_ladder_classic.py -v`
|
||||
Expected: all pass
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/pipeline/extractor.py tests/test_extraction_ladder_classic.py
|
||||
git commit -m "feat: coverage-driven extraction retry ladder (classic path)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Verbatim-source stamping upgrade
|
||||
|
||||
**Objective:** When a text layer exists, check each object's source_text against the page text with the existing fuzzy machinery; stamp `grounding="vision_unverified"` when it doesn't match so the wave-5b verifier prioritizes it. Cheap upgrade, reuses text_layer._tokens — no new call sites.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pipeline/extractor.py` (`_normalize_sheet`, ~line 182 where `grounding` is stamped)
|
||||
- Test: extend `tests/test_extractor_text_grounding.py`
|
||||
|
||||
**Step 1: Failing test** — object whose source_text is NOT a fuzzy substring of the page text keeps the object (guard passes via digits) but gets stamped `vision_unverified`.
|
||||
|
||||
**Step 2:** Run, expect FAIL.
|
||||
|
||||
**Step 3: Implement** — in `_normalize_sheet`, when `page_text` is present and no `grounding` stamp yet: normalized source_text (via `backend.text_coverage._norm`) not substring of normalized page text → `grounding = "vision_unverified"` (counted in the existing log line as a third counter).
|
||||
|
||||
**Step 4:** Run, expect PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/pipeline/extractor.py tests/test_extractor_text_grounding.py
|
||||
git commit -m "feat: stamp vision-unverified source_text against text layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Surface coverage in the report
|
||||
|
||||
**Objective:** `report.summary` gains per-job extraction-quality visibility so "is extraction healthy?" is answerable without log spelunking.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/agents/runner.py` (where summary is assembled) and/or `backend/pipeline/report.py`
|
||||
- Test: extend the runner-level stub test (tests/agents/test_wave5b_suppression.py pattern)
|
||||
|
||||
**Step 1: Failing test** — runner-level: summary contains `extraction_coverage = {"pages_below_floor": [...], "mean_ratio": float, "fallback_pages": [...]}`.
|
||||
|
||||
**Step 2-4:** Implement by aggregating the `coverage` dicts Task 4/5 attach to each sheet; NO new ProjectMemory keys (closed registry trap) — compute at report assembly from the sheets list already in scope.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/agents/runner.py backend/pipeline/report.py tests/
|
||||
git commit -m "feat: extraction coverage summary in report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + Cypress validation run
|
||||
|
||||
**Step 1:** `.venv/bin/python -m pytest tests/ -q` — expected: all pass (128 + new).
|
||||
|
||||
**Step 2:** Push branch, wait for Gitea Actions sha-<short> build, deploy per the skill's deploy runbook (compose pull + up -d --force-recreate).
|
||||
|
||||
**Step 3:** Resubmit the exact Cypress PDF (`docker cp`'d source.pdf preserved at /tmp/cypress-source.pdf on sits-docker):
|
||||
`curl -F file=@source.pdf -F pipeline_mode=agent https://conchecker.scoutitsystems.com/check`
|
||||
|
||||
**Step 4: Acceptance criteria (compare against job 3e01d5baba32):**
|
||||
- Zero text-bearing pages with 0 assertions (was: pages 8, 10, 18).
|
||||
- `sheet_number` present on >= 37/38 pages (was: 31/38).
|
||||
- A102 RCP content (ceiling heights, tape lights, sconces) present in assertions.
|
||||
- Spot-check: no regression in validated-issue quality — suppressed_issues and validated_issues counts within noise of the prior run; cost delta reported (expect +1 cheap text-only call per low-coverage page, ~$0 on healthy pages).
|
||||
- `report.summary.extraction_coverage.pages_below_floor` is empty or every entry is a genuinely scanned page.
|
||||
|
||||
---
|
||||
|
||||
## Files Touched (summary)
|
||||
|
||||
- Create: `backend/text_coverage.py`
|
||||
- Modify: `backend/prompts.py`, `backend/config.py`, `backend/agents/extractors.py`, `backend/pipeline/extractor.py`, `backend/agents/runner.py`, `backend/pipeline/report.py`
|
||||
- Tests: `tests/test_text_coverage.py`, `tests/agents/test_extraction_ladder.py`, `tests/test_extraction_ladder_classic.py`, extensions to `tests/test_extractor_text_grounding.py` and the runner-level stub test
|
||||
|
||||
## Risks, Tradeoffs, Open Questions
|
||||
|
||||
- **Fallback flood risk:** 200 low-confidence stubs/page could flood downstream scopes. Mitigations: EXTRACT_FALLBACK_MAX_OBJECTS cap, confidence=low (specialists already weight confidence), fallback only fires below the coverage floor (3/38 pages on Cypress). If Brain merge gets noisy, lower the cap or restrict fallback to pages where rungs 1+2 BOTH return 0 objects.
|
||||
- **Cost:** rung 2 adds one text-only call per low-coverage page (~12k input chars, no image) — negligible vs the 65k-token vision pass. Healthy pages skip it entirely.
|
||||
- **Sheet-id regex false positives:** member marks like W12X26 are excluded by the 2-3-digit shape, but "S301" inside a detail reference ("2/S301") will match. Tail-of-page preference mitigates; wrong-but-present sheet_number is still strictly better than None for scope keying (sheet_index wave can correct it).
|
||||
- **Open question:** should rung 2 route to the local model (aimax LM Studio) instead of the cloud extractor model to make retries free? Config knob `AGENT_EXTRACT_TEXT_MODEL` would allow it; not included in this plan (YAGNI until cost data from the validation run says otherwise).
|
||||
- **Explicit non-goal:** graphical-only content (symbol geometry, line work) stays vision-based — text-layer-first cannot see it. Scanned PDFs (no text layer) keep today's behaviour plus the existing failed_scopes gap note.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Plan: Brain-directed clarification pass (bounded hub-and-spoke)
|
||||
|
||||
Date: 2026-08-20
|
||||
Branch: agent-mode
|
||||
|
||||
## Goal
|
||||
Let the Brain actively chase weak/ambiguous findings instead of only judging
|
||||
the finished pile once. Bounded, traceable, reuses the wave-5b verifier as the
|
||||
"answer" channel. NOT a free agentic loop.
|
||||
|
||||
## Shape (agent pipeline)
|
||||
Insert **wave 6.5: Brain-directed clarification** between the wave-6 Brain merge
|
||||
and the review-gate / wave-7 branches, so BOTH paths benefit.
|
||||
|
||||
1. `BrainAgent.plan_clarifications(prioritized)` — one focused LLM call. Brain
|
||||
names findings it is unsure about and emits TYPED requests:
|
||||
`{issue_id, request_type, reason}`. v1 executes only `verify_evidence`;
|
||||
the router accepts other types but logs them as "planned, not executed"
|
||||
(extensible without a rewrite). Capped at `BRAIN_CLARIFY_MAX_REQUESTS`.
|
||||
Brain is told which findings already carry `verification` (from 5b) so it
|
||||
does not re-request them.
|
||||
2. Route `verify_evidence` requests → build verify scopes for exactly those
|
||||
findings (reuse the SAME scope builder as wave 5b: fresh page images +
|
||||
hi-DPI evidence crops + text-layer oracle) → `EvidenceVerifierAgent` →
|
||||
`apply_verdicts(prioritized, ...)`. Refuted findings are annotated,
|
||||
demoted, removed from `prioritized`, and pushed into `memory["suppressed"]`
|
||||
(existing key — no memory-registry crash). Clarify decisions recorded in
|
||||
`memory["decisions"]`.
|
||||
3. No second full Brain merge: Brain ASKED (step 1) and the verifier ANSWERED
|
||||
(step 2); the answer prunes/annotates the list. This keeps issue_ids stable
|
||||
for the review queue and adds at most 1 + N calls. One iteration only.
|
||||
|
||||
## Bounds / knobs (config.py, all env-overridable)
|
||||
- `ENABLE_BRAIN_CLARIFY` (_flag, default true)
|
||||
- `BRAIN_CLARIFY_MAX_REQUESTS` (default 8)
|
||||
- reuse `AGENT_VERIFY_CONCURRENCY`, `VERIFY_MAX_TOKENS`,
|
||||
`AGENT_VERIFY_REASONING_EFFORT`, `AGENT_CONFLICT_MAX_IMAGES`,
|
||||
`VERIFY_HI_DPI_CROPS`.
|
||||
|
||||
## Reuse / refactor
|
||||
- Extract the inline wave-5b verify-scope construction into
|
||||
`_build_verify_scopes(findings, targets, sheet_to_page, page_to_b64,
|
||||
page_to_text, page_words, pdf_path, prefix)` so wave 5b and wave 6.5 share
|
||||
it. Preserve wave-5b behavior exactly (its tests guard this).
|
||||
|
||||
## Classic pipeline
|
||||
Out of scope for v1 — the verifier/crops live only in the agent path. Classic
|
||||
keeps its single dedup_validate. Documented as agent-only.
|
||||
|
||||
## Tests
|
||||
- `plan_clarifications` parses/caps/skips-already-verified (stub call_json).
|
||||
- Router executes verify_evidence, ignores unknown types.
|
||||
- Runner smoke: a low-confidence finding Brain flags gets refuted → moves to
|
||||
suppressed_issues; stub verifier.call_json (no live calls).
|
||||
|
||||
## Pitfalls to respect
|
||||
- ProjectMemory keys are a closed registry — only use existing `suppressed` /
|
||||
`decisions`. (Skill defect C1.)
|
||||
- Stub `backend.agents.verifier.call_json` in runner tests or it hits the net.
|
||||
- Extractor stub sheets need >= 2 assertions or no clusters form.
|
||||
@@ -27,6 +27,23 @@ AGENT_CONFLICT_CONCURRENCY=4
|
||||
AGENT_SPECIALIST_CONCURRENCY=4
|
||||
AGENT_RFI_CONCURRENCY=4
|
||||
|
||||
# -- Review focus toggles -------------------------------------------
|
||||
# ENABLE_CODE_REVIEW: run the code/ADA/jurisdiction review path (both pipelines).
|
||||
# Default OFF - the product focuses on drawing integrity and cross-discipline
|
||||
# coordination, not code/accessibility compliance. Set to 1 to restore it.
|
||||
ENABLE_CODE_REVIEW=false
|
||||
# ENABLE_DRAWING_INTEGRITY: per-sheet Drawing Integrity QA wave (both pipelines).
|
||||
# The drawing-focused pass - dangling references, on-sheet contradictions,
|
||||
# dimension sanity, missing sheet essentials, tag hygiene. Default ON.
|
||||
ENABLE_DRAWING_INTEGRITY=true
|
||||
AGENT_INTEGRITY_MODEL=
|
||||
AGENT_INTEGRITY_CONCURRENCY=4
|
||||
AGENT_INTEGRITY_MAX_IMAGES=1
|
||||
AGENT_INTEGRITY_MAX_ASSERTIONS=80
|
||||
INTEGRITY_MAX_TOKENS=16384
|
||||
# Skip sheets with fewer than this many extracted objects (too sparse to check)
|
||||
INTEGRITY_MIN_ASSERTIONS=3
|
||||
|
||||
# Agent-mode human-review gate (pipeline stops after Brain until a human reviews)
|
||||
AGENT_REQUIRE_REVIEW=true
|
||||
# Max clean clusters added to the review queue as non-blocking spot-checks
|
||||
@@ -79,6 +96,14 @@ AGENT_VERIFY_SEVERITIES=critical,high
|
||||
AGENT_VERIFY_REASONING_EFFORT=low
|
||||
VERIFY_MAX_TOKENS=8192
|
||||
|
||||
# Wave 6.5 Brain-directed clarification (bounded hub-and-spoke). After the Brain
|
||||
# merge, the Brain names findings it is unsure about; verify_evidence requests
|
||||
# route back through the wave-5b verifier. One planning call + at most
|
||||
# BRAIN_CLARIFY_MAX_REQUESTS verifications, single iteration. Default ON.
|
||||
ENABLE_BRAIN_CLARIFY=true
|
||||
BRAIN_CLARIFY_MAX_REQUESTS=8
|
||||
BRAIN_CLARIFY_MAX_TOKENS=4096
|
||||
|
||||
# Text-layer grounding (deterministic PDF text layer via PyMuPDF)
|
||||
# TEXT_LAYER_ENABLED: master switch for text-layer extraction/grounding
|
||||
# TEXT_LAYER_MIN_CHARS: below this per page the sheet stays vision-only
|
||||
|
||||
+72
-1
@@ -6,7 +6,12 @@ from typing import Dict, List, Tuple
|
||||
|
||||
from backend import config
|
||||
from backend.agents.base import AgentUsage
|
||||
from backend.agents.prompts import BRAIN_SYSTEM_PROMPT, BRAIN_USER_PROMPT
|
||||
from backend.agents.prompts import (
|
||||
BRAIN_CLARIFY_SYSTEM_PROMPT,
|
||||
BRAIN_CLARIFY_USER_PROMPT,
|
||||
BRAIN_SYSTEM_PROMPT,
|
||||
BRAIN_USER_PROMPT,
|
||||
)
|
||||
from backend.llm import call_json
|
||||
from backend.pipeline._stage import collect_list, validate_issue
|
||||
|
||||
@@ -127,3 +132,69 @@ class BrainAgent:
|
||||
issues.sort(key=lambda item: -int(item.get("risk_score") or 0))
|
||||
decisions = parsed.get("decisions") or []
|
||||
return issues, [item for item in decisions if isinstance(item, dict)]
|
||||
|
||||
def plan_clarifications(self, prioritized: List[Dict]) -> List[Dict]:
|
||||
"""Wave 6.5 planning call: name kept findings the Brain wants to
|
||||
double-check before publishing, as typed clarification requests.
|
||||
|
||||
Returns a capped list of {issue_id, request_type, reason}. Only
|
||||
findings that carry an issue_id and do NOT already have a verification
|
||||
result are offered to the model; anything the model names outside that
|
||||
set, or with an unknown request_type, is dropped by the caller/router.
|
||||
Never raises — a failed/empty plan just yields no requests.
|
||||
"""
|
||||
max_requests = config.BRAIN_CLARIFY_MAX_REQUESTS
|
||||
if not prioritized or max_requests <= 0:
|
||||
return []
|
||||
candidates = [
|
||||
{
|
||||
"issue_id": f.get("issue_id"),
|
||||
"severity": f.get("severity"),
|
||||
"confidence": f.get("confidence"),
|
||||
"source_stage": f.get("source_stage"),
|
||||
"description": (f.get("description") or "")[:400],
|
||||
"evidence": f.get("evidence") or [],
|
||||
"already_verified": bool(f.get("verification")),
|
||||
}
|
||||
for f in prioritized
|
||||
if f.get("issue_id") and not f.get("verification")
|
||||
]
|
||||
if not candidates:
|
||||
return []
|
||||
instruction = (
|
||||
BRAIN_CLARIFY_USER_PROMPT
|
||||
.replace("{max_requests}", str(max_requests))
|
||||
.replace("{findings}", json.dumps(candidates, ensure_ascii=True))
|
||||
)
|
||||
try:
|
||||
parsed = call_json(
|
||||
system_prompt=BRAIN_CLARIFY_SYSTEM_PROMPT,
|
||||
user_text=instruction,
|
||||
max_tokens=config.BRAIN_CLARIFY_MAX_TOKENS,
|
||||
model=config.AGENT_BRAIN_MODEL,
|
||||
usage_tracker=self.usage,
|
||||
usage_stage="agent.brain_clarify",
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
raw = parsed.get("requests") if isinstance(parsed, dict) else parsed
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
valid_ids = {c["issue_id"] for c in candidates}
|
||||
requests: List[Dict] = []
|
||||
seen: set = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
issue_id = item.get("issue_id")
|
||||
if issue_id not in valid_ids or issue_id in seen:
|
||||
continue
|
||||
requests.append({
|
||||
"issue_id": issue_id,
|
||||
"request_type": (item.get("request_type") or "verify_evidence").strip(),
|
||||
"reason": (item.get("reason") or "").strip(),
|
||||
})
|
||||
seen.add(issue_id)
|
||||
if len(requests) >= max_requests:
|
||||
break
|
||||
return requests
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Per-sheet Drawing Integrity QA agent.
|
||||
|
||||
Reads ONE sheet's own extracted objects + sheet image + deterministic text
|
||||
layer and flags defects internal to that single sheet: dangling detail/
|
||||
callout/keynote references, schedule-vs-plan/legend disagreements on the same
|
||||
sheet, dimension strings that do not sum, missing title-block/scale/north
|
||||
essentials, and duplicate/inconsistent tags. This is the drawing-focused pass
|
||||
that complements the cross-sheet conflict critic; it never does code/ADA or
|
||||
cross-sheet coordination.
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from backend import config
|
||||
from backend.agents.base import AgentResult, AgentScope, AgentUsage, failure
|
||||
from backend.agents.prompts import (
|
||||
DRAWING_INTEGRITY_SYSTEM_PROMPT,
|
||||
DRAWING_INTEGRITY_USER_PROMPT,
|
||||
)
|
||||
from backend.llm import call_json
|
||||
from backend.pipeline._serialize import dumps
|
||||
from backend.pipeline._stage import collect_list, validate_issue
|
||||
|
||||
|
||||
def _sheet_meta(sheet: Dict) -> Dict:
|
||||
"""Compact title-block-ish descriptor of the sheet (no raw assertions)."""
|
||||
return {
|
||||
"sheet_number": sheet.get("sheet_number"),
|
||||
"sheet_title": sheet.get("sheet_title"),
|
||||
"discipline": sheet.get("discipline"),
|
||||
"drawing_type": sheet.get("drawing_type"),
|
||||
"level": sheet.get("level"),
|
||||
"scale": sheet.get("scale"),
|
||||
}
|
||||
|
||||
|
||||
def build_integrity_scopes(
|
||||
sheets: List[Dict], page_to_b64: Dict, page_to_text: Dict
|
||||
) -> List[AgentScope]:
|
||||
"""One scope per sheet that carries enough objects to judge internal
|
||||
consistency. Sheets below INTEGRITY_MIN_ASSERTIONS are skipped as too
|
||||
sparse for a meaningful single-sheet back-check."""
|
||||
scopes: List[AgentScope] = []
|
||||
for sheet in sheets:
|
||||
assertions = sheet.get("assertions") or []
|
||||
if len(assertions) < config.INTEGRITY_MIN_ASSERTIONS:
|
||||
continue
|
||||
page_number = sheet.get("page_number")
|
||||
scopes.append(AgentScope(
|
||||
scope_id=f"integrity:{page_number}",
|
||||
payload={
|
||||
"sheet": sheet,
|
||||
"page_number": page_number,
|
||||
"image_b64": page_to_b64.get(page_number),
|
||||
"text_layer": page_to_text.get(page_number) or "",
|
||||
},
|
||||
))
|
||||
return scopes
|
||||
|
||||
|
||||
class DrawingIntegrityAgent:
|
||||
name = "drawing_integrity"
|
||||
|
||||
def __init__(self, usage: AgentUsage) -> None:
|
||||
self.usage = usage
|
||||
|
||||
def run(self, scope: AgentScope) -> AgentResult:
|
||||
try:
|
||||
sheet = dict(scope.payload["sheet"])
|
||||
assertions = (
|
||||
sheet.get("assertions") or []
|
||||
)[:config.AGENT_INTEGRITY_MAX_ASSERTIONS]
|
||||
text_layer = (scope.payload.get("text_layer") or "")[
|
||||
:config.TEXT_LAYER_MAX_CHARS
|
||||
]
|
||||
image_b64 = scope.payload.get("image_b64")
|
||||
images = [image_b64] if image_b64 else []
|
||||
images = images[:config.AGENT_INTEGRITY_MAX_IMAGES]
|
||||
|
||||
instruction = DRAWING_INTEGRITY_USER_PROMPT
|
||||
for key, value in {
|
||||
"sheet_meta": dumps(_sheet_meta(sheet)),
|
||||
"assertions": dumps(assertions),
|
||||
"text_layer": text_layer,
|
||||
}.items():
|
||||
instruction = instruction.replace("{" + key + "}", value)
|
||||
|
||||
parsed = call_json(
|
||||
system_prompt=DRAWING_INTEGRITY_SYSTEM_PROMPT,
|
||||
user_text=instruction,
|
||||
images_b64=images,
|
||||
max_tokens=config.INTEGRITY_MAX_TOKENS,
|
||||
model=config.AGENT_INTEGRITY_MODEL,
|
||||
usage_tracker=self.usage,
|
||||
usage_stage="agent.drawing_integrity",
|
||||
reasoning_effort=config.EXTRACT_REASONING_EFFORT or None,
|
||||
reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None,
|
||||
)
|
||||
findings = collect_list(
|
||||
parsed, "issues",
|
||||
lambda item: validate_issue(item, "drawing_integrity"),
|
||||
)
|
||||
sheet_number = sheet.get("sheet_number")
|
||||
for finding in findings:
|
||||
finding.update(agent=self.name, scope_id=scope.scope_id)
|
||||
# Anchor the finding to this sheet if the model left it blank.
|
||||
if not finding.get("sheets") and sheet_number:
|
||||
finding["sheets"] = [sheet_number]
|
||||
return AgentResult(scope_id=scope.scope_id, artifacts=findings)
|
||||
except Exception as exc:
|
||||
return failure(scope, exc)
|
||||
@@ -12,6 +12,42 @@ Sheet index: {sheet_index}
|
||||
Aggregate sheet summaries: {sheet_summaries}
|
||||
Cluster summary: {cluster_summary}"""
|
||||
|
||||
DRAWING_INTEGRITY_SYSTEM_PROMPT = """You are a Senior Architect performing a single-sheet QAQC back-check of ONE construction drawing before the set is issued for bid, permit, or construction.
|
||||
You are given the extracted construction objects for this one sheet, plus the sheet image and its deterministic PDF text layer.
|
||||
Your job is to find problems INTERNAL TO THIS SHEET - defects a human checker would red-line on this drawing by itself, without needing any other sheet.
|
||||
You are NOT performing code review. You are NOT checking ADA/accessibility. You are NOT doing cross-sheet coordination (a separate reviewer handles conflicts between sheets). You are NOT estimating cost. You are NOT redesigning anything.
|
||||
What IS a drawing-integrity issue on this sheet:
|
||||
- Dangling reference: a detail callout, section marker, elevation marker, keynote, or sheet reference that points to a target that does not exist on this sheet AND is not resolved by an explicit off-sheet reference (e.g. "SIM 5/A501" when this is A501 and it has no detail 5; a keynote number called out in the plan but absent from the keynote legend on the same sheet).
|
||||
- On-sheet contradiction: the plan disagrees with a schedule or legend printed on the SAME sheet; two notes on the sheet contradict each other; a tag in the plan is not in the sheet's own schedule/legend (or vice versa); the title block discipline/level disagrees with the drawing content.
|
||||
- Dimension sanity: a dimension string whose segments do not sum to the stated overall; an overall dimension that contradicts a repeated/typical dimension on the same sheet; obviously impossible or missing critical dimensions on a dimensioned plan.
|
||||
- Missing sheet essentials: no scale, no north arrow on a plan that needs one, missing sheet number/title in the title block, a schedule with header columns but no rows, a legend referenced but not present.
|
||||
- Label/tag hygiene: duplicate tags that should be unique on this sheet (two different doors both tagged 101A), a room shown with no room number/name where the sheet otherwise numbers rooms, inconsistent tag formatting that breaks a reference.
|
||||
What is NOT a drawing-integrity issue:
|
||||
- Anything requiring another sheet to judge (that is cross-sheet coordination, handled elsewhere).
|
||||
- A code, ADA, or accessibility requirement.
|
||||
- A design preference or cost concern.
|
||||
- A value simply not repeated where repetition is optional.
|
||||
- Anything you cannot support with text or a clear visual from THIS sheet.
|
||||
Be conservative and evidence-bound:
|
||||
- Only flag defects you can point to with verbatim source_text from this sheet or a clear description of what the image shows.
|
||||
- Trust the TEXT LAYER for alphanumeric content (numbers, tags, note text, dimensions); use the image for geometry, symbols, linework, and whether a referenced target actually appears.
|
||||
- When a value is marked DISPUTED (possible extraction misread), verify against the image before relying on it.
|
||||
- If the sheet is internally clean, return an empty issues array.
|
||||
Severity (use exactly one of critical, high, medium, low):
|
||||
- high = a defect that would cause rework, a wrong build, or a stop at permit/bid if issued as-is (missing critical dimension, dangling reference to a nonexistent detail that drives construction).
|
||||
- medium = a real drawing defect needing correction before issue.
|
||||
- low = minor cleanup/clarification.
|
||||
Use plain ASCII only. Respond only with valid JSON."""
|
||||
|
||||
DRAWING_INTEGRITY_USER_PROMPT = """Back-check this single sheet for internal drawing-integrity defects.
|
||||
Respond ONLY with a valid JSON object - no markdown fences, no explanation:
|
||||
{"issues":[{"issue_id":"string","source_stage":"drawing_integrity","category":"dangling_reference | on_sheet_contradiction | dimension_error | missing_sheet_essential | tag_or_label_error | other","severity":"critical | high | medium | low","confidence":"high | medium | low","location":"where on the sheet, e.g. 'Room 124 / detail callout 5' or 'door schedule'","disciplines":["string"],"sheets":["this sheet number"],"description":"senior architect explanation of the defect and why it matters","evidence":[{"discipline":"string","sheet":"string","source_text":"verbatim text from this sheet","asserted_value":"string"}],"recommended_resolution":"coordinate drawing | correct dimension | add missing detail | issue RFI | verify with architect | verify with engineer","code_reference":null}]}
|
||||
If the sheet is internally clean, return {"issues":[]}.
|
||||
Sheet: {sheet_meta}
|
||||
Extracted objects on this sheet: {assertions}
|
||||
TEXT LAYER (deterministic page text - authoritative for alphanumeric content):
|
||||
{text_layer}"""
|
||||
|
||||
BRAIN_SYSTEM_PROMPT = """You are the central decision layer for a construction drawing
|
||||
review. Merge duplicate specialist findings, reject vague or unsupported findings,
|
||||
preserve verbatim evidence, and prioritize the kept issues. Do not create new issues.
|
||||
@@ -19,7 +55,23 @@ Conflicts need drawing evidence; completeness findings may instead cite an expli
|
||||
missing item from the sheet index. Return only valid JSON."""
|
||||
|
||||
BRAIN_USER_PROMPT = """Judge and consolidate these scoped specialist findings.
|
||||
Return {"issues":[{"issue_id":"string","source_stage":"conflict | qaqc | code | constructability","category":"string","severity":"critical | high | medium | low","confidence":"high | medium | low","location":"string","disciplines":["string"],"sheets":["string"],"description":"string","evidence":[{"discipline":"string","sheet":"string","source_text":"string","asserted_value":"string"}],"recommended_resolution":"string","code_reference":"string or null","risk_score":1,"recommended_priority":"immediate | before_bid | before_permit | before_construction | track_only"}],"decisions":[{"finding_refs":["string"],"action":"kept | merged | dropped","reason":"string","kept_issue_id":"string or null"}]}.
|
||||
Return {"issues":[{"issue_id":"string","source_stage":"conflict | drawing_integrity | qaqc | code | constructability","category":"string","severity":"critical | high | medium | low","confidence":"high | medium | low","location":"string","disciplines":["string"],"sheets":["string"],"description":"string","evidence":[{"discipline":"string","sheet":"string","source_text":"string","asserted_value":"string"}],"recommended_resolution":"string","code_reference":"string or null","risk_score":1,"recommended_priority":"immediate | before_bid | before_permit | before_construction | track_only"}],"decisions":[{"finding_refs":["string"],"action":"kept | merged | dropped","reason":"string","kept_issue_id":"string or null"}]}.
|
||||
Sheet index: {sheet_index}
|
||||
Jurisdiction summary: {jurisdiction}
|
||||
Specialist findings: {findings}"""
|
||||
|
||||
BRAIN_CLARIFY_SYSTEM_PROMPT = """You are the central decision layer for a construction drawing review, deciding which of your kept findings you are NOT yet confident enough to publish.
|
||||
You have already merged and prioritized the findings. Now, for the borderline ones, you may request ONE targeted clarification each before the report is finalized.
|
||||
Request a clarification only when a finding's evidence is thin, ambiguous, possibly a misread of the drawing, or internally inconsistent - the kind of finding a senior reviewer would double-check against the sheet before signing off. Do NOT request clarification for findings that are already clearly supported by verbatim evidence, and do NOT re-request a finding that already carries a verification result.
|
||||
The only request type available right now is:
|
||||
- verify_evidence: re-check this finding's quoted evidence against the actual sheet images and deterministic text layer (catches wave-1 vision misreads such as "(2)" vs "(5)" and dangling references that do not actually appear on the sheet).
|
||||
Be selective. Requesting everything wastes the budget and slows the review; request only the findings where a second look would actually change your decision.
|
||||
Use plain ASCII only. Respond only with valid JSON."""
|
||||
|
||||
BRAIN_CLARIFY_USER_PROMPT = """Decide which of these kept findings you want to double-check before publishing.
|
||||
You may request at most {max_requests} clarifications. Choose the findings where a second look at the sheet would most likely change your keep/drop/severity decision.
|
||||
Respond ONLY with a valid JSON object - no markdown fences, no explanation:
|
||||
{"requests":[{"issue_id":"the issue_id of the finding to check","request_type":"verify_evidence","reason":"one sentence: why this finding is uncertain"}]}
|
||||
If every finding is already well supported, return {"requests":[]}.
|
||||
Findings (each shows issue_id, severity, confidence, evidence, and whether it already has a verification result):
|
||||
{findings}"""
|
||||
|
||||
+180
-38
@@ -18,6 +18,9 @@ from backend.agents.extractors import (
|
||||
SheetExtractorAgent,
|
||||
SheetIndexAgent,
|
||||
)
|
||||
from backend.agents.integrity_agent import (
|
||||
DrawingIntegrityAgent, build_integrity_scopes,
|
||||
)
|
||||
from backend.agents.linker import LinkerAgent, build_link_scopes, build_object_graph
|
||||
from backend.agents.memory import ProjectMemory
|
||||
from backend.agents.orchestrator import Orchestrator
|
||||
@@ -174,11 +177,23 @@ def run_agent_pipeline(
|
||||
memory.extend("findings", conflict_findings)
|
||||
|
||||
orchestrator.stage("Agent wave 5: scoped specialists")
|
||||
code_results = orchestrator.run_scopes(
|
||||
CodeAgent(usage),
|
||||
build_code_scopes(sheets, jurisdiction, sheet_index),
|
||||
config.AGENT_SPECIALIST_CONCURRENCY,
|
||||
)
|
||||
if config.ENABLE_CODE_REVIEW:
|
||||
code_results = orchestrator.run_scopes(
|
||||
CodeAgent(usage),
|
||||
build_code_scopes(sheets, jurisdiction, sheet_index),
|
||||
config.AGENT_SPECIALIST_CONCURRENCY,
|
||||
)
|
||||
else:
|
||||
orchestrator.stage("[wave 5] code/ADA review disabled (ENABLE_CODE_REVIEW=0)")
|
||||
code_results = []
|
||||
if config.ENABLE_DRAWING_INTEGRITY:
|
||||
integrity_results = orchestrator.run_scopes(
|
||||
DrawingIntegrityAgent(usage),
|
||||
build_integrity_scopes(sheets, page_to_b64, page_to_text),
|
||||
config.AGENT_INTEGRITY_CONCURRENCY,
|
||||
)
|
||||
else:
|
||||
integrity_results = []
|
||||
construct_results = orchestrator.run_scopes(
|
||||
ConstructabilityAgent(usage),
|
||||
build_construct_scopes(clusters, conflict_findings),
|
||||
@@ -197,7 +212,8 @@ def run_agent_pipeline(
|
||||
)
|
||||
specialist_findings = [
|
||||
artifact
|
||||
for result in code_results + construct_results + completeness_results
|
||||
for result in (code_results + integrity_results
|
||||
+ construct_results + completeness_results)
|
||||
for artifact in result.artifacts
|
||||
]
|
||||
|
||||
@@ -210,38 +226,12 @@ def run_agent_pipeline(
|
||||
severities=config.AGENT_VERIFY_SEVERITIES,
|
||||
)
|
||||
target_indexes = {id(f): i for i, f in enumerate(specialist_findings)}
|
||||
verify_scopes = []
|
||||
for finding in verify_targets:
|
||||
cited_pages = [
|
||||
sheet_to_page[str(name)]
|
||||
for name in (finding.get("sheets") or [])
|
||||
if sheet_to_page.get(str(name)) in page_to_b64
|
||||
]
|
||||
images = [
|
||||
page_to_b64[p]
|
||||
for p in cited_pages[:config.AGENT_CONFLICT_MAX_IMAGES]
|
||||
]
|
||||
if not images:
|
||||
continue # never judge evidence against images we could not load
|
||||
# Text oracle: concatenated text layer of the cited sheets, capped.
|
||||
excerpt = "\n\n".join(
|
||||
f"--- Page {p} ---\n{page_to_text[p]}"
|
||||
for p in cited_pages
|
||||
if page_to_text.get(p)
|
||||
)[:config.VERIFY_TEXT_MAX_CHARS]
|
||||
if config.VERIFY_HI_DPI_CROPS:
|
||||
images = _evidence_crops(finding, cited_pages, sheet_to_page,
|
||||
page_words, page_to_b64, pdf_path,
|
||||
fallback=images)
|
||||
verify_scopes.append(AgentScope(
|
||||
scope_id=f"verify:{target_indexes[id(finding)]}",
|
||||
payload={
|
||||
"finding_index": target_indexes[id(finding)],
|
||||
"finding": finding,
|
||||
"images_b64": images,
|
||||
"text_layer_excerpt": excerpt,
|
||||
},
|
||||
))
|
||||
verify_scopes = _build_verify_scopes(
|
||||
verify_targets,
|
||||
index_for=lambda f: target_indexes[id(f)],
|
||||
sheet_to_page=sheet_to_page, page_to_b64=page_to_b64,
|
||||
page_to_text=page_to_text, page_words=page_words, pdf_path=pdf_path,
|
||||
)
|
||||
verify_results = orchestrator.run_scopes(
|
||||
EvidenceVerifierAgent(usage), verify_scopes, config.AGENT_VERIFY_CONCURRENCY)
|
||||
suppressed = apply_verdicts(specialist_findings, verify_results)
|
||||
@@ -286,6 +276,18 @@ def run_agent_pipeline(
|
||||
1 for decision in decisions if decision.get("action") == "merged"
|
||||
)
|
||||
|
||||
# Wave 6.5 — Brain-directed clarification (bounded hub-and-spoke). The Brain
|
||||
# names kept findings it is unsure about; verify_evidence requests route
|
||||
# back through the wave-5b verifier (fresh images + text-layer oracle).
|
||||
# Refuted findings are demoted, dropped from `prioritized`, and moved to
|
||||
# memory["suppressed"]. One planning call, one bounded verify wave, no loop.
|
||||
if config.ENABLE_BRAIN_CLARIFY and prioritized:
|
||||
prioritized = _brain_clarification_pass(
|
||||
orchestrator, usage, memory, prioritized,
|
||||
sheet_to_page=sheet_to_page, page_to_b64=page_to_b64,
|
||||
page_to_text=page_to_text, page_words=page_words, pdf_path=pdf_path,
|
||||
)
|
||||
|
||||
if require_review:
|
||||
orchestrator.stage("Agent review gate: build human-review queue")
|
||||
memory_snapshot = memory.snapshot()
|
||||
@@ -331,6 +333,10 @@ def run_agent_pipeline(
|
||||
1 for item in specialist_findings
|
||||
if item.get("source_stage") == "code"
|
||||
),
|
||||
"drawing_integrity": sum(
|
||||
1 for item in specialist_findings
|
||||
if item.get("source_stage") == "drawing_integrity"
|
||||
),
|
||||
"constructability": sum(
|
||||
1 for item in specialist_findings
|
||||
if item.get("source_stage") == "constructability"
|
||||
@@ -404,6 +410,10 @@ def run_agent_pipeline(
|
||||
1 for item in specialist_findings
|
||||
if item.get("source_stage") == "code"
|
||||
),
|
||||
"drawing_integrity": sum(
|
||||
1 for item in specialist_findings
|
||||
if item.get("source_stage") == "drawing_integrity"
|
||||
),
|
||||
"constructability": sum(
|
||||
1 for item in specialist_findings
|
||||
if item.get("source_stage") == "constructability"
|
||||
@@ -440,6 +450,138 @@ def _dump(out_dir: str, name: str, value) -> None:
|
||||
json.dump(value, f, indent=2)
|
||||
|
||||
|
||||
def _brain_clarification_pass(
|
||||
orchestrator,
|
||||
usage,
|
||||
memory,
|
||||
prioritized,
|
||||
sheet_to_page,
|
||||
page_to_b64,
|
||||
page_to_text,
|
||||
page_words,
|
||||
pdf_path,
|
||||
):
|
||||
"""Wave 6.5: let the Brain request targeted clarifications, execute the
|
||||
verify_evidence ones through the wave-5b verifier, and prune refuted
|
||||
findings out of `prioritized` into memory["suppressed"].
|
||||
|
||||
Bounded and non-looping: one Brain planning call, at most
|
||||
BRAIN_CLARIFY_MAX_REQUESTS verifications, a single pass. Returns the
|
||||
(possibly shortened) prioritized list. Any request type other than
|
||||
verify_evidence is logged as planned-but-not-executed and left untouched.
|
||||
"""
|
||||
requests = BrainAgent(usage).plan_clarifications(prioritized)
|
||||
if not requests:
|
||||
return prioritized
|
||||
by_id = {f.get("issue_id"): f for f in prioritized}
|
||||
verify_findings = []
|
||||
unsupported = 0
|
||||
for req in requests:
|
||||
if req.get("request_type") != "verify_evidence":
|
||||
unsupported += 1
|
||||
continue
|
||||
finding = by_id.get(req.get("issue_id"))
|
||||
if finding is not None and finding not in verify_findings:
|
||||
verify_findings.append(finding)
|
||||
orchestrator.stage(
|
||||
f"Agent wave 6.5: Brain-directed clarification "
|
||||
f"({len(verify_findings)} verify, {unsupported} other)"
|
||||
)
|
||||
if unsupported:
|
||||
for req in requests:
|
||||
if req.get("request_type") != "verify_evidence":
|
||||
orchestrator.stats.failed_scopes.append(
|
||||
f"brain_clarify:{req.get('issue_id')}: "
|
||||
f"request_type '{req.get('request_type')}' planned, "
|
||||
f"not executed (v1 supports verify_evidence only)"
|
||||
)
|
||||
if not verify_findings:
|
||||
return prioritized
|
||||
index_of = {id(f): i for i, f in enumerate(prioritized)}
|
||||
verify_scopes = _build_verify_scopes(
|
||||
verify_findings,
|
||||
index_for=lambda f: index_of[id(f)],
|
||||
sheet_to_page=sheet_to_page, page_to_b64=page_to_b64,
|
||||
page_to_text=page_to_text, page_words=page_words, pdf_path=pdf_path,
|
||||
)
|
||||
if not verify_scopes:
|
||||
return prioritized
|
||||
verify_results = orchestrator.run_scopes(
|
||||
EvidenceVerifierAgent(usage), verify_scopes,
|
||||
config.AGENT_VERIFY_CONCURRENCY,
|
||||
)
|
||||
suppressed = apply_verdicts(prioritized, verify_results)
|
||||
if suppressed:
|
||||
suppressed_ids = {id(f) for f in suppressed}
|
||||
prioritized = [f for f in prioritized if id(f) not in suppressed_ids]
|
||||
existing = memory.snapshot().get("suppressed") or []
|
||||
memory.replace("suppressed", existing + suppressed)
|
||||
memory.extend("decisions", [
|
||||
{
|
||||
"finding_refs": [f.get("issue_id")],
|
||||
"action": "dropped",
|
||||
"reason": "Brain-directed clarification: evidence refuted on re-check",
|
||||
"kept_issue_id": None,
|
||||
}
|
||||
for f in suppressed
|
||||
])
|
||||
return prioritized
|
||||
|
||||
|
||||
def _build_verify_scopes(
|
||||
targets,
|
||||
index_for,
|
||||
sheet_to_page,
|
||||
page_to_b64,
|
||||
page_to_text,
|
||||
page_words,
|
||||
pdf_path,
|
||||
):
|
||||
"""Build EvidenceVerifierAgent scopes for a set of findings.
|
||||
|
||||
Shared by wave 5b (severity-gated) and wave 6.5 (Brain-directed): each
|
||||
finding's cited sheets are mapped to page images (hi-DPI evidence crops
|
||||
when enabled, else full pages) plus a capped text-layer oracle. Findings
|
||||
whose sheets resolve to NO loadable image are skipped (I2 guard) — never
|
||||
judge evidence against images we could not load. index_for(finding) yields
|
||||
the finding_index the verifier echoes back for apply_verdicts alignment.
|
||||
"""
|
||||
scopes = []
|
||||
for finding in targets:
|
||||
cited_pages = [
|
||||
sheet_to_page[str(name)]
|
||||
for name in (finding.get("sheets") or [])
|
||||
if sheet_to_page.get(str(name)) in page_to_b64
|
||||
]
|
||||
images = [
|
||||
page_to_b64[p]
|
||||
for p in cited_pages[:config.AGENT_CONFLICT_MAX_IMAGES]
|
||||
]
|
||||
if not images:
|
||||
continue # never judge evidence against images we could not load
|
||||
# Text oracle: concatenated text layer of the cited sheets, capped.
|
||||
excerpt = "\n\n".join(
|
||||
f"--- Page {p} ---\n{page_to_text[p]}"
|
||||
for p in cited_pages
|
||||
if page_to_text.get(p)
|
||||
)[:config.VERIFY_TEXT_MAX_CHARS]
|
||||
if config.VERIFY_HI_DPI_CROPS:
|
||||
images = _evidence_crops(finding, cited_pages, sheet_to_page,
|
||||
page_words, page_to_b64, pdf_path,
|
||||
fallback=images)
|
||||
finding_index = index_for(finding)
|
||||
scopes.append(AgentScope(
|
||||
scope_id=f"verify:{finding_index}",
|
||||
payload={
|
||||
"finding_index": finding_index,
|
||||
"finding": finding,
|
||||
"images_b64": images,
|
||||
"text_layer_excerpt": excerpt,
|
||||
},
|
||||
))
|
||||
return scopes
|
||||
|
||||
|
||||
def _evidence_crops(
|
||||
finding: Dict,
|
||||
cited_pages: list,
|
||||
|
||||
@@ -36,6 +36,15 @@ def _valid_verdict(item):
|
||||
|
||||
|
||||
def _status(verdicts):
|
||||
"""Roll per-evidence verdicts up to a finding-level status.
|
||||
|
||||
NOTE on "corrected": it is deliberately NON-confirming. The canonical case
|
||||
(job 959e16407573) is evidence quoting "(2) 2x6 STUD PACK" against a sheet
|
||||
that reads "(5)" — the text exists but the VALUE the finding rests on was a
|
||||
wave-1 misread, so the finding's basis is gone. Hence refuted = zero
|
||||
CONFIRMED verdicts, not zero not_found ones. Do not "fix" this to treat
|
||||
corrected as supporting; see tests/agents/test_verifier.py.
|
||||
"""
|
||||
if not verdicts:
|
||||
return "unverified"
|
||||
confirmed = sum(1 for v in verdicts if v["verdict"] == "confirmed")
|
||||
|
||||
+51
-4
@@ -12,6 +12,15 @@ load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
_TRUTHY = ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _flag(name: str, default: str) -> bool:
|
||||
"""Parse a boolean env knob. Accepts 1/true/yes/on (case-insensitive) so a
|
||||
knob set to "1" behaves the same as one set to "true" — mixing bare
|
||||
`== "true"` comparisons with this set silently disabled features."""
|
||||
return os.getenv(name, default).strip().lower() in _TRUTHY
|
||||
|
||||
# -- AI Backend (OpenRouter) ----------------------------------------
|
||||
# One multimodal model does both extraction (Stage 1) and conflict
|
||||
# reasoning (Stage 3). Override MODEL per-stage if you ever split them.
|
||||
@@ -47,6 +56,33 @@ AGENT_CONFLICT_CONCURRENCY = int(os.getenv("AGENT_CONFLICT_CONCURRENCY", "4"))
|
||||
AGENT_SPECIALIST_CONCURRENCY = int(os.getenv("AGENT_SPECIALIST_CONCURRENCY", "4"))
|
||||
AGENT_RFI_CONCURRENCY = int(os.getenv("AGENT_RFI_CONCURRENCY", "4"))
|
||||
|
||||
# -- Review focus toggles -------------------------------------------
|
||||
# ENABLE_CODE_REVIEW gates the code/ADA/jurisdiction review path in BOTH
|
||||
# pipelines. Default OFF: the product's focus is drawing-integrity and
|
||||
# cross-discipline coordination, not code/accessibility compliance. When
|
||||
# False the CodeAgent wave (agent) and the Code/ADA stage (classic) are
|
||||
# skipped entirely, by_stage.code reports 0, and nothing in the ADA corpus
|
||||
# or jurisdiction meta is deleted so the path can be re-enabled with one env
|
||||
# flag. Set ENABLE_CODE_REVIEW=1 to restore code/ADA findings.
|
||||
ENABLE_CODE_REVIEW = _flag("ENABLE_CODE_REVIEW", "false")
|
||||
|
||||
# Per-sheet Drawing Integrity QA wave (agent + classic). This is the
|
||||
# drawing-focused pass: it reads ONE sheet's own objects + image + text layer
|
||||
# and flags problems internal to that sheet -- dangling detail/callout/keynote
|
||||
# references, schedule-vs-plan or legend disagreements on the same sheet,
|
||||
# dimension strings that do not sum, missing title-block/scale/north-arrow,
|
||||
# and notes that contradict each other. It complements (does not replace) the
|
||||
# cross-sheet conflict critic. Default ON.
|
||||
ENABLE_DRAWING_INTEGRITY = _flag("ENABLE_DRAWING_INTEGRITY", "true")
|
||||
AGENT_INTEGRITY_MODEL = os.getenv("AGENT_INTEGRITY_MODEL", "") or MODEL
|
||||
AGENT_INTEGRITY_CONCURRENCY = int(os.getenv("AGENT_INTEGRITY_CONCURRENCY", "4"))
|
||||
AGENT_INTEGRITY_MAX_IMAGES = int(os.getenv("AGENT_INTEGRITY_MAX_IMAGES", "1"))
|
||||
AGENT_INTEGRITY_MAX_ASSERTIONS = int(os.getenv("AGENT_INTEGRITY_MAX_ASSERTIONS", "80"))
|
||||
INTEGRITY_MAX_TOKENS = int(os.getenv("INTEGRITY_MAX_TOKENS", "16384"))
|
||||
# Skip sheets with fewer than this many extracted objects -- too sparse for a
|
||||
# meaningful internal-consistency pass (avoids burning a call on near-empty pages).
|
||||
INTEGRITY_MIN_ASSERTIONS = int(os.getenv("INTEGRITY_MIN_ASSERTIONS", "3"))
|
||||
|
||||
# Wave 5b evidence verification (vision fact-check of cited sheet text)
|
||||
AGENT_VERIFY_MODEL = os.getenv("AGENT_VERIFY_MODEL", "") or MODEL
|
||||
AGENT_VERIFY_CONCURRENCY = int(os.getenv("AGENT_VERIFY_CONCURRENCY", "4"))
|
||||
@@ -59,6 +95,17 @@ AGENT_VERIFY_SEVERITIES = {
|
||||
AGENT_VERIFY_REASONING_EFFORT = os.getenv("AGENT_VERIFY_REASONING_EFFORT", "low").strip()
|
||||
VERIFY_MAX_TOKENS = int(os.getenv("VERIFY_MAX_TOKENS", "8192"))
|
||||
|
||||
# Wave 6.5 Brain-directed clarification. After the Brain merge, the Brain may
|
||||
# name findings it is unsure about and emit typed clarification requests; v1
|
||||
# executes verify_evidence requests by routing them back through the wave-5b
|
||||
# EvidenceVerifierAgent (fresh page images + hi-DPI evidence crops + text-layer
|
||||
# oracle). Bounded: one planning call, at most BRAIN_CLARIFY_MAX_REQUESTS
|
||||
# verifications, a single iteration. Reuses AGENT_VERIFY_* / VERIFY_* knobs for
|
||||
# the verification calls. Default ON.
|
||||
ENABLE_BRAIN_CLARIFY = _flag("ENABLE_BRAIN_CLARIFY", "true")
|
||||
BRAIN_CLARIFY_MAX_REQUESTS = int(os.getenv("BRAIN_CLARIFY_MAX_REQUESTS", "8"))
|
||||
BRAIN_CLARIFY_MAX_TOKENS = int(os.getenv("BRAIN_CLARIFY_MAX_TOKENS", "4096"))
|
||||
|
||||
# -- Text-layer grounding (deterministic PDF text layer via PyMuPDF) ----
|
||||
# The vector text layer is extracted once per job and grounds the extractor,
|
||||
# rescues misquoted-but-real values in the grounding guard, and serves the
|
||||
@@ -118,8 +165,8 @@ EXTRACT_REASONING_MAX_TOKENS = int(os.getenv("EXTRACT_REASONING_MAX_TOKENS", "20
|
||||
# structuring pass (rung 2), then deterministic text-layer fallback stubs
|
||||
# (rung 3) so no text-bearing page goes dark.
|
||||
EXTRACT_COVERAGE_FLOOR = float(os.getenv("EXTRACT_COVERAGE_FLOOR", "0.6"))
|
||||
EXTRACT_TEXT_RETRY_ENABLED = os.getenv("EXTRACT_TEXT_RETRY_ENABLED", "true").lower() == "true"
|
||||
EXTRACT_FALLBACK_ENABLED = os.getenv("EXTRACT_FALLBACK_ENABLED", "true").lower() == "true"
|
||||
EXTRACT_TEXT_RETRY_ENABLED = _flag("EXTRACT_TEXT_RETRY_ENABLED", "true")
|
||||
EXTRACT_FALLBACK_ENABLED = _flag("EXTRACT_FALLBACK_ENABLED", "true")
|
||||
EXTRACT_FALLBACK_MAX_OBJECTS = int(os.getenv("EXTRACT_FALLBACK_MAX_OBJECTS", "200"))
|
||||
REASON_MAX_TOKENS = int(os.getenv("REASON_MAX_TOKENS", "4096"))
|
||||
|
||||
@@ -191,5 +238,5 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", "")
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
||||
SMTP_USE_SSL = os.getenv("SMTP_USE_SSL", "false").lower() == "true"
|
||||
SMTP_USE_TLS = _flag("SMTP_USE_TLS", "true")
|
||||
SMTP_USE_SSL = _flag("SMTP_USE_SSL", "false")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
drawing_integrity.py - Per-sheet Drawing Integrity QA (LLM, classic pipeline).
|
||||
|
||||
The drawing-focused pass: reads ONE sheet's own extracted objects + sheet image
|
||||
+ deterministic text layer and flags defects internal to that single sheet
|
||||
(dangling detail/callout/keynote references, schedule-vs-plan/legend
|
||||
disagreements on the same sheet, dimension strings that do not sum, missing
|
||||
title-block/scale essentials, duplicate/inconsistent tags). It complements the
|
||||
cross-sheet conflict checker; it never does code/ADA or cross-sheet
|
||||
coordination. Emits the canonical issue schema. Returns [] on failure.
|
||||
|
||||
Gated by config.ENABLE_DRAWING_INTEGRITY. Runs sheets concurrently, one call
|
||||
per sheet, skipping sheets below INTEGRITY_MIN_ASSERTIONS.
|
||||
"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, List
|
||||
|
||||
from backend import config
|
||||
from backend.agents.prompts import (
|
||||
DRAWING_INTEGRITY_SYSTEM_PROMPT,
|
||||
DRAWING_INTEGRITY_USER_PROMPT,
|
||||
)
|
||||
from backend.pipeline._serialize import dumps
|
||||
from backend.pipeline._stage import call_stage, collect_list, validate_issue
|
||||
|
||||
|
||||
def _sheet_meta(sheet: Dict) -> Dict:
|
||||
return {
|
||||
"sheet_number": sheet.get("sheet_number"),
|
||||
"sheet_title": sheet.get("sheet_title"),
|
||||
"discipline": sheet.get("discipline"),
|
||||
"drawing_type": sheet.get("drawing_type"),
|
||||
"level": sheet.get("level"),
|
||||
"scale": sheet.get("scale"),
|
||||
}
|
||||
|
||||
|
||||
def _review_sheet(sheet: Dict, page_to_b64: Dict, page_to_text: Dict) -> List[Dict]:
|
||||
page_number = sheet.get("page_number")
|
||||
assertions = (sheet.get("assertions") or [])[
|
||||
:config.AGENT_INTEGRITY_MAX_ASSERTIONS
|
||||
]
|
||||
text_layer = (page_to_text.get(page_number) or "")[
|
||||
:config.TEXT_LAYER_MAX_CHARS
|
||||
]
|
||||
image = page_to_b64.get(page_number)
|
||||
images = [image][:config.AGENT_INTEGRITY_MAX_IMAGES] if image else []
|
||||
parsed = call_stage(
|
||||
DRAWING_INTEGRITY_SYSTEM_PROMPT,
|
||||
DRAWING_INTEGRITY_USER_PROMPT,
|
||||
subs={
|
||||
"sheet_meta": dumps(_sheet_meta(sheet)),
|
||||
"assertions": dumps(assertions),
|
||||
"text_layer": text_layer,
|
||||
},
|
||||
images_b64=images,
|
||||
max_tokens=config.INTEGRITY_MAX_TOKENS,
|
||||
)
|
||||
issues = collect_list(
|
||||
parsed, "issues", lambda c: validate_issue(c, "drawing_integrity")
|
||||
)
|
||||
sheet_number = sheet.get("sheet_number")
|
||||
for issue in issues:
|
||||
if not issue.get("sheets") and sheet_number:
|
||||
issue["sheets"] = [sheet_number]
|
||||
return issues
|
||||
|
||||
|
||||
def drawing_integrity_review(sheets: List[Dict], pages: List[Dict]) -> List[Dict]:
|
||||
"""One LLM call per non-sparse sheet, run concurrently."""
|
||||
if not config.ENABLE_DRAWING_INTEGRITY:
|
||||
return []
|
||||
page_to_b64 = {p["page_number"]: p.get("base64") for p in pages}
|
||||
page_to_text = {p["page_number"]: p.get("text_layer") for p in pages}
|
||||
targets = [
|
||||
s for s in sheets
|
||||
if len(s.get("assertions") or []) >= config.INTEGRITY_MIN_ASSERTIONS
|
||||
]
|
||||
issues: List[Dict] = []
|
||||
if targets:
|
||||
with ThreadPoolExecutor(max_workers=config.AGENT_INTEGRITY_CONCURRENCY) as pool:
|
||||
for res in pool.map(
|
||||
lambda s: _review_sheet(s, page_to_b64, page_to_text), targets
|
||||
):
|
||||
issues.extend(res)
|
||||
print(f"[DrawingIntegrity] {len(issues)} issue(s) across {len(targets)} sheet(s)")
|
||||
return issues
|
||||
@@ -39,6 +39,7 @@ from backend.agents.disputes import annotate_clusters
|
||||
from backend.pipeline.conflict_checker import check_conflicts
|
||||
from backend.pipeline.qaqc_review import senior_review
|
||||
from backend.pipeline.code_review import code_review
|
||||
from backend.pipeline.drawing_integrity import drawing_integrity_review
|
||||
from backend.pipeline.constructability import constructability_review
|
||||
from backend.pipeline.validator import dedup_validate
|
||||
from backend.pipeline.risk import score_and_prioritize
|
||||
@@ -158,15 +159,23 @@ def _run_stages(
|
||||
stage("Full-set QAQC review")
|
||||
qaqc_issues = senior_review(sheets, clusters, conflicts, sheet_index)
|
||||
|
||||
stage("Code / ADA review")
|
||||
code_issues = code_review(jurisdiction, sheets, sheet_index)
|
||||
if config.ENABLE_CODE_REVIEW:
|
||||
stage("Code / ADA review")
|
||||
code_issues = code_review(jurisdiction, sheets, sheet_index)
|
||||
else:
|
||||
print("[Code] code/ADA review disabled (ENABLE_CODE_REVIEW=0)")
|
||||
code_issues = []
|
||||
|
||||
stage("Drawing integrity (per-sheet QA)")
|
||||
integrity_issues = drawing_integrity_review(sheets, pages)
|
||||
|
||||
stage("Constructability review")
|
||||
construct_issues = constructability_review(sheets, clusters, conflicts)
|
||||
|
||||
stage("Validate & deduplicate")
|
||||
conflict_issues = [v for v in (validate_issue(c, "conflict") for c in conflicts) if v]
|
||||
all_issues = conflict_issues + qaqc_issues + code_issues + construct_issues
|
||||
all_issues = (conflict_issues + integrity_issues + qaqc_issues
|
||||
+ code_issues + construct_issues)
|
||||
validated = dedup_validate(all_issues)
|
||||
|
||||
stage("Risk scoring & prioritization")
|
||||
@@ -189,6 +198,7 @@ def _run_stages(
|
||||
"conflicts": len(conflicts),
|
||||
"qaqc": len(qaqc_issues),
|
||||
"code": len(code_issues),
|
||||
"drawing_integrity": len(integrity_issues),
|
||||
"constructability": len(construct_issues),
|
||||
"validated": len(validated),
|
||||
"rfis": len(rfis),
|
||||
@@ -213,6 +223,7 @@ def _run_stages(
|
||||
_dump(out_dir, "project_intelligence.json", project_intel)
|
||||
_dump(out_dir, "qaqc_issues.json", qaqc_issues)
|
||||
_dump(out_dir, "code_issues.json", code_issues)
|
||||
_dump(out_dir, "drawing_integrity.json", integrity_issues)
|
||||
_dump(out_dir, "constructability.json", construct_issues)
|
||||
_dump(out_dir, "validated_issues.json", prioritized)
|
||||
_dump(out_dir, "rfis.json", rfis)
|
||||
|
||||
+10
-10
@@ -410,24 +410,24 @@ Normalized assertions: {normalized_assertions}"""
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CONFLICT_SYSTEM_PROMPT = """You are a Senior Architect and construction-drawing coordination reviewer doing a back-check of a drawing set BEFORE it is issued for bid, permit, or construction.
|
||||
You are given clustered facts that multiple disciplines have asserted about the same location or element.
|
||||
Decide whether these disciplines GENUINELY CONTRADICT each other - the kind of issue a human coordinator would issue as a QAQC comment or RFI before the set goes out.
|
||||
You are given clustered facts asserted about the same location or element. Those facts may come from MULTIPLE disciplines, from a SINGLE discipline across several sheets, or from ONE sheet (plan vs schedule vs detail vs keynote on that sheet).
|
||||
Decide whether these facts GENUINELY CONTRADICT each other - the kind of issue a human coordinator would issue as a QAQC comment or RFI before the set goes out. A contradiction between two facts is a conflict whether or not the two facts come from different disciplines.
|
||||
You are NOT performing code review in this stage. You are NOT checking ADA in this stage. You are NOT estimating cost or scope. You are NOT rewriting the drawings.
|
||||
What IS a conflict:
|
||||
- Two disciplines state different values for the same physical quantity at the same place.
|
||||
- An element is shown in different locations by different disciplines.
|
||||
- A schedule disagrees with what is drawn on the plan.
|
||||
- Two facts state different values for the same physical quantity at the same place (across disciplines, across sheets of one discipline, or on the same sheet).
|
||||
- An element is shown in different locations by different facts.
|
||||
- A schedule disagrees with what is drawn on the plan (even on the same sheet).
|
||||
- A detail disagrees with the plan.
|
||||
- A keynote disagrees with a schedule, plan, or detail.
|
||||
- A keynote or general note disagrees with a schedule, plan, detail, or legend - including a keynote/legend mismatch on a single sheet.
|
||||
- A callout, detail reference, section marker, or tag references something that does not exist (a dangling reference).
|
||||
- The same room, door, equipment, wall, or utility is labeled or dimensioned inconsistently across sheets or within one sheet.
|
||||
- An element required by one discipline has no counterpart where another discipline should show it.
|
||||
- A duct, pipe, conduit, or piece of equipment conflicts with structure, ceiling height, rated wall, or required clearance.
|
||||
- Equipment shown by one discipline lacks required power, plumbing, ventilation, access, or support in another discipline.
|
||||
- Demolition drawings remove something that new work drawings keep without explanation.
|
||||
- A callout, keynote, or tag references something that does not exist.
|
||||
- The same room, door, equipment, wall, or utility is labeled inconsistently across sheets.
|
||||
What is NOT a conflict:
|
||||
- Two disciplines describing different, compatible aspects of the same place.
|
||||
- A value shown on one discipline and simply not repeated on another, unless that discipline is expected to show it.
|
||||
- Two facts describing different, compatible aspects of the same place.
|
||||
- A value shown once and simply not repeated elsewhere, unless another sheet or discipline is expected to show it.
|
||||
- Rounding or representation differences that resolve to the same real value.
|
||||
- A possible code issue.
|
||||
- A design preference.
|
||||
|
||||
@@ -119,7 +119,12 @@ def merge_objects(vision_objs: List[Dict], text_objs: List[Dict]) -> List[Dict]:
|
||||
return merged
|
||||
|
||||
|
||||
_SHEET_ID_RE = re.compile(r"\b([A-Z]{1,2}\d{2,3}(?:\.\d+)?)\b")
|
||||
# Sheet ids: 1-2 letters, OPTIONAL HYPHEN, 2-3 digits, optional decimal suffix.
|
||||
# The hyphen matters: civil/landscape sets number sheets C-001 / L-101, and a
|
||||
# regex without it leaves those pages sheet_number=None, which then shows up as
|
||||
# a false "declared but not in set" in sheet_reconcile. Kept in sync with
|
||||
# sheet_reconcile._SHEET_TOKEN_RE.
|
||||
_SHEET_ID_RE = re.compile(r"\b([A-Z]{1,2}-?\d{2,3}(?:\.\d+)?)\b")
|
||||
|
||||
|
||||
def recover_sheet_number(page_text: str) -> Optional[str]:
|
||||
|
||||
+2
-2
@@ -111,7 +111,7 @@
|
||||
<input type="email" id="email" placeholder="you@firm.com" />
|
||||
</div>
|
||||
<details class="email-card" id="intake">
|
||||
<summary style="cursor:pointer">Project details <span class="opt">(optional — improves code/ADA review)</span></summary>
|
||||
<summary style="cursor:pointer">Project details <span class="opt">(optional)</span></summary>
|
||||
<input type="text" id="project_name" placeholder="Project name" style="width:100%;margin-top:8px" />
|
||||
<input type="text" id="address" placeholder="Project address" style="width:100%;margin-top:8px" />
|
||||
<input type="text" id="occupancy" placeholder="Occupancy (e.g. Business, Assembly)" style="width:100%;margin-top:8px" />
|
||||
@@ -473,7 +473,7 @@ function render(rep){
|
||||
const issues=rep.validated_issues||[];
|
||||
if(issues.length){
|
||||
html+='<details open style="margin-top:24px"><summary><b>QAQC issues ('+issues.length+')</b> '+
|
||||
'<span class="opt">conflicts + full-set + code/ADA + constructability, deduplicated</span></summary>';
|
||||
'<span class="opt">conflicts + drawing integrity + full-set + constructability, deduplicated</span></summary>';
|
||||
for(const c of issues){
|
||||
const rs=c.review_state;
|
||||
html+='<div class="conflict '+esc(c.severity)+'">'+
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Wave 6.5 Brain-directed clarification: planning unit + runner integration."""
|
||||
|
||||
import backend.agents.brain as brain_mod
|
||||
import backend.agents.runner as runner_mod
|
||||
from backend import config
|
||||
from backend.agents.base import AgentResult
|
||||
from backend.agents.base import AgentUsage
|
||||
from backend.agents.brain import BrainAgent
|
||||
from backend.agents.runner import run_agent_pipeline
|
||||
|
||||
|
||||
# --- plan_clarifications unit tests ---------------------------------------
|
||||
|
||||
def _finding(issue_id, **kw):
|
||||
base = {"issue_id": issue_id, "severity": "medium", "confidence": "low",
|
||||
"source_stage": "conflict", "description": "d",
|
||||
"evidence": [{"sheet": "A1", "source_text": "x"}]}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_plan_caps_and_filters_unknown_ids(monkeypatch):
|
||||
monkeypatch.setattr(config, "BRAIN_CLARIFY_MAX_REQUESTS", 2)
|
||||
monkeypatch.setattr(brain_mod, "call_json", lambda **k: {"requests": [
|
||||
{"issue_id": "A", "request_type": "verify_evidence", "reason": "thin"},
|
||||
{"issue_id": "GHOST", "request_type": "verify_evidence", "reason": "x"},
|
||||
{"issue_id": "B", "request_type": "verify_evidence", "reason": "amb"},
|
||||
{"issue_id": "C", "request_type": "verify_evidence", "reason": "over cap"},
|
||||
]})
|
||||
prioritized = [_finding("A"), _finding("B"), _finding("C")]
|
||||
reqs = BrainAgent(AgentUsage()).plan_clarifications(prioritized)
|
||||
ids = [r["issue_id"] for r in reqs]
|
||||
assert ids == ["A", "B"] # GHOST filtered, capped at 2
|
||||
|
||||
|
||||
def test_plan_skips_already_verified(monkeypatch):
|
||||
monkeypatch.setattr(config, "BRAIN_CLARIFY_MAX_REQUESTS", 8)
|
||||
captured = {}
|
||||
|
||||
def fake(**kwargs):
|
||||
captured["user_text"] = kwargs["user_text"]
|
||||
return {"requests": [
|
||||
{"issue_id": "A", "request_type": "verify_evidence", "reason": "y"},
|
||||
]}
|
||||
|
||||
monkeypatch.setattr(brain_mod, "call_json", fake)
|
||||
prioritized = [
|
||||
_finding("A"),
|
||||
_finding("V", verification={"status": "confirmed", "verdicts": []}),
|
||||
]
|
||||
reqs = BrainAgent(AgentUsage()).plan_clarifications(prioritized)
|
||||
assert [r["issue_id"] for r in reqs] == ["A"]
|
||||
# The already-verified finding must not even be offered to the model.
|
||||
assert '"V"' not in captured["user_text"]
|
||||
|
||||
|
||||
def test_plan_empty_on_call_failure(monkeypatch):
|
||||
monkeypatch.setattr(config, "BRAIN_CLARIFY_MAX_REQUESTS", 8)
|
||||
|
||||
def boom(**k):
|
||||
raise RuntimeError("brain down")
|
||||
monkeypatch.setattr(brain_mod, "call_json", boom)
|
||||
assert BrainAgent(AgentUsage()).plan_clarifications([_finding("A")]) == []
|
||||
|
||||
|
||||
def test_plan_no_requests_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(config, "BRAIN_CLARIFY_MAX_REQUESTS", 8)
|
||||
monkeypatch.setattr(brain_mod, "call_json", lambda **k: {"requests": []})
|
||||
assert BrainAgent(AgentUsage()).plan_clarifications([_finding("A")]) == []
|
||||
|
||||
|
||||
# --- runner-level integration ---------------------------------------------
|
||||
|
||||
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, brain_finding, plan_requests):
|
||||
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": "(2) 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": []},
|
||||
]))
|
||||
# A filler conflict finding so memory["findings"] is non-empty and wave 6
|
||||
# actually invokes Brain.run (which our stub replaces with brain_finding).
|
||||
monkeypatch.setattr(runner_mod, "ConflictCriticAgent", _stub_agent([
|
||||
{"issue_id": "FILLER", "severity": "low", "confidence": "low",
|
||||
"source_stage": "conflict", "sheets": [], "description": "filler",
|
||||
"evidence": []},
|
||||
]))
|
||||
monkeypatch.setattr(runner_mod, "CodeAgent", _stub_agent([]))
|
||||
monkeypatch.setattr(runner_mod, "ConstructabilityAgent", _stub_agent([]))
|
||||
monkeypatch.setattr(runner_mod, "CompletenessAgent", _stub_agent([]))
|
||||
monkeypatch.setattr(runner_mod, "DrawingIntegrityAgent", _stub_agent([]))
|
||||
# Brain.run returns our finding; plan_clarifications returns the requests.
|
||||
monkeypatch.setattr(
|
||||
runner_mod, "BrainAgent",
|
||||
lambda usage: type("B", (), {
|
||||
"run": lambda self, findings, si, ju: ([dict(brain_finding)], []),
|
||||
"plan_clarifications": lambda self, prioritized: list(plan_requests),
|
||||
})())
|
||||
|
||||
|
||||
def test_brain_clarify_refutes_and_suppresses(monkeypatch, tmp_path):
|
||||
"""Brain flags a MEDIUM finding wave-5b's severity gate skipped; the
|
||||
clarification verifier refutes it, so it moves to suppressed_issues."""
|
||||
monkeypatch.setattr(config, "ENABLE_BRAIN_CLARIFY", True)
|
||||
finding = {
|
||||
"issue_id": "M1", "severity": "medium", "confidence": "low",
|
||||
"source_stage": "conflict", "sheets": ["S401"],
|
||||
"description": "beam bears on (2) 2x6 stud pack",
|
||||
"evidence": [{"sheet": "S401", "source_text": "(2) 2x6 STUD PACK"}],
|
||||
}
|
||||
_patch_pipeline(monkeypatch, finding, [
|
||||
{"issue_id": "M1", "request_type": "verify_evidence", "reason": "misread?"},
|
||||
])
|
||||
# The clarification verifier returns a 'corrected' verdict -> refuted.
|
||||
monkeypatch.setattr(
|
||||
"backend.agents.verifier.call_json",
|
||||
lambda **kwargs: {"verdicts": [
|
||||
{"sheet": "S401", "source_text": "(2) 2x6 STUD PACK",
|
||||
"verdict": "corrected", "actual_text": "(5) 2x6 STUD PACK",
|
||||
"notes": "reads (5)"},
|
||||
]})
|
||||
pdf = tmp_path / "d.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path),
|
||||
require_review=False)
|
||||
validated = report.get("validated_issues") or []
|
||||
assert all(f.get("issue_id") != "M1" for f in validated) # dropped
|
||||
assert [f["issue_id"] for f in report["suppressed_issues"]] == ["M1"]
|
||||
assert report["suppressed_issues"][0]["verification"]["status"] == "refuted"
|
||||
|
||||
|
||||
def test_brain_clarify_confirms_keeps_finding(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "ENABLE_BRAIN_CLARIFY", True)
|
||||
finding = {
|
||||
"issue_id": "M2", "severity": "medium", "confidence": "low",
|
||||
"source_stage": "conflict", "sheets": ["S401"],
|
||||
"description": "beam bears on (5) 2x6 stud pack",
|
||||
"evidence": [{"sheet": "S401", "source_text": "(5) 2x6 STUD PACK"}],
|
||||
}
|
||||
_patch_pipeline(monkeypatch, finding, [
|
||||
{"issue_id": "M2", "request_type": "verify_evidence", "reason": "check"},
|
||||
])
|
||||
monkeypatch.setattr(
|
||||
"backend.agents.verifier.call_json",
|
||||
lambda **kwargs: {"verdicts": [
|
||||
{"sheet": "S401", "source_text": "(5) 2x6 STUD PACK",
|
||||
"verdict": "confirmed", "actual_text": None, "notes": None},
|
||||
]})
|
||||
pdf = tmp_path / "d.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path),
|
||||
require_review=False)
|
||||
validated = report.get("validated_issues") or []
|
||||
kept = [f for f in validated if f.get("issue_id") == "M2"]
|
||||
assert len(kept) == 1
|
||||
assert kept[0]["verification"]["status"] == "confirmed"
|
||||
assert report["suppressed_issues"] == []
|
||||
|
||||
|
||||
def test_brain_clarify_disabled_is_noop(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "ENABLE_BRAIN_CLARIFY", False)
|
||||
finding = {
|
||||
"issue_id": "M3", "severity": "medium", "confidence": "low",
|
||||
"source_stage": "conflict", "sheets": ["S401"],
|
||||
"description": "d", "evidence": [{"sheet": "S401", "source_text": "t"}],
|
||||
}
|
||||
# plan_clarifications should never be consulted; give it a bomb to prove it.
|
||||
def _bomb(self, prioritized):
|
||||
raise AssertionError("plan_clarifications must not run when disabled")
|
||||
_patch_pipeline(monkeypatch, finding, [])
|
||||
monkeypatch.setattr(
|
||||
runner_mod, "BrainAgent",
|
||||
lambda usage: type("B", (), {
|
||||
"run": lambda self, findings, si, ju: ([dict(finding)], []),
|
||||
"plan_clarifications": _bomb})())
|
||||
pdf = tmp_path / "d.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path),
|
||||
require_review=False)
|
||||
validated = report.get("validated_issues") or []
|
||||
assert any(f.get("issue_id") == "M3" for f in validated)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Unit tests for the per-sheet DrawingIntegrityAgent and scope builder."""
|
||||
|
||||
import backend.agents.integrity_agent as integ
|
||||
from backend.agents.base import AgentScope, AgentUsage
|
||||
from backend.agents.integrity_agent import (
|
||||
DrawingIntegrityAgent, build_integrity_scopes,
|
||||
)
|
||||
from backend import config
|
||||
|
||||
|
||||
def _sheet(page, sheet_number, n_assertions):
|
||||
return {
|
||||
"sheet_number": sheet_number,
|
||||
"page_number": page,
|
||||
"sheet_title": f"Sheet {sheet_number}",
|
||||
"discipline": "Architectural",
|
||||
"assertions": [
|
||||
{"object_type": "note", "source_text": f"note {i}"}
|
||||
for i in range(n_assertions)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_build_scopes_skips_sparse_sheets(monkeypatch):
|
||||
monkeypatch.setattr(config, "INTEGRITY_MIN_ASSERTIONS", 3)
|
||||
sheets = [
|
||||
_sheet(1, "A101", 5), # kept
|
||||
_sheet(2, "A102", 2), # skipped (too sparse)
|
||||
_sheet(3, "A103", 3), # kept (== floor)
|
||||
]
|
||||
page_to_b64 = {1: "IMG1", 2: "IMG2", 3: "IMG3"}
|
||||
page_to_text = {1: "text one", 2: "text two", 3: "text three"}
|
||||
scopes = build_integrity_scopes(sheets, page_to_b64, page_to_text)
|
||||
ids = sorted(s.scope_id for s in scopes)
|
||||
assert ids == ["integrity:1", "integrity:3"]
|
||||
# Each scope carries its own page image + text layer.
|
||||
by_id = {s.scope_id: s for s in scopes}
|
||||
assert by_id["integrity:1"].payload["image_b64"] == "IMG1"
|
||||
assert by_id["integrity:1"].payload["text_layer"] == "text one"
|
||||
|
||||
|
||||
def test_agent_parses_and_anchors_sheet(monkeypatch):
|
||||
"""Findings with blank sheets get anchored to the scope's sheet number."""
|
||||
captured = {}
|
||||
|
||||
def fake_call_json(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"issues": [
|
||||
{"issue_id": "DI-1", "severity": "high", "confidence": "high",
|
||||
"category": "dangling_reference", "sheets": [],
|
||||
"description": "Detail callout 5/A101 has no detail 5 on this sheet",
|
||||
"evidence": [{"sheet": "A101", "source_text": "5/A101"}]},
|
||||
]}
|
||||
|
||||
monkeypatch.setattr(integ, "call_json", fake_call_json)
|
||||
sheet = _sheet(1, "A101", 5)
|
||||
scope = AgentScope("integrity:1", {
|
||||
"sheet": sheet, "page_number": 1,
|
||||
"image_b64": "IMG1", "text_layer": "the deterministic text layer",
|
||||
})
|
||||
result = DrawingIntegrityAgent(AgentUsage()).run(scope)
|
||||
assert result.error == ""
|
||||
assert len(result.artifacts) == 1
|
||||
finding = result.artifacts[0]
|
||||
assert finding["source_stage"] == "drawing_integrity"
|
||||
assert finding["sheets"] == ["A101"] # anchored
|
||||
assert finding["agent"] == "drawing_integrity"
|
||||
assert finding["scope_id"] == "integrity:1"
|
||||
# The image + text layer reached the model.
|
||||
assert captured["images_b64"] == ["IMG1"]
|
||||
assert "the deterministic text layer" in captured["user_text"]
|
||||
|
||||
|
||||
def test_agent_empty_issues_is_clean(monkeypatch):
|
||||
monkeypatch.setattr(integ, "call_json", lambda **k: {"issues": []})
|
||||
scope = AgentScope("integrity:1", {
|
||||
"sheet": _sheet(1, "A101", 5), "page_number": 1,
|
||||
"image_b64": "IMG1", "text_layer": "t",
|
||||
})
|
||||
result = DrawingIntegrityAgent(AgentUsage()).run(scope)
|
||||
assert result.error == ""
|
||||
assert result.artifacts == []
|
||||
|
||||
|
||||
def test_agent_survives_call_failure(monkeypatch):
|
||||
def boom(**kwargs):
|
||||
raise RuntimeError("model exploded")
|
||||
monkeypatch.setattr(integ, "call_json", boom)
|
||||
scope = AgentScope("integrity:1", {
|
||||
"sheet": _sheet(1, "A101", 5), "page_number": 1,
|
||||
"image_b64": "IMG1", "text_layer": "t",
|
||||
})
|
||||
result = DrawingIntegrityAgent(AgentUsage()).run(scope)
|
||||
assert "model exploded" in result.error
|
||||
assert result.artifacts == []
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Flag-gating tests: ENABLE_CODE_REVIEW off skips code, drawing_integrity runs.
|
||||
|
||||
Runner-level smoke tests using stubbed agents (same pattern as
|
||||
test_wave5b_suppression). Verifies the code/ADA wave is skipped when
|
||||
ENABLE_CODE_REVIEW is false and the Drawing Integrity wave feeds findings
|
||||
into the report by_stage counters.
|
||||
"""
|
||||
|
||||
import backend.agents.runner as runner_mod
|
||||
from backend import config
|
||||
from backend.agents.base import AgentResult
|
||||
from backend.agents.runner import run_agent_pipeline
|
||||
|
||||
|
||||
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 _integrity_finding():
|
||||
return {
|
||||
"issue_id": "DI-1", "severity": "high", "confidence": "high",
|
||||
"source_stage": "drawing_integrity", "sheets": ["A101"],
|
||||
"category": "dangling_reference",
|
||||
"description": "Detail callout 5/A101 has no detail 5 on this sheet",
|
||||
"evidence": [{"sheet": "A101", "source_text": "5/A101"}],
|
||||
}
|
||||
|
||||
|
||||
def _patch(monkeypatch, code_should_raise):
|
||||
monkeypatch.setattr(
|
||||
runner_mod, "convert_pdf_to_images",
|
||||
lambda path: [{"page_number": 1, "base64": "QUJD"}])
|
||||
# Sheet has 3+ assertions so the integrity wave does NOT skip it.
|
||||
monkeypatch.setattr(runner_mod, "SheetExtractorAgent", _stub_agent([
|
||||
{"sheet_number": "A101", "page_number": 1, "level": "1",
|
||||
"discipline": "A", "assertions": [
|
||||
{"text": "5/A101", "object_type": "detail_marker"},
|
||||
{"text": "ROOM 101", "object_type": "room"},
|
||||
{"text": "DOOR 101A", "object_type": "door"},
|
||||
]},
|
||||
]))
|
||||
monkeypatch.setattr(runner_mod, "SheetIndexAgent", _stub_agent([{}]))
|
||||
monkeypatch.setattr(runner_mod, "JurisdictionAgent", _stub_agent([{}]))
|
||||
monkeypatch.setattr(runner_mod, "LinkerAgent", _stub_agent([]))
|
||||
monkeypatch.setattr(runner_mod, "ConflictCriticAgent", _stub_agent([]))
|
||||
|
||||
def code_boom(usage):
|
||||
if code_should_raise:
|
||||
raise AssertionError("CodeAgent must not run when gated off")
|
||||
return _stub_agent([])(usage)
|
||||
monkeypatch.setattr(runner_mod, "CodeAgent", code_boom)
|
||||
|
||||
monkeypatch.setattr(runner_mod, "DrawingIntegrityAgent",
|
||||
_stub_agent([_integrity_finding()]))
|
||||
monkeypatch.setattr(runner_mod, "ConstructabilityAgent", _stub_agent([]))
|
||||
monkeypatch.setattr(runner_mod, "CompletenessAgent", _stub_agent([]))
|
||||
monkeypatch.setattr(
|
||||
runner_mod, "BrainAgent",
|
||||
lambda usage: type("B", (), {
|
||||
"run": lambda self, findings, si, ju: (list(findings), []),
|
||||
"plan_clarifications": lambda self, prioritized: []})())
|
||||
# Stub the wave-5b verifier so the high-severity integrity finding is
|
||||
# confirmed (never a live network call).
|
||||
monkeypatch.setattr(
|
||||
"backend.agents.verifier.call_json",
|
||||
lambda **kwargs: {"verdicts": [
|
||||
{"sheet": "A101", "source_text": "5/A101",
|
||||
"verdict": "confirmed", "actual_text": None, "notes": None},
|
||||
]})
|
||||
|
||||
|
||||
def test_code_gated_off_integrity_on(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "ENABLE_CODE_REVIEW", False)
|
||||
monkeypatch.setattr(config, "ENABLE_DRAWING_INTEGRITY", True)
|
||||
_patch(monkeypatch, code_should_raise=True)
|
||||
pdf = tmp_path / "d.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path),
|
||||
require_review=False)
|
||||
by_stage = report["summary"]["by_stage"]
|
||||
assert by_stage["code"] == 0
|
||||
assert by_stage["drawing_integrity"] == 1
|
||||
# The integrity finding survived into the validated set.
|
||||
assert any(f.get("issue_id") == "DI-1"
|
||||
for f in report.get("validated_issues") or [])
|
||||
|
||||
|
||||
def test_code_enabled_runs(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "ENABLE_CODE_REVIEW", True)
|
||||
monkeypatch.setattr(config, "ENABLE_DRAWING_INTEGRITY", True)
|
||||
_patch(monkeypatch, code_should_raise=False)
|
||||
# build_code_scopes runs on the real sheet; CodeAgent is stubbed to []
|
||||
pdf = tmp_path / "d.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path),
|
||||
require_review=False)
|
||||
# No crash; integrity still reported.
|
||||
assert report["summary"]["by_stage"]["drawing_integrity"] == 1
|
||||
@@ -4,7 +4,7 @@ from backend.agents.runner import run_agent_pipeline
|
||||
|
||||
def _patch_brain(monkeypatch):
|
||||
monkeypatch.setattr("backend.agents.runner.convert_pdf_to_images", lambda path: [{"page_number": 1, "base64": "x"}])
|
||||
monkeypatch.setattr("backend.agents.runner.BrainAgent", lambda usage: type("B", (), {"run": lambda self, findings, sheet_index, jurisdiction: ([{"issue_id": "AGENT-0001", "severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}], [])})())
|
||||
monkeypatch.setattr("backend.agents.runner.BrainAgent", lambda usage: type("B", (), {"run": lambda self, findings, sheet_index, jurisdiction: ([{"issue_id": "AGENT-0001", "severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}], []), "plan_clarifications": lambda self, prioritized: []})())
|
||||
|
||||
|
||||
def test_agent_runner_can_enter_review_mode(monkeypatch, tmp_path):
|
||||
|
||||
@@ -63,7 +63,8 @@ def _patch_pipeline(monkeypatch, finding, verify_sink):
|
||||
runner_mod, "BrainAgent",
|
||||
lambda usage: type("B", (), {
|
||||
"run": lambda self, findings, sheet_index, jurisdiction:
|
||||
(list(findings), [])})())
|
||||
(list(findings), []),
|
||||
"plan_clarifications": lambda self, prioritized: []})())
|
||||
|
||||
class _RecordingVerifier:
|
||||
name = "verify"
|
||||
|
||||
@@ -46,7 +46,8 @@ def _patch_pipeline(monkeypatch, finding):
|
||||
runner_mod, "BrainAgent",
|
||||
lambda usage: type("B", (), {
|
||||
"run": lambda self, findings, sheet_index, jurisdiction:
|
||||
(list(findings), [])})())
|
||||
(list(findings), []),
|
||||
"plan_clarifications": lambda self, prioritized: []})())
|
||||
|
||||
|
||||
def test_refuted_finding_is_suppressed_not_crash(monkeypatch, tmp_path):
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for the classic-path drawing_integrity_review stage + gating."""
|
||||
|
||||
import backend.pipeline.drawing_integrity as di
|
||||
from backend import config
|
||||
from backend.pipeline.drawing_integrity import drawing_integrity_review
|
||||
|
||||
|
||||
def _sheet(page, sheet_number, n):
|
||||
return {
|
||||
"sheet_number": sheet_number,
|
||||
"page_number": page,
|
||||
"discipline": "Architectural",
|
||||
"assertions": [{"source_text": f"n{i}"} for i in range(n)],
|
||||
}
|
||||
|
||||
|
||||
def _pages(pages):
|
||||
return [{"page_number": p, "base64": f"IMG{p}", "text_layer": f"txt{p}"}
|
||||
for p in pages]
|
||||
|
||||
|
||||
def test_disabled_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(config, "ENABLE_DRAWING_INTEGRITY", False)
|
||||
called = []
|
||||
monkeypatch.setattr(di, "call_stage", lambda *a, **k: called.append(1) or {})
|
||||
out = drawing_integrity_review([_sheet(1, "A101", 5)], _pages([1]))
|
||||
assert out == []
|
||||
assert called == [] # no LLM calls when disabled
|
||||
|
||||
|
||||
def test_reviews_only_dense_sheets(monkeypatch):
|
||||
monkeypatch.setattr(config, "ENABLE_DRAWING_INTEGRITY", True)
|
||||
monkeypatch.setattr(config, "INTEGRITY_MIN_ASSERTIONS", 3)
|
||||
seen_sheets = []
|
||||
|
||||
def fake_call_stage(system, user, subs=None, images_b64=None, **k):
|
||||
# Record which sheet_meta reached the model.
|
||||
seen_sheets.append(subs["sheet_meta"])
|
||||
return {"issues": [
|
||||
{"severity": "medium", "confidence": "high",
|
||||
"category": "on_sheet_contradiction", "sheets": [],
|
||||
"description": "plan disagrees with same-sheet schedule"},
|
||||
]}
|
||||
|
||||
monkeypatch.setattr(di, "call_stage", fake_call_stage)
|
||||
sheets = [_sheet(1, "A101", 5), _sheet(2, "A102", 1), _sheet(3, "A103", 4)]
|
||||
out = drawing_integrity_review(sheets, _pages([1, 2, 3]))
|
||||
# Two dense sheets reviewed, one skipped; each produced one anchored finding.
|
||||
assert len(out) == 2
|
||||
assert all(f["source_stage"] == "drawing_integrity" for f in out)
|
||||
assert {tuple(f["sheets"]) for f in out} == {("A101",), ("A103",)}
|
||||
assert len(seen_sheets) == 2
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Regression tests for defects found in the Aug 2026 agent-mode code review.
|
||||
|
||||
R2 - text_coverage._SHEET_ID_RE could not match hyphenated sheet ids (C-001),
|
||||
leaving civil/landscape pages sheet_number=None and producing false
|
||||
"declared but not in set" reconciliation warnings.
|
||||
R3 - config bool knobs mixed `== "true"` with the 1/true/yes set, so setting
|
||||
EXTRACT_TEXT_RETRY_ENABLED=1 silently DISABLED the retry ladder.
|
||||
|
||||
See also tests/agents/test_verifier.py for the verifier's "corrected"
|
||||
semantics, which are intentional and pinned there.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from backend.sheet_reconcile import declared_sheet_list, reconcile_sheets
|
||||
from backend.text_coverage import recover_sheet_number
|
||||
|
||||
|
||||
# --- R2: hyphenated sheet ids ----------------------------------------------
|
||||
|
||||
def test_recover_sheet_number_handles_hyphenated_civil_id():
|
||||
page_text = ("GENERAL NOTES\n" * 40) + "PROJECT NO 2024-118\nSHEET\nC-001\n"
|
||||
assert recover_sheet_number(page_text) == "C-001"
|
||||
|
||||
|
||||
def test_recover_sheet_number_still_handles_plain_ids():
|
||||
page_text = ("NOTES\n" * 40) + "SHEET\nS302\n"
|
||||
assert recover_sheet_number(page_text) == "S302"
|
||||
|
||||
|
||||
def test_recovered_hyphenated_id_reconciles_against_declared_index():
|
||||
"""The whole point: a recovered C-001 must not read as a missing sheet."""
|
||||
declared = declared_sheet_list({1: "SHEET LIST\nC-001 CIVIL\nA102 PLAN\n"})
|
||||
assert declared == ["C-001", "A102"]
|
||||
recovered = recover_sheet_number(("X\n" * 40) + "SHEET\nC-001\n")
|
||||
recon = reconcile_sheets(
|
||||
[{"sheet_number": recovered}, {"sheet_number": "A102"}], declared)
|
||||
assert recon["declared_not_in_set"] == []
|
||||
assert recon["in_set_not_declared"] == []
|
||||
|
||||
|
||||
# --- R3: boolean env knob parsing ------------------------------------------
|
||||
|
||||
def test_numeric_one_enables_ladder_knobs():
|
||||
with mock.patch.dict(os.environ, {
|
||||
"EXTRACT_TEXT_RETRY_ENABLED": "1",
|
||||
"EXTRACT_FALLBACK_ENABLED": "yes",
|
||||
}):
|
||||
cfg = importlib.reload(importlib.import_module("backend.config"))
|
||||
try:
|
||||
assert cfg.EXTRACT_TEXT_RETRY_ENABLED is True
|
||||
assert cfg.EXTRACT_FALLBACK_ENABLED is True
|
||||
finally:
|
||||
importlib.reload(cfg)
|
||||
|
||||
|
||||
def test_false_values_still_disable_ladder_knobs():
|
||||
with mock.patch.dict(os.environ, {
|
||||
"EXTRACT_TEXT_RETRY_ENABLED": "false",
|
||||
"EXTRACT_FALLBACK_ENABLED": "0",
|
||||
}):
|
||||
cfg = importlib.reload(importlib.import_module("backend.config"))
|
||||
try:
|
||||
assert cfg.EXTRACT_TEXT_RETRY_ENABLED is False
|
||||
assert cfg.EXTRACT_FALLBACK_ENABLED is False
|
||||
finally:
|
||||
importlib.reload(cfg)
|
||||
Reference in New Issue
Block a user