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,184 @@
|
||||
"""
|
||||
normalizer.py - Stage 3: construction object normalization + project intelligence.
|
||||
|
||||
Stage 3a: normalize_assertions — adds normalized values (e.g. 9'-0" -> 108 in)
|
||||
and normalized tags/room identifiers to extracted objects. Additive; originals
|
||||
are always preserved. Processed in batches across a thread pool.
|
||||
|
||||
Stage 3b: build_project_intelligence — cross-sheet merging pass that assigns
|
||||
Global Object IDs (GOIDs) and builds relationships between objects that
|
||||
represent the same physical element across multiple drawings/schedules.
|
||||
Batched by object_type so each call stays within the token budget.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, List
|
||||
|
||||
from backend import config
|
||||
from backend.pipeline._stage import call_stage
|
||||
from backend.prompts import (
|
||||
NORMALIZATION_SYSTEM_PROMPT,
|
||||
NORMALIZATION_USER_INSTRUCTION,
|
||||
PROJECT_INTELLIGENCE_SYSTEM_PROMPT,
|
||||
PROJECT_INTELLIGENCE_USER_INSTRUCTION,
|
||||
)
|
||||
|
||||
|
||||
def _batches(items: List[Dict], size: int) -> List[List[Dict]]:
|
||||
return [items[i:i + size] for i in range(0, len(items), size)]
|
||||
|
||||
|
||||
def _normalize_batch(batch: List[Dict]) -> Dict[str, Dict]:
|
||||
"""Return {assertion_id: normalization fields} for one batch."""
|
||||
payload = [
|
||||
{
|
||||
"assertion_id": a["id"],
|
||||
"attribute": a.get("attribute"),
|
||||
"value": a.get("value"),
|
||||
"object_type": a.get("object_type"),
|
||||
"object_tag": a.get("object_tag"),
|
||||
"source_text": a.get("source_text"),
|
||||
}
|
||||
for a in batch
|
||||
]
|
||||
parsed = call_stage(
|
||||
NORMALIZATION_SYSTEM_PROMPT,
|
||||
NORMALIZATION_USER_INSTRUCTION,
|
||||
subs={"assertions": json.dumps(payload, ensure_ascii=True)},
|
||||
max_tokens=config.NORMALIZE_MAX_TOKENS,
|
||||
)
|
||||
if isinstance(parsed, list):
|
||||
rows = parsed
|
||||
elif isinstance(parsed, dict):
|
||||
rows = parsed.get("normalized_assertions") or []
|
||||
else:
|
||||
rows = []
|
||||
out: Dict[str, Dict] = {}
|
||||
for n in rows:
|
||||
if isinstance(n, dict) and n.get("assertion_id"):
|
||||
out[n["assertion_id"]] = {
|
||||
"normalized_value": n.get("normalized_value"),
|
||||
"normalized_unit": n.get("normalized_unit"),
|
||||
"normalized_tag": n.get("normalized_tag"),
|
||||
"normalized_room": n.get("normalized_room"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def normalize_assertions(sheets: List[Dict]) -> List[Dict]:
|
||||
"""Annotate each assertion in-place with normalized fields."""
|
||||
all_assertions = [a for s in sheets for a in s.get("assertions", [])]
|
||||
if not all_assertions:
|
||||
return sheets
|
||||
|
||||
batches = _batches(all_assertions, config.NORMALIZE_BATCH_SIZE)
|
||||
merged: Dict[str, Dict] = {}
|
||||
with ThreadPoolExecutor(max_workers=config.NORMALIZE_CONCURRENCY) as pool:
|
||||
for result in pool.map(_normalize_batch, batches):
|
||||
merged.update(result)
|
||||
|
||||
applied = 0
|
||||
for a in all_assertions:
|
||||
norm = merged.get(a["id"])
|
||||
if norm:
|
||||
if norm.get("normalized_value") is not None:
|
||||
a["normalized_value"] = norm["normalized_value"]
|
||||
a["normalized_unit"] = norm.get("normalized_unit")
|
||||
applied += 1
|
||||
if norm.get("normalized_tag"):
|
||||
a["normalized_tag"] = norm["normalized_tag"]
|
||||
if norm.get("normalized_room"):
|
||||
a["normalized_room"] = norm["normalized_room"]
|
||||
# Also push into location_key so clusterer picks it up
|
||||
if isinstance(a.get("location_key"), dict) and not a["location_key"].get("room"):
|
||||
a["location_key"]["room"] = norm["normalized_room"]
|
||||
|
||||
print(f"[Normalize] normalized {applied}/{len(all_assertions)} objects")
|
||||
return sheets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3b - project intelligence: GOIDs + relationships
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Max objects of one type to send in a single intelligence call.
|
||||
_INTEL_BATCH_SIZE = 60
|
||||
|
||||
|
||||
def _intel_batch(object_type: str, objects: List[Dict]) -> Dict:
|
||||
"""Run the project intelligence pass for one object_type batch."""
|
||||
payload = []
|
||||
for obj in objects:
|
||||
payload.append({
|
||||
"object_id": obj.get("id"),
|
||||
"object_type": obj.get("object_type") or object_type,
|
||||
"tag": obj.get("object_tag"),
|
||||
"name": obj.get("object_name"),
|
||||
"attributes": obj.get("object_attributes") or {},
|
||||
"location_key": obj.get("location_key") or {},
|
||||
"source_text": obj.get("source_text") or "",
|
||||
"source_sheet": (obj.get("location_key") or {}).get("sheet_number"),
|
||||
})
|
||||
parsed = call_stage(
|
||||
PROJECT_INTELLIGENCE_SYSTEM_PROMPT,
|
||||
PROJECT_INTELLIGENCE_USER_INSTRUCTION,
|
||||
subs={
|
||||
"object_type": object_type,
|
||||
"objects": json.dumps(payload, ensure_ascii=True),
|
||||
},
|
||||
max_tokens=config.NORMALIZE_MAX_TOKENS,
|
||||
)
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
return {
|
||||
"project_objects": parsed.get("project_objects") or [],
|
||||
"unresolved_relationships": parsed.get("unresolved_relationships") or [],
|
||||
}
|
||||
|
||||
|
||||
def build_project_intelligence(sheets: List[Dict]) -> Dict:
|
||||
"""
|
||||
Stage 3b: cross-sheet GOID assignment and relationship building.
|
||||
|
||||
Groups all extracted objects by object_type, then calls the Project
|
||||
Intelligence LLM once per type (batched) to assign GOIDs and link
|
||||
objects that represent the same physical element across sheets.
|
||||
|
||||
Returns {"project_objects": [...], "unresolved_relationships": [...]}.
|
||||
On any failure, returns an empty dict (graceful degradation).
|
||||
"""
|
||||
all_assertions = [a for s in sheets for a in s.get("assertions", [])]
|
||||
if not all_assertions:
|
||||
return {}
|
||||
|
||||
# Group by object_type; unknown/graphical go under "general"
|
||||
by_type: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for a in all_assertions:
|
||||
otype = a.get("object_type") or "general"
|
||||
by_type[otype].append(a)
|
||||
|
||||
all_project_objects: List[Dict] = []
|
||||
all_unresolved: List[Dict] = []
|
||||
|
||||
# Process each type in batches; high-count types (e.g. dimensions) are
|
||||
# less useful for GOID merging so we skip types with only 1 object.
|
||||
for otype, objs in sorted(by_type.items()):
|
||||
if len(objs) < 2:
|
||||
continue
|
||||
batches = _batches(objs, _INTEL_BATCH_SIZE)
|
||||
for batch in batches:
|
||||
try:
|
||||
result = _intel_batch(otype, batch)
|
||||
all_project_objects.extend(result.get("project_objects") or [])
|
||||
all_unresolved.extend(result.get("unresolved_relationships") or [])
|
||||
except Exception as e:
|
||||
print(f"[ProjectIntel] {otype} batch failed: {e}")
|
||||
|
||||
print(f"[ProjectIntel] {len(all_project_objects)} project objects, "
|
||||
f"{len(all_unresolved)} unresolved relationships")
|
||||
return {
|
||||
"project_objects": all_project_objects,
|
||||
"unresolved_relationships": all_unresolved,
|
||||
}
|
||||
Reference in New Issue
Block a user