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>
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""
|
|
llm_clusterer.py - Stage 4 (LLM variant): semantic co-location clustering.
|
|
|
|
An alternative to the deterministic clusterer.py. Sends the flattened
|
|
assertions (each with a stable id) to the model, which groups ones that refer
|
|
to the same real-world room/door/grid/tag/equipment -- catching fuzzy matches
|
|
the exact-key clusterer misses ("Room 124" vs "Mgr Office 124"). The returned
|
|
assertion_ids are resolved back to full assertions and emitted in the SAME
|
|
internal cluster shape conflict_checker/report consume, so it is a drop-in swap.
|
|
|
|
Selected via config.CLUSTERER == "llm". On failure returns []. Keeps clusters
|
|
with >= 2 member assertions (a single-assertion cluster gives the reasoner
|
|
nothing to compare).
|
|
"""
|
|
|
|
import json
|
|
from typing import Dict, List
|
|
|
|
from backend import config
|
|
from backend.pipeline._stage import call_stage, collect_list
|
|
from backend.prompts import CLUSTER_SYSTEM_PROMPT, CLUSTER_USER_INSTRUCTION
|
|
|
|
MAX_ASSERTIONS_PER_CLUSTER = 24
|
|
|
|
|
|
def _flatten(sheets: List[Dict]) -> List[Dict]:
|
|
flat: List[Dict] = []
|
|
for s in sheets:
|
|
for a in s.get("assertions", []):
|
|
flat.append({**a,
|
|
"discipline": s.get("discipline", "Unknown"),
|
|
"sheet_number": s.get("sheet_number"),
|
|
"page_number": s.get("page_number")})
|
|
return flat
|
|
|
|
|
|
def _payload(flat: List[Dict]) -> List[Dict]:
|
|
return [{
|
|
"assertion_id": a["id"],
|
|
"discipline": a["discipline"],
|
|
"sheet_number": a.get("sheet_number"),
|
|
"attribute": a.get("attribute"),
|
|
"value": a.get("value"),
|
|
"location_key": a.get("location_key"),
|
|
"source_text": a.get("source_text"),
|
|
} for a in flat]
|
|
|
|
|
|
def _location(pk) -> str:
|
|
if not isinstance(pk, dict):
|
|
return "Unspecified"
|
|
lvl = f" / {pk['level']}" if pk.get("level") else ""
|
|
for key, label in (("room", "Room"), ("grid", "Grid"), ("tag", "Tag")):
|
|
if pk.get(key):
|
|
return f"{label} {pk[key]}{lvl}"
|
|
return pk.get("plan_zone") or pk.get("room_name") or "Unspecified"
|
|
|
|
|
|
def cluster_by_location_llm(sheets: List[Dict]) -> List[Dict]:
|
|
flat = _flatten(sheets)
|
|
if not flat:
|
|
return []
|
|
by_id = {a["id"]: a for a in flat}
|
|
|
|
parsed = call_stage(
|
|
CLUSTER_SYSTEM_PROMPT,
|
|
CLUSTER_USER_INSTRUCTION,
|
|
subs={"normalized_assertions": json.dumps(_payload(flat), ensure_ascii=True)},
|
|
max_tokens=config.CLUSTER_MAX_TOKENS,
|
|
)
|
|
raw = collect_list(parsed, "clusters")
|
|
|
|
clusters: List[Dict] = []
|
|
for c in raw:
|
|
member_ids = [i for i in (c.get("assertion_ids") or []) if i in by_id]
|
|
members = [by_id[i] for i in member_ids]
|
|
disciplines = sorted({m["discipline"] for m in members})
|
|
# Need at least two facts to compare (cross-discipline, or schedule vs plan).
|
|
if len(members) < 2:
|
|
continue
|
|
members = members[:MAX_ASSERTIONS_PER_CLUSTER]
|
|
clusters.append({
|
|
"key": str(c.get("cluster_id") or _location(c.get("primary_location_key"))),
|
|
"location": _location(c.get("primary_location_key")),
|
|
"disciplines": disciplines,
|
|
"page_numbers": sorted({m["page_number"] for m in members if m.get("page_number")}),
|
|
"sheets": sorted({m["sheet_number"] for m in members if m.get("sheet_number")}),
|
|
"assertions": members,
|
|
"kind": "llm",
|
|
})
|
|
|
|
clusters.sort(key=lambda c: (-len(c["disciplines"]), -len(c["assertions"]), c["key"]))
|
|
if len(clusters) > config.CLUSTER_MAX:
|
|
print(f"[Cluster/LLM] capping {len(clusters)} -> {config.CLUSTER_MAX}")
|
|
clusters = clusters[:config.CLUSTER_MAX]
|
|
print(f"[Cluster/LLM] {len(clusters)} clusters (from {len(raw)} returned)")
|
|
return clusters
|