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,
|
||||
}
|
||||
Reference in New Issue
Block a user