feat: deterministic sheet-list reconciliation (cover index vs identified sheets)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user