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