Files
woogiandClaude Opus 4.8 1d248a8808 Initial commit: Conflict Checker
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>
2026-07-03 00:22:02 +00:00

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