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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user