diff --git a/backend/agents/runner.py b/backend/agents/runner.py index 7a5716a..0cff159 100644 --- a/backend/agents/runner.py +++ b/backend/agents/runner.py @@ -31,6 +31,7 @@ from backend.pipeline.report import build_report, to_markdown from backend.pipeline.sheet_index import derive_project_meta_from_cover from backend.review.gate import build_review_queue from backend.review.store import ReviewStore +from backend.sheet_reconcile import declared_sheet_list, reconcile_sheets from backend.text_layer import ( attach_text_layers, coverage_gaps, find_evidence_bbox, render_crop, ) @@ -84,6 +85,20 @@ def run_agent_pipeline( sheets.sort(key=lambda sheet: sheet.get("page_number") or 0) memory.replace("sheets", sheets) memory.dump("01-extract.json") + + # Deterministic reconciliation: the cover sheet's own sheet index + # declares what the set should contain; compare against what wave 1 + # identified (catches missed sheets AND phantom/misread sheet numbers). + sheet_recon = reconcile_sheets(sheets, declared_sheet_list(page_to_text)) + if sheet_recon["declared_total"]: + print(f"[SheetIndex] cover declares {sheet_recon['declared_total']} " + f"sheets; {sheet_recon['found_total']} identified in set") + if sheet_recon["declared_not_in_set"]: + print(f"[SheetIndex] declared but not in set: " + f"{', '.join(sheet_recon['declared_not_in_set'][:20])}") + if sheet_recon["in_set_not_declared"]: + print(f"[SheetIndex] in set but not declared: " + f"{', '.join(sheet_recon['in_set_not_declared'][:20])}") # Coverage signal: text layer present but extraction failed/empty reuses # the failed-scopes gap-finding path (finding built below wave 6). for gap_page in coverage_gaps(pages, sheets): @@ -289,6 +304,7 @@ def run_agent_pipeline( "project_input": merged_input, "jurisdiction": jurisdiction, "sheet_index": sheet_index, + "sheet_reconciliation": sheet_recon, "project_intelligence": object_graph, "validated_issues": prioritized, "rfis": [], @@ -365,6 +381,7 @@ def run_agent_pipeline( "project_input": merged_input, "jurisdiction": jurisdiction, "sheet_index": sheet_index, + "sheet_reconciliation": sheet_recon, "project_intelligence": object_graph, "validated_issues": prioritized, "rfis": rfis, diff --git a/backend/pipeline/runner.py b/backend/pipeline/runner.py index 58e8c7f..3a913fc 100644 --- a/backend/pipeline/runner.py +++ b/backend/pipeline/runner.py @@ -27,6 +27,7 @@ from typing import Dict, Optional, Callable from backend.pipeline.pdf_processor import convert_pdf_to_images from backend.pipeline.extractor import extract_assertions +from backend.sheet_reconcile import declared_sheet_list, reconcile_sheets from backend.text_layer import attach_text_layers, coverage_gaps from backend.pipeline.sheet_index import classify_sheets, derive_project_meta_from_cover from backend.pipeline.jurisdiction import run_jurisdiction @@ -111,6 +112,19 @@ def _run_stages( sheets = extract_assertions(pages) coverage_gaps(pages, sheets) # classic: log-only recall signal + # Deterministic reconciliation: cover-sheet index vs identified sheets. + page_to_text = {p["page_number"]: p.get("text_layer") for p in pages} + sheet_recon = reconcile_sheets(sheets, declared_sheet_list(page_to_text)) + if sheet_recon["declared_total"]: + print(f"[SheetIndex] cover declares {sheet_recon['declared_total']} " + f"sheets; {sheet_recon['found_total']} identified in set") + if sheet_recon["declared_not_in_set"]: + print(f"[SheetIndex] declared but not in set: " + f"{', '.join(sheet_recon['declared_not_in_set'][:20])}") + if sheet_recon["in_set_not_declared"]: + print(f"[SheetIndex] in set but not declared: " + f"{', '.join(sheet_recon['in_set_not_declared'][:20])}") + stage("Classify sheet index") sheet_index = classify_sheets(sheets) @@ -167,6 +181,7 @@ def _run_stages( report["project_input"] = merged_input report["jurisdiction"] = jurisdiction report["sheet_index"] = sheet_index + report["sheet_reconciliation"] = sheet_recon report["project_intelligence"] = project_intel report["validated_issues"] = prioritized report["rfis"] = rfis diff --git a/backend/sheet_reconcile.py b/backend/sheet_reconcile.py new file mode 100644 index 0000000..a8f888b --- /dev/null +++ b/backend/sheet_reconcile.py @@ -0,0 +1,93 @@ +"""sheet_reconcile.py - deterministic sheet-list reconciliation (no LLM). + +The cover sheet's own sheet index (SHEET LIST / DRAWING INDEX) declares which +sheets the set is SUPPOSED to contain. Comparing that declaration against the +sheets wave-1 actually identified answers two early questions: + +- declared_not_in_set: sheets the index lists but we didn't identify - dark + pages, misidentification, or disciplines genuinely absent from this PDF. +- in_set_not_declared: sheet numbers we extracted that the index doesn't + list - misread title blocks or unlisted sheets. + +Deterministic complement to the LLM sheet_index stage, which can only infer +from what extraction already found. +""" + +import re +from typing import Dict, List, Optional + +# Markers that introduce the drawing set's own sheet index on a cover page. +_INDEX_MARKERS = ( + "SHEET LIST", + "DRAWING INDEX", + "SHEET INDEX", + "DRAWING LIST", + "INDEX OF DRAWINGS", +) + +# Sheet ids: 1-2 letters, optional hyphen, 2-3 digits, optional decimal suffix. +# Covers S301, A102, LS101, C-001, C-001.1; excludes dates/project numbers +# (pure digits) and member marks (W12X26 - letter after digits). +_SHEET_TOKEN_RE = re.compile(r"\b([A-Z]{1,2}-?\d{2,3}(?:\.\d+)?)\b") + +# Only cover-front pages carry the set index. +_MAX_INDEX_PAGE = 5 + + +def _normalize_id(sheet_id: str) -> str: + return (sheet_id or "").upper().replace("-", "").strip() + + +def declared_sheet_list(page_texts: Dict[int, Optional[str]]) -> List[str]: + """Scrape the declared sheet list off the cover page's text layer. + + page_texts: {page_number: text_layer_or_None}. Returns the ordered, + deduped list of declared sheet ids, or [] when no index marker exists. + Only the FIRST page containing a marker is parsed (later 'sheet list' + echoes in legends/schedules are ignored). + """ + for page_number in sorted(page_texts): + if page_number > _MAX_INDEX_PAGE: + break + text = page_texts.get(page_number) or "" + upper = text.upper() + marker_at = -1 + for marker in _INDEX_MARKERS: + marker_at = upper.find(marker) + if marker_at >= 0: + break + if marker_at < 0: + continue + section = text[marker_at:] + declared: List[str] = [] + for token in _SHEET_TOKEN_RE.findall(section): + if token not in declared: + declared.append(token) + return declared + return [] + + +def reconcile_sheets(sheets: List[Dict], declared: List[str]) -> Dict: + """Compare extracted sheet_numbers against the declared index. + + Comparison is hyphen/case-normalized; output lists keep the declared / + extracted originals. + """ + found: List[str] = [str(s["sheet_number"]) for s in sheets or [] + if s.get("sheet_number")] + found_norm = {_normalize_id(n) for n in found} + declared_norm = {_normalize_id(n) for n in declared} + + declared_not_in_set = [n for n in declared if _normalize_id(n) not in found_norm] + # Preserve extraction order, dedupe, keep originals. + in_set_not_declared: List[str] = [] + for n in found: + if _normalize_id(n) not in declared_norm and n not in in_set_not_declared: + in_set_not_declared.append(n) + + return { + "declared_total": len(declared), + "found_total": len(found), + "declared_not_in_set": declared_not_in_set, + "in_set_not_declared": in_set_not_declared, + } diff --git a/tests/test_sheet_reconcile.py b/tests/test_sheet_reconcile.py new file mode 100644 index 0000000..9f38165 --- /dev/null +++ b/tests/test_sheet_reconcile.py @@ -0,0 +1,86 @@ +"""Deterministic sheet-list reconciliation: cover index vs extracted sheets.""" + +from backend.sheet_reconcile import declared_sheet_list, reconcile_sheets + +COVER_TEXT = """VERIZON CYPRESS +SHEET LIST +SHEET NUMBER +SHEET NAME +G000 +COVER +G001 +GENERAL INFO +C-001 +CIVIL COVER +C-001.1 +ALTA SURVEY +L-101 +LANDSCAPE PLAN +S101 +FOUNDATION PLAN +S301 +WALL SECTIONS +S401 +PERSPECTIVE VIEW +A101 +FLOOR PLAN +A102 +REFLECTED CEILING PLAN +E400 +ELECTRICAL SITE PLAN +""" + + +def test_declared_sheet_list_from_cover(): + declared = declared_sheet_list({1: COVER_TEXT, 2: "symbols legend"}) + assert declared[0] == "G000" + assert "C-001" in declared and "C-001.1" in declared # hyphenated ids kept + assert "L-101" in declared + assert "A102" in declared + assert declared.count("G000") == 1 + assert len(declared) == 11 + + +def test_declared_sheet_list_uses_first_index_page_only(): + texts = {1: "no index here", 2: COVER_TEXT, 3: "SHEET LIST\nXX999\nBOGUS"} + declared = declared_sheet_list(texts) + assert "XX999" not in declared # only the first marker page is parsed + + +def test_declared_sheet_list_none_when_no_marker(): + assert declared_sheet_list({1: "just notes", 2: "floor plan stuff"}) == [] + + +def _sheets(*nums): + return [{"page_number": i + 1, "sheet_number": n} + for i, n in enumerate(nums)] + + +def test_reconcile_both_directions(): + declared = declared_sheet_list({1: COVER_TEXT}) + rec = reconcile_sheets(_sheets("G000", "G001", "S101", "S301", "S302", "A101"), + declared) + # declared but not extracted (civil/landscape not in this PDF + missing) + assert "C-001" in rec["declared_not_in_set"] + assert "A102" in rec["declared_not_in_set"] + assert "E400" in rec["declared_not_in_set"] + # extracted but not on the cover index (misread or unlisted sheet) + assert rec["in_set_not_declared"] == ["S302"] + assert rec["declared_total"] == 11 + assert rec["found_total"] == 6 + + +def test_reconcile_normalizes_hyphens(): + declared = ["C-001", "S301"] + rec = reconcile_sheets(_sheets("C001", "S301"), declared) + assert rec["declared_not_in_set"] == [] + assert rec["in_set_not_declared"] == [] + + +def test_reconcile_ignores_unidentified_sheets(): + rec = reconcile_sheets( + [{"page_number": 8, "sheet_number": None}, + {"page_number": 9, "sheet_number": "S301"}], + ["S301", "A102"]) + assert rec["found_total"] == 1 + assert rec["declared_not_in_set"] == ["A102"]