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,97 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user