feat: deterministic sheet-list reconciliation (cover index vs identified sheets)
Docker Release / build-and-push (push) Successful in 1m19s
Docker Release / release (push) Skipped

This commit is contained in:
2026-08-18 13:49:34 -05:00
parent fe09e4a66b
commit bae608a505
4 changed files with 211 additions and 0 deletions
+17
View File
@@ -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,
+15
View File
@@ -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
+93
View File
@@ -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,
}
+86
View File
@@ -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"]