Files
Conflict_Checker/backend/sheet_reconcile.py
T
woogi bae608a505
Docker Release / build-and-push (push) Successful in 1m19s
Docker Release / release (push) Skipped
feat: deterministic sheet-list reconciliation (cover index vs identified sheets)
2026-08-18 13:49:34 -05:00

94 lines
3.4 KiB
Python

"""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,
}