Cross-discipline design-contradiction checker for construction drawing sets. Standalone tool broken out from Iron_Bid; a pipeline stage may later fold back into Iron_Bid. Pipeline: PDF->images -> per-sheet assertion extraction -> deterministic clustering by location -> per-cluster reasoning -> report. Includes CLI (cli/run_check.py) and web UI (backend/main.py). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
4.6 KiB
Python
118 lines
4.6 KiB
Python
"""
|
|
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
|