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.pipeline.sheet_index import derive_project_meta_from_cover
|
||||||
from backend.review.gate import build_review_queue
|
from backend.review.gate import build_review_queue
|
||||||
from backend.review.store import ReviewStore
|
from backend.review.store import ReviewStore
|
||||||
|
from backend.sheet_reconcile import declared_sheet_list, reconcile_sheets
|
||||||
from backend.text_layer import (
|
from backend.text_layer import (
|
||||||
attach_text_layers, coverage_gaps, find_evidence_bbox, render_crop,
|
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)
|
sheets.sort(key=lambda sheet: sheet.get("page_number") or 0)
|
||||||
memory.replace("sheets", sheets)
|
memory.replace("sheets", sheets)
|
||||||
memory.dump("01-extract.json")
|
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
|
# Coverage signal: text layer present but extraction failed/empty reuses
|
||||||
# the failed-scopes gap-finding path (finding built below wave 6).
|
# the failed-scopes gap-finding path (finding built below wave 6).
|
||||||
for gap_page in coverage_gaps(pages, sheets):
|
for gap_page in coverage_gaps(pages, sheets):
|
||||||
@@ -289,6 +304,7 @@ def run_agent_pipeline(
|
|||||||
"project_input": merged_input,
|
"project_input": merged_input,
|
||||||
"jurisdiction": jurisdiction,
|
"jurisdiction": jurisdiction,
|
||||||
"sheet_index": sheet_index,
|
"sheet_index": sheet_index,
|
||||||
|
"sheet_reconciliation": sheet_recon,
|
||||||
"project_intelligence": object_graph,
|
"project_intelligence": object_graph,
|
||||||
"validated_issues": prioritized,
|
"validated_issues": prioritized,
|
||||||
"rfis": [],
|
"rfis": [],
|
||||||
@@ -365,6 +381,7 @@ def run_agent_pipeline(
|
|||||||
"project_input": merged_input,
|
"project_input": merged_input,
|
||||||
"jurisdiction": jurisdiction,
|
"jurisdiction": jurisdiction,
|
||||||
"sheet_index": sheet_index,
|
"sheet_index": sheet_index,
|
||||||
|
"sheet_reconciliation": sheet_recon,
|
||||||
"project_intelligence": object_graph,
|
"project_intelligence": object_graph,
|
||||||
"validated_issues": prioritized,
|
"validated_issues": prioritized,
|
||||||
"rfis": rfis,
|
"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.pdf_processor import convert_pdf_to_images
|
||||||
from backend.pipeline.extractor import extract_assertions
|
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.text_layer import attach_text_layers, coverage_gaps
|
||||||
from backend.pipeline.sheet_index import classify_sheets, derive_project_meta_from_cover
|
from backend.pipeline.sheet_index import classify_sheets, derive_project_meta_from_cover
|
||||||
from backend.pipeline.jurisdiction import run_jurisdiction
|
from backend.pipeline.jurisdiction import run_jurisdiction
|
||||||
@@ -111,6 +112,19 @@ def _run_stages(
|
|||||||
sheets = extract_assertions(pages)
|
sheets = extract_assertions(pages)
|
||||||
coverage_gaps(pages, sheets) # classic: log-only recall signal
|
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")
|
stage("Classify sheet index")
|
||||||
sheet_index = classify_sheets(sheets)
|
sheet_index = classify_sheets(sheets)
|
||||||
|
|
||||||
@@ -167,6 +181,7 @@ def _run_stages(
|
|||||||
report["project_input"] = merged_input
|
report["project_input"] = merged_input
|
||||||
report["jurisdiction"] = jurisdiction
|
report["jurisdiction"] = jurisdiction
|
||||||
report["sheet_index"] = sheet_index
|
report["sheet_index"] = sheet_index
|
||||||
|
report["sheet_reconciliation"] = sheet_recon
|
||||||
report["project_intelligence"] = project_intel
|
report["project_intelligence"] = project_intel
|
||||||
report["validated_issues"] = prioritized
|
report["validated_issues"] = prioritized
|
||||||
report["rfis"] = rfis
|
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