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>
173 lines
6.2 KiB
Python
173 lines
6.2 KiB
Python
"""
|
|
clusterer.py - Stage 2: deterministic co-location clustering (no LLM).
|
|
|
|
Collapses the N-by-N sheet-comparison explosion into a handful of clusters
|
|
where assertions from different disciplines (or a schedule vs a plan within one
|
|
discipline) refer to the SAME place or element. Only clusters worth reasoning
|
|
about reach Stage 3, which keeps each reasoning call small and cheap.
|
|
|
|
A cluster is emitted when, for a shared normalized location key, either:
|
|
- assertions come from >= 2 distinct disciplines (cross-discipline), or
|
|
- one discipline mixes a schedule/count fact with a plan/location/dimension
|
|
fact for the same element (schedule-vs-plan).
|
|
|
|
Limitation (v1): a location that appears in only ONE discipline never forms a
|
|
cluster, so pure "missing element" gaps are not caught deterministically.
|
|
Stage 3 still catches missing counterparts whenever the location is co-located.
|
|
"""
|
|
|
|
import re
|
|
from typing import List, Dict, Tuple, Optional
|
|
|
|
# Caps to keep Stage 3 token usage bounded.
|
|
MAX_CLUSTERS = 120
|
|
MAX_ASSERTIONS_PER_CLUSTER = 24
|
|
|
|
_SCHEDULE_HINTS = ("count", "schedule", "_entry")
|
|
_PLAN_HINTS = ("location", "dim", "elev")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Location-key normalization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _norm_grid(v: str) -> Optional[str]:
|
|
"""'A/3', '3-A', 'A.3' -> canonical sorted 'token/token'."""
|
|
toks = [t for t in re.split(r"[^A-Za-z0-9]+", v.upper()) if t]
|
|
if not toks:
|
|
return None
|
|
return "/".join(sorted(toks))
|
|
|
|
|
|
def _norm_room(v: str) -> Optional[str]:
|
|
v = re.sub(r"(?i)\b(room|rm|space)\b", "", v).strip()
|
|
v = re.sub(r"\s+", " ", v).upper()
|
|
return v or None
|
|
|
|
|
|
def _norm_tag(v: str) -> Optional[str]:
|
|
toks = [t for t in re.split(r"[^A-Za-z0-9]+", v.upper()) if t]
|
|
if not toks:
|
|
return None
|
|
return "-".join(toks)
|
|
|
|
|
|
def cluster_keys_for(assertion: Dict) -> List[Tuple[str, str]]:
|
|
"""All (key_type, normalized_value) keys this assertion participates in."""
|
|
lk = assertion.get("location_key") or {}
|
|
keys: List[Tuple[str, str]] = []
|
|
if lk.get("grid"):
|
|
g = _norm_grid(str(lk["grid"]))
|
|
if g:
|
|
keys.append(("grid", g))
|
|
if lk.get("room"):
|
|
r = _norm_room(str(lk["room"]))
|
|
if r:
|
|
keys.append(("room", r))
|
|
if lk.get("tag"):
|
|
t = _norm_tag(str(lk["tag"]))
|
|
if t:
|
|
keys.append(("tag", t))
|
|
return keys
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Clustering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _is_schedule(attr: str) -> bool:
|
|
a = (attr or "").lower()
|
|
return any(h in a for h in _SCHEDULE_HINTS)
|
|
|
|
|
|
def _is_plan(attr: str) -> bool:
|
|
a = (attr or "").lower()
|
|
return any(h in a for h in _PLAN_HINTS)
|
|
|
|
|
|
def _flatten(sheets: List[Dict]) -> List[Dict]:
|
|
"""Attach sheet-level context onto each assertion for clustering/evidence."""
|
|
flat: List[Dict] = []
|
|
for sheet in sheets:
|
|
for a in sheet.get("assertions", []):
|
|
flat.append({
|
|
**a,
|
|
"discipline": sheet.get("discipline", "Unknown"),
|
|
"sheet_number": sheet.get("sheet_number"),
|
|
"page_number": sheet.get("page_number"),
|
|
})
|
|
return flat
|
|
|
|
|
|
def _human_location(key_type: str, value: str, members: List[Dict]) -> str:
|
|
label = {"room": "Room", "grid": "Grid", "tag": "Tag"}.get(key_type, key_type)
|
|
levels = {m["location_key"].get("level") for m in members if m.get("location_key")}
|
|
levels.discard(None)
|
|
lvl = f" / {sorted(levels)[0]}" if len(levels) == 1 else ""
|
|
return f"{label} {value}{lvl}"
|
|
|
|
|
|
def cluster_by_location(sheets: List[Dict]) -> List[Dict]:
|
|
"""
|
|
Build co-location clusters from per-sheet extractions.
|
|
|
|
Returns a list of clusters:
|
|
{
|
|
"key": "room:204",
|
|
"location": "Room 204 / Level 2",
|
|
"disciplines": ["Architectural", "Mechanical"],
|
|
"page_numbers": [3, 14],
|
|
"sheets": ["A2.1", "M2.1"],
|
|
"assertions": [ {discipline, sheet_number, attribute, value, source_text, ...}, ... ]
|
|
}
|
|
"""
|
|
flat = _flatten(sheets)
|
|
|
|
buckets: Dict[Tuple[str, str], List[Dict]] = {}
|
|
for a in flat:
|
|
for key in cluster_keys_for(a):
|
|
buckets.setdefault(key, []).append(a)
|
|
|
|
clusters: List[Dict] = []
|
|
for (key_type, value), members in buckets.items():
|
|
disciplines = {m["discipline"] for m in members}
|
|
cross_discipline = len(disciplines) >= 2
|
|
schedule_vs_plan = (
|
|
len(disciplines) == 1
|
|
and any(_is_schedule(m["attribute"]) for m in members)
|
|
and any(_is_plan(m["attribute"]) for m in members)
|
|
)
|
|
if not (cross_discipline or schedule_vs_plan):
|
|
continue
|
|
|
|
# Rank members so the cap keeps the most informative ones (high
|
|
# confidence first), but always keep at least one per discipline.
|
|
members = sorted(
|
|
members,
|
|
key=lambda m: {"high": 0, "medium": 1, "low": 2}.get(m.get("confidence"), 1),
|
|
)[:MAX_ASSERTIONS_PER_CLUSTER]
|
|
|
|
pages = sorted({m["page_number"] for m in members if m.get("page_number")})
|
|
sheet_nums = sorted({m["sheet_number"] for m in members if m.get("sheet_number")})
|
|
|
|
clusters.append({
|
|
"key": f"{key_type}:{value}",
|
|
"location": _human_location(key_type, value, members),
|
|
"disciplines": sorted(disciplines),
|
|
"page_numbers": pages,
|
|
"sheets": sheet_nums,
|
|
"assertions": members,
|
|
"kind": "cross_discipline" if cross_discipline else "schedule_vs_plan",
|
|
})
|
|
|
|
# Most disciplines / most assertions first; deterministic tie-break by key.
|
|
clusters.sort(key=lambda c: (-len(c["disciplines"]), -len(c["assertions"]), c["key"]))
|
|
if len(clusters) > MAX_CLUSTERS:
|
|
print(f"[Cluster] Capping {len(clusters)} clusters to {MAX_CLUSTERS}")
|
|
clusters = clusters[:MAX_CLUSTERS]
|
|
|
|
print(f"[Cluster] {len(clusters)} candidate clusters "
|
|
f"({sum(1 for c in clusters if c['kind']=='cross_discipline')} cross-discipline, "
|
|
f"{sum(1 for c in clusters if c['kind']=='schedule_vs_plan')} schedule-vs-plan)")
|
|
return clusters
|