""" sheet_index.py - Stage 1: sheet index and drawing classification (LLM). Classifies the set's sheets (discipline, drawing type, level, QAQC relevance) and flags missing expected sheets, reusing the title-block data the extractor already produced -- no extra image calls. Also exposes a cheap, no-LLM helper that scrapes best-effort project metadata off the cover sheet so Stage 0 (jurisdiction) has something to work with when the intake form is blank. """ import json import re from typing import Dict, List, Optional from backend import config from backend.pipeline._stage import call_stage from backend.prompts import SHEET_INDEX_SYSTEM_PROMPT, SHEET_INDEX_USER_INSTRUCTION def _index_input(sheets: List[Dict]) -> List[Dict]: """Compact per-sheet records for the classification call.""" return [ { "sheet_id": s.get("sheet_number") or f"page-{s.get('page_number')}", "sheet_number": s.get("sheet_number"), "sheet_title": s.get("sheet_title"), "discipline": s.get("discipline"), "level": s.get("level"), "page_number": s.get("page_number"), } for s in sheets ] def classify_sheets(sheets: List[Dict]) -> Dict: """Return the classified sheet index, or {} on failure.""" parsed = call_stage( SHEET_INDEX_SYSTEM_PROMPT, SHEET_INDEX_USER_INSTRUCTION, subs={"sheet_index_input": json.dumps(_index_input(sheets), ensure_ascii=True)}, max_tokens=config.SHEET_INDEX_MAX_TOKENS, ) if isinstance(parsed, dict): return parsed if isinstance(parsed, list): # Model returned a bare array of sheet entries -> wrap it. return {"sheet_index": parsed, "missing_expected_sheets": []} return {} # --- cover-sheet metadata (no LLM) ----------------------------------------- _ADDR_RE = re.compile(r"\d{1,6}\s+\w[\w .]*\b(?:st|street|ave|avenue|rd|road|blvd|" r"boulevard|dr|drive|ln|lane|way|hwy|highway|ct|court)\b", re.IGNORECASE) def _is_cover(sheet: Dict) -> bool: title = (sheet.get("sheet_title") or "").lower() num = (sheet.get("sheet_number") or "").upper() return ("cover" in title or "title sheet" in title or num in ("G000", "G-000", "G0.0", "T0.0", "G001", "G-001") or sheet.get("page_number") == 1) # Words that, alone, never make up a real project name (so a title built only # from these is "generic" and should not be used as the project name). _GENERIC_WORDS = {"cover", "sheet", "title", "index", "general", "notes", "drawing", "drawings", "of", "the", "and"} _FILE_NOISE_RE = re.compile( r"(?i)(?:\d{2,3}%|\breview set\b|\bpermit set\b|\bbid set\b|" r"\bconstruction set\b|\bdrawings?\b|\bset\b|\bissued for \w+\b|" r"\bifc\b|\bifp\b)") _TMP_PREFIX_RE = re.compile(r"^tmp\w+?_") _DATE_RE = re.compile(r"\b\d{6,8}\b") def _is_generic_title(title: str) -> bool: t = re.sub(r"[^a-z ]", "", (title or "").lower()).strip() return (not t) or all(w in _GENERIC_WORDS for w in t.split()) def _name_from_filename(source_name: Optional[str]) -> Optional[str]: """'tmpXXXX_20260618_Verizon Elyson_100% Review Set.pdf' -> 'Verizon Elyson'.""" if not source_name: return None stem = re.sub(r"\.pdf$", "", source_name, flags=re.IGNORECASE) stem = _TMP_PREFIX_RE.sub("", stem) stem = stem.replace("_", " ") # spaces first so dates get boundaries stem = _DATE_RE.sub(" ", stem) stem = _FILE_NOISE_RE.sub(" ", stem) stem = re.sub(r"\s+", " ", stem).strip(" -") return stem or None def derive_project_meta_from_cover(sheets: List[Dict], source_name: Optional[str] = None) -> Dict: """Best-effort {project_name, address} from cover-sheet assertions + filename.""" meta: Dict[str, str] = {} covers = [s for s in sheets if _is_cover(s)] or sheets[:1] for s in covers: for a in s.get("assertions", []): text = (a.get("source_text") or a.get("value") or "").strip() if text and "address" not in meta: m = _ADDR_RE.search(text) if m: meta["address"] = m.group(0).strip() # Use the cover title as the name only if it isn't a generic label. title = (s.get("sheet_title") or "").strip() if "project_name" not in meta and title and not _is_generic_title(title): meta["project_name"] = title # Filename is the most reliable name for retail/prototype sets. if "project_name" not in meta: fn = _name_from_filename(source_name) if fn: meta["project_name"] = fn return meta