""" 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