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:
2026-07-03 00:22:02 +00:00
co-authored by Claude Opus 4.8
commit 1d248a8808
262 changed files with 8209 additions and 0 deletions
View File
+64
View File
@@ -0,0 +1,64 @@
"""
_serialize.py - Compact JSON views of pipeline state for the text QAQC stages.
The full sheet/cluster objects carry base64 images and bookkeeping that would
blow up prompt token counts. These helpers strip them to the fields the
reasoning prompts actually need.
"""
import json
from typing import Dict, List
def slim_assertion(a: Dict) -> Dict:
out = {
"sheet_number": a.get("sheet_number"),
"discipline": a.get("discipline"),
"attribute": a.get("attribute"),
"value": a.get("value"),
"source_text": a.get("source_text"),
"location_key": a.get("location_key"),
}
if a.get("normalized_value") is not None:
out["normalized_value"] = a["normalized_value"]
return {k: v for k, v in out.items() if v is not None}
def slim_sheets(sheets: List[Dict]) -> List[Dict]:
return [
{
"sheet_number": s.get("sheet_number"),
"discipline": s.get("discipline"),
"sheet_title": s.get("sheet_title"),
"level": s.get("level"),
"assertions": [
{
"attribute": a.get("attribute"),
"value": a.get("value"),
"source_text": a.get("source_text"),
"location_key": a.get("location_key"),
**({"normalized_value": a["normalized_value"]}
if a.get("normalized_value") is not None else {}),
}
for a in s.get("assertions", [])
],
}
for s in sheets
]
def slim_clusters(clusters: List[Dict]) -> List[Dict]:
return [
{
"key": c.get("key"),
"location": c.get("location"),
"disciplines": c.get("disciplines"),
"kind": c.get("kind"),
"assertions": [slim_assertion(a) for a in c.get("assertions", [])],
}
for c in clusters
]
def dumps(obj) -> str:
return json.dumps(obj, ensure_ascii=True)
+125
View File
@@ -0,0 +1,125 @@
"""
_stage.py - Shared helpers for the LLM QAQC stages (Stages 0-11).
Every LLM stage follows the same shape:
1. render a user instruction by replacing {placeholders}
(str.replace, NOT str.format -- the prompts carry literal JSON braces),
2. call the multimodal model via llm.call_json,
3. validate / coerce the result so malformed model output degrades
gracefully (drop the item) instead of crashing the pipeline.
This module factors that out so each stage module stays thin, and defines the
canonical "issue" schema shared by Stages 6-9 (qaqc / code / constructability /
dedup-validate). Mirrors the proven shape of conflict_checker._valid_conflict.
"""
from typing import Any, Callable, Dict, List, Optional
from backend.llm import call_json
_SEVERITY = ("critical", "high", "medium", "low")
_CONFIDENCE = ("high", "medium", "low")
def render(instruction: str, subs: Dict[str, str]) -> str:
"""Substitute {key} placeholders via str.replace (brace-safe for JSON prompts)."""
out = instruction
for k, v in (subs or {}).items():
out = out.replace("{" + k + "}", "" if v is None else str(v))
return out
def call_stage(
system_prompt: str,
user_instruction: str,
subs: Optional[Dict[str, str]] = None,
images_b64: Optional[List[str]] = None,
max_tokens: int = 4096,
model: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Run one LLM call for a stage; returns the parsed JSON dict or None."""
user_text = render(user_instruction, subs or {})
return call_json(
system_prompt=system_prompt,
user_text=user_text,
images_b64=images_b64,
max_tokens=max_tokens,
model=model,
)
def collect_list(
parsed: Optional[Any],
key: str,
validate: Optional[Callable[[Dict], Optional[Dict]]] = None,
) -> List[Dict]:
"""
Pull a list of items out of a model response, optionally validating each.
Tolerant of shape drift: the model may return {key: [...]} or a bare [...].
"""
if isinstance(parsed, list):
items = parsed
elif isinstance(parsed, dict):
items = parsed.get(key) or []
else:
return []
if not isinstance(items, list):
return []
out: List[Dict] = []
for it in items:
if not isinstance(it, dict):
continue
v = validate(it) if validate else it
if v:
out.append(v)
return out
def validate_issue(c: Dict, source_stage: str = "") -> Optional[Dict]:
"""
Coerce + validate one canonical QAQC issue; drop if it has no description.
Tolerant of schema drift between prompts: accepts evidence[].sheet or
evidence[].sheet_number, and recommended_resolution or recommended_next_step.
"""
if not isinstance(c, dict):
return None
description = (c.get("description") or c.get("summary") or "").strip()
if not description:
return None # an issue with no description is noise
severity = (c.get("severity") or "medium").strip().lower()
if severity not in _SEVERITY:
severity = "medium"
confidence = (c.get("confidence") or "medium").strip().lower()
if confidence not in _CONFIDENCE:
confidence = "medium"
evidence: List[Dict] = []
for ev in (c.get("evidence") or []):
if isinstance(ev, dict):
evidence.append({
"discipline": ev.get("discipline") or "?",
"sheet": ev.get("sheet") or ev.get("sheet_number") or "?",
"source_text": ev.get("source_text") or "",
"asserted_value": ev.get("asserted_value") or "",
})
return {
"issue_id": (c.get("issue_id") or "").strip(),
"source_stage": (c.get("source_stage") or source_stage).strip(),
"category": (c.get("category") or c.get("issue_type") or "uncategorized").strip(),
"severity": severity,
"confidence": confidence,
"location": (c.get("location") or "").strip(),
"disciplines": c.get("disciplines") or c.get("disciplines_involved") or [],
"sheets": c.get("sheets") or [],
"description": description,
"evidence": evidence,
"recommended_resolution": (
c.get("recommended_resolution") or c.get("recommended_next_step") or ""
).strip(),
"code_reference": c.get("code_reference"),
"risk_score": c.get("risk_score"),
}
+172
View File
@@ -0,0 +1,172 @@
"""
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
+118
View File
@@ -0,0 +1,118 @@
"""
code_refs.py - Pluggable code/standard reference retriever for Stage 7.
Loads a local corpus of code/standard text and returns the excerpts most
relevant to the active review paths and the drawing's subject matter. Kept
dependency-free (keyword scoring, no embeddings) so it ships today; the
retrieve() interface is the seam to swap in a vector store later.
Corpus layout: drop plain-text files in backend/code_corpus/. The standard is
inferred from the filename prefix (ada*.txt -> ADA, tas*.txt -> TAS,
ibc*.txt -> IBC, ifc*.txt -> IFC, iebc*.txt -> IEBC, energy*.txt -> ENERGY).
Each file is chunked on blank lines; a leading "<number> ..." token on a chunk
is captured as its section id. An empty corpus is fine -- Stage 7 then runs
reasoning-only and cites nothing.
"""
import os
import re
from typing import Dict, List
_CORPUS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"code_corpus")
# review_paths flag -> standard tag the corpus uses.
PATH_TO_STANDARD = {
"ada_review": "ADA",
"tas_tdlr_review": "TAS",
"ibc_review": "IBC",
"iebc_review": "IEBC",
"fire_code_review": "IFC",
"energy_review": "ENERGY",
}
_FILE_PREFIX_TO_STANDARD = [
("ada", "ADA"), ("tas", "TAS"), ("iebc", "IEBC"),
("ibc", "IBC"), ("ifc", "IFC"), ("energy", "ENERGY"),
]
_SECTION_RE = re.compile(r"^\s*(\d+[\d.]*\b)")
_WORD_RE = re.compile(r"[a-z]{4,}")
_corpus_cache: List[Dict] = None # type: ignore
def _standard_for_file(name: str) -> str:
stem = name.lower()
for prefix, std in _FILE_PREFIX_TO_STANDARD:
if stem.startswith(prefix):
return std
return stem.split("_")[0].split(".")[0].upper()
def load_corpus() -> List[Dict]:
"""Load and chunk the corpus once. Each chunk: {standard, section, text}."""
global _corpus_cache
if _corpus_cache is not None:
return _corpus_cache
chunks: List[Dict] = []
if os.path.isdir(_CORPUS_DIR):
for fname in sorted(os.listdir(_CORPUS_DIR)):
if not fname.lower().endswith(".txt"):
continue
std = _standard_for_file(fname)
with open(os.path.join(_CORPUS_DIR, fname), encoding="utf-8") as f:
raw = f.read()
for para in re.split(r"\n\s*\n", raw):
text = para.strip()
if len(text) < 20:
continue
m = _SECTION_RE.match(text)
chunks.append({
"standard": std,
"section": m.group(1) if m else None,
"text": text,
})
_corpus_cache = chunks
return chunks
def _query_terms(assertions: List[Dict]) -> set:
blob = " ".join(
f"{a.get('attribute','')} {a.get('value','')} {a.get('source_text','')}"
for a in assertions
).lower()
return set(_WORD_RE.findall(blob))
def retrieve(review_paths: Dict, assertions: List[Dict], k: int = 24) -> List[Dict]:
"""Top-k corpus chunks for the active standards, scored by term overlap."""
corpus = load_corpus()
if not corpus:
return []
active = {std for flag, std in PATH_TO_STANDARD.items() if (review_paths or {}).get(flag)}
# If the profile is empty/unknown, fall back to accessibility standards.
pool = [c for c in corpus if not active or c["standard"] in active]
if not pool:
pool = corpus
terms = _query_terms(assertions)
scored = []
for c in pool:
ctext = c["text"].lower()
score = sum(1 for t in terms if t in ctext)
if score:
scored.append((score, c))
scored.sort(key=lambda sc: sc[0], reverse=True)
return [c for _, c in scored[:k]]
def format_excerpts(excerpts: List[Dict]) -> str:
"""Render retrieved excerpts for the prompt."""
if not excerpts:
return "(no code excerpts available; do not cite section numbers)"
out = []
for e in excerpts:
sec = f" {e['section']}" if e.get("section") else ""
out.append(f"[{e['standard']}{sec}] {e['text']}")
return "\n\n".join(out)
+72
View File
@@ -0,0 +1,72 @@
"""
code_review.py - Stage 7: code / ADA / TDLR / Municode review (LLM + retrieval).
Flags likely code and accessibility issues, grounded in BOTH the drawing
evidence and retrieved excerpts of the applicable standards (see code_refs.py).
The model may only cite section numbers that appear in the retrieved excerpts;
with an empty corpus it runs reasoning-only and cites nothing.
Sheets are processed in batches (bounded assertion count per call) so a single
oversized whole-set call can't truncate and zero out the whole stage. Each batch
retrieves excerpts targeted to its own assertions. Emits the canonical issue
schema; returns [] on failure.
"""
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List
from backend import config
from backend.pipeline import code_refs
from backend.pipeline.jurisdiction import active_review_paths
from backend.pipeline._serialize import dumps, slim_sheets
from backend.pipeline._stage import call_stage, collect_list, validate_issue
from backend.prompts import CODE_REVIEW_SYSTEM_PROMPT, CODE_REVIEW_USER_INSTRUCTION
def _sheet_batches(sheets: List[Dict], max_assertions: int) -> List[List[Dict]]:
"""Group whole sheets so each batch holds <= max_assertions facts."""
batches: List[List[Dict]] = []
cur: List[Dict] = []
n = 0
for s in sheets:
a = len(s.get("assertions", []))
if cur and n + a > max_assertions:
batches.append(cur)
cur, n = [], 0
cur.append(s)
n += a
if cur:
batches.append(cur)
return batches
def _review_batch(batch: List[Dict], jurisdiction: Dict, sheet_index: Dict,
paths: Dict) -> List[Dict]:
assertions = [a for s in batch for a in s.get("assertions", [])]
excerpts = code_refs.retrieve(paths, assertions)
parsed = call_stage(
CODE_REVIEW_SYSTEM_PROMPT,
CODE_REVIEW_USER_INSTRUCTION,
subs={
"jurisdiction": dumps(jurisdiction or {}),
"sheet_index": dumps(sheet_index or {}),
"assertions": dumps(slim_sheets(batch)),
"code_references": code_refs.format_excerpts(excerpts),
},
max_tokens=config.CODE_MAX_TOKENS,
)
return collect_list(parsed, "issues", lambda c: validate_issue(c, "code"))
def code_review(jurisdiction: Dict, sheets: List[Dict], sheet_index: Dict) -> List[Dict]:
paths = active_review_paths(jurisdiction)
if not code_refs.load_corpus():
print("[Code] no corpus -> reasoning-only (no citations)")
batches = _sheet_batches(sheets, config.CODE_BATCH_SIZE)
issues: List[Dict] = []
with ThreadPoolExecutor(max_workers=config.CODE_CONCURRENCY) as pool:
for res in pool.map(lambda b: _review_batch(b, jurisdiction, sheet_index, paths), batches):
issues.extend(res)
print(f"[Code] {len(issues)} code/accessibility issue(s) across {len(batches)} batch(es)")
return issues
+136
View File
@@ -0,0 +1,136 @@
"""
conflict_checker.py - Stage 3: cross-discipline conflict reasoning.
One reasoning call per co-located cluster (batch-size-1 per the IronBid
token-budget lesson: small, focused calls don't truncate). Each call gets the
cluster's assertions as evidence plus the relevant sheet images, and returns
zero or more validated conflicts. Calls run in parallel.
"""
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict, Optional
from backend import config
from backend.llm import call_json
from backend.prompts import (
CONFLICT_SYSTEM_PROMPT,
CONFLICT_USER_INSTRUCTION,
CONFLICT_CATEGORIES,
)
# Cap images per cluster to bound tokens/cost. Most clusters touch 2-4 sheets.
MAX_IMAGES_PER_CLUSTER = 6
def _evidence_block(cluster: Dict) -> str:
lines = []
for a in cluster["assertions"]:
sheet = a.get("sheet_number") or "?"
lines.append(
f"- [{a.get('discipline','Unknown')}] {sheet} | "
f"{a.get('attribute','')} = {a.get('value','')} | "
f"\"{a.get('source_text','')}\""
)
return "\n".join(lines)
def _images_for(cluster: Dict, page_to_b64: Dict[int, str]) -> List[str]:
imgs = []
for pn in cluster.get("page_numbers", [])[:MAX_IMAGES_PER_CLUSTER]:
b64 = page_to_b64.get(pn)
if b64:
imgs.append(b64)
return imgs
def _valid_conflict(c: Dict, cluster: Dict) -> Optional[Dict]:
"""Coerce + validate one model-returned conflict; drop if malformed."""
if not isinstance(c, dict):
return None
category = (c.get("category") or "").strip()
if category not in CONFLICT_CATEGORIES:
# Unknown category -> keep but mark, so tuning can see what the model wanted.
category = category or "uncategorized"
severity = (c.get("severity") or "medium").strip().lower()
if severity not in ("high", "medium", "low"):
severity = "medium"
description = (c.get("description") or "").strip()
if not description:
return None # a conflict with no description is noise
return {
"category": category,
"severity": severity,
"disciplines": c.get("disciplines") or cluster.get("disciplines", []),
"location": c.get("location") or cluster.get("location", ""),
"sheets": c.get("sheets") or cluster.get("sheets", []),
"description": description,
"evidence": c.get("evidence") or [],
"recommended_resolution": (c.get("recommended_resolution") or "").strip(),
"confidence": (c.get("confidence") or "medium").strip().lower(),
"cluster_key": cluster.get("key"),
}
def _check_one(cluster: Dict, page_to_b64: Dict[int, str]) -> List[Dict]:
user_text = (
CONFLICT_USER_INSTRUCTION
.replace("{location}", cluster.get("location", ""))
.replace("{evidence}", _evidence_block(cluster))
)
parsed = call_json(
system_prompt=CONFLICT_SYSTEM_PROMPT,
user_text=user_text,
images_b64=_images_for(cluster, page_to_b64),
max_tokens=config.REASON_MAX_TOKENS,
)
if isinstance(parsed, list):
candidates = parsed
elif isinstance(parsed, dict):
candidates = parsed.get("conflicts") or []
else:
return []
out = []
for c in candidates:
v = _valid_conflict(c, cluster)
if v:
out.append(v)
return out
def check_conflicts(clusters: List[Dict], pages: List[Dict]) -> List[Dict]:
"""
Reason over every cluster and return a flat list of validated conflicts.
pages: the Stage-0 page dicts (need 'page_number' and 'base64') so each
cluster can be shown its own sheets.
"""
page_to_b64 = {p["page_number"]: p["base64"] for p in pages}
print(f"[Conflicts] Reasoning over {len(clusters)} clusters...")
conflicts: List[Dict] = []
completed = 0
lock = threading.Lock()
def work(cluster: Dict) -> List[Dict]:
nonlocal completed
found = _check_one(cluster, page_to_b64)
with lock:
completed += 1
if completed % 10 == 0 or completed == len(clusters):
print(f"[Conflicts] {completed}/{len(clusters)} clusters checked")
return found
with ThreadPoolExecutor(max_workers=config.REASON_CONCURRENCY) as ex:
futures = [ex.submit(work, c) for c in clusters]
for fut in as_completed(futures):
try:
conflicts.extend(fut.result())
except Exception as e:
print(f"[Conflicts] cluster error: {e}")
# Sort by severity then category for a stable, readable report.
sev_rank = {"high": 0, "medium": 1, "low": 2}
conflicts.sort(key=lambda c: (sev_rank.get(c["severity"], 1), c["category"]))
print(f"[Conflicts] Found {len(conflicts)} conflict(s)")
return conflicts
+35
View File
@@ -0,0 +1,35 @@
"""
constructability.py - Stage 8: constructability review (LLM).
One whole-set reasoning pass flagging things that are drawn but hard, ambiguous,
or impossible to build as shown (access/clearance, sequencing, support, routing,
tolerance, dimension closure, detail gaps). Emits the canonical issue schema.
Returns [] on failure.
"""
from typing import Dict, List
from backend import config
from backend.pipeline._serialize import dumps, slim_clusters, slim_sheets
from backend.pipeline._stage import call_stage, collect_list, validate_issue
from backend.prompts import (
CONSTRUCTABILITY_SYSTEM_PROMPT,
CONSTRUCTABILITY_USER_INSTRUCTION,
)
def constructability_review(sheets: List[Dict], clusters: List[Dict],
conflicts: List[Dict]) -> List[Dict]:
parsed = call_stage(
CONSTRUCTABILITY_SYSTEM_PROMPT,
CONSTRUCTABILITY_USER_INSTRUCTION,
subs={
"assertions": dumps(slim_sheets(sheets)),
"clusters": dumps(slim_clusters(clusters)),
"conflicts": dumps(conflicts),
},
max_tokens=config.CONSTRUCT_MAX_TOKENS,
)
issues = collect_list(parsed, "issues", lambda c: validate_issue(c, "constructability"))
print(f"[Constructability] {len(issues)} constructability issue(s)")
return issues
+287
View File
@@ -0,0 +1,287 @@
"""
extractor.py - Stage 2: per-sheet construction object extraction.
One vision call per page produces a "sheet object manifest": the discipline
plus a list of typed, grounded construction objects (each with verbatim
source_text and a location_key). Output feeds Stage 3 normalization and
Stage 4 clustering.
Grounding guard (anti-hallucination): an object is dropped if its primary
value contains numeric claims not present in its source_text. Graphical
objects (graphical_basis set, no source_text) are allowed through.
"""
import re
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict, Optional
from backend import config
from backend.llm import call_json
from backend.prompts import (
EXTRACTOR_SYSTEM_PROMPT,
EXTRACTOR_USER_INSTRUCTION,
DISCIPLINE_PREFIXES,
ATTRIBUTE_VOCAB,
)
# prefix (upper) -> discipline, longest-prefix-first for greedy matching
_PREFIX_TO_DISCIPLINE = sorted(
((p.upper(), disc) for disc, ps in DISCIPLINE_PREFIXES.items() for p in ps),
key=lambda kv: -len(kv[0]),
)
_LETTERS_RE = re.compile(r"^[A-Za-z]+")
_DIGITS_RE = re.compile(r"\d+")
# "Room 124", "RM 124A", "SPACE 12" -> "124" / "124A" / "12". Conservative: an
# explicit room/rm/space keyword followed by a 2-4 digit number (+ optional letter).
_ROOM_RE = re.compile(r"(?i)\b(?:room|rm|space)\s*#?\s*(\d{2,4}[A-Za-z]?)\b")
def _room_from_text(text: Optional[str]) -> Optional[str]:
"""Best-effort room id from free text when location_key.room is missing."""
if not text:
return None
m = _ROOM_RE.search(str(text))
return m.group(1) if m else None
def discipline_from_sheet_number(sheet_number: Optional[str]) -> Optional[str]:
"""Deterministic discipline from the sheet-number letter prefix, or None."""
if not sheet_number:
return None
m = _LETTERS_RE.match(sheet_number.strip())
if not m:
return None
prefix = m.group(0).upper()
for cand, disc in _PREFIX_TO_DISCIPLINE: # longest prefix wins
if prefix == cand:
return disc
# Fall back to the single leading letter (e.g. "A2" -> "A")
for cand, disc in _PREFIX_TO_DISCIPLINE:
if prefix.startswith(cand) and len(cand) == 1:
return disc
return None
def _is_grounded(value: str, source_text: str, graphical_basis: str = "") -> bool:
"""
Keep an object only if its primary value is supported by its source_text,
OR it is a graphical object (has graphical_basis with no text to quote).
- If graphical_basis is set and source_text is absent, the object is valid.
- If the value contains digits, every distinct digit-run must appear in
source_text (catches invented dimensions/counts/elevations).
- If the value has no digits, require some alphabetic-token overlap.
"""
# Graphical objects (no readable text on sheet) are always allowed through.
if graphical_basis and not source_text:
return True
if not value or not source_text:
return False
value = str(value)
src_low = source_text.lower()
val_digits = set(_DIGITS_RE.findall(value))
if val_digits:
src_digits = set(_DIGITS_RE.findall(source_text))
return val_digits.issubset(src_digits)
# No digits: text-based grounding.
val_norm = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
if val_norm and val_norm in src_low:
return True
val_tokens = {t for t in val_norm.split() if len(t) > 2}
src_tokens = set(re.sub(r"[^a-z0-9]+", " ", src_low).split())
return bool(val_tokens & src_tokens)
def _primary_value(obj: Dict) -> str:
"""Extract a single string 'value' from an object for grounding checks.
Handles both the new 'attributes' dict schema and the legacy 'value' field.
"""
attrs = obj.get("attributes")
if isinstance(attrs, dict) and attrs:
return str(next(iter(attrs.values())))
# Legacy assertion schema has a top-level 'value' field
return (obj.get("value") or obj.get("description")
or obj.get("name") or obj.get("tag") or "")
def _normalize_sheet(parsed: Dict, page_number: int) -> Dict:
"""
Validate + clean one parsed sheet result, attaching page_number and ids.
The LLM now returns a 'sheet' header + 'objects' array (Stage 2 schema).
We map each object to an enriched assertion dict that is backward-compatible
with all downstream stages (clusterer, conflict_checker, etc.) while also
carrying the new typed-object fields.
"""
# Support both new schema {sheet:{...}, objects:[...]} and old {sheet_number, assertions:[...]}
sheet_meta = parsed.get("sheet") or {}
sheet_number = sheet_meta.get("sheet_number") or parsed.get("sheet_number")
sheet_title = sheet_meta.get("sheet_title") or parsed.get("sheet_title")
level = sheet_meta.get("level") or parsed.get("level")
scale = sheet_meta.get("scale") or parsed.get("scale")
drawing_type = sheet_meta.get("drawing_type")
raw_discipline = sheet_meta.get("discipline") or parsed.get("discipline")
discipline = raw_discipline or discipline_from_sheet_number(sheet_number)
if not discipline or discipline == "General":
deduced = discipline_from_sheet_number(sheet_number)
if deduced:
discipline = deduced
discipline = discipline or "Unknown"
# Accept both new 'objects' and legacy 'assertions' keys
raw_objects = parsed.get("objects") or parsed.get("assertions") or []
clean: List[Dict] = []
dropped = 0
for idx, obj in enumerate(raw_objects):
if not isinstance(obj, dict):
continue
source_text = obj.get("source_text") or ""
graphical_basis = obj.get("graphical_basis") or ""
# Derive a primary value for the grounding check
primary_val = _primary_value(obj)
if not _is_grounded(primary_val, source_text, graphical_basis):
dropped += 1
continue
# --- location_key: new schema is richer; map to legacy shape + extras ---
lk = obj.get("location_key")
if not isinstance(lk, dict):
lk = {}
# Room recovery: try new keys first, then fall back to text scan
room = (lk.get("room") or lk.get("room_number")
or _room_from_text(obj.get("name")) or _room_from_text(source_text))
room_name = lk.get("room_name")
assertion_id = (obj.get("object_id") or obj.get("assertion_id")
or f"{sheet_number or 'p' + str(page_number)}#{idx}")
# Map object's attributes dict to legacy subject/attribute/value
attrs = obj.get("attributes") or {}
if isinstance(attrs, dict) and attrs:
first_attr_key = next(iter(attrs))
first_attr_val = str(attrs[first_attr_key])
else:
first_attr_key = obj.get("object_type") or "unspecified"
first_attr_val = primary_val
clean.append({
# --- legacy fields (backward-compat with clusterer, conflict_checker, etc.) ---
"id": assertion_id,
"subject": (obj.get("name") or obj.get("tag") or obj.get("object_type")
or obj.get("subject") or ""),
"attribute": first_attr_key,
"value": first_attr_val,
"location_key": {
"grid": lk.get("grid"),
"room": room,
"room_name": room_name,
"level": lk.get("level") or level,
"tag": lk.get("tag") or obj.get("tag"),
"detail_reference": lk.get("detail_reference"),
"plan_zone": lk.get("plan_zone"),
"elevation_reference": lk.get("elevation_reference"),
},
"source_text": source_text,
"confidence": obj.get("confidence") or "medium",
# --- new typed-object fields ---
"object_type": obj.get("object_type"),
"category": obj.get("category"),
"object_tag": obj.get("tag"),
"object_name": obj.get("name"),
"object_description": obj.get("description"),
"object_attributes": attrs,
"graphical_basis": graphical_basis or None,
"review_uses": obj.get("review_uses") or [],
})
if dropped:
print(f"[Extract] Page {page_number} ({sheet_number}): dropped {dropped} ungrounded object(s)")
unresolved = parsed.get("unresolved_items") or []
return {
"page_number": page_number,
"sheet_number": sheet_number,
"discipline": discipline,
"sheet_title": sheet_title,
"drawing_type": drawing_type,
"level": level,
"scale": scale,
"assertions": clean, # downstream stages read this key
"unresolved_items": unresolved,
}
def _extract_one(page: Dict, sheet_hint: str = "") -> Dict:
user_text = EXTRACTOR_USER_INSTRUCTION.replace("{sheet_hint}", sheet_hint)
parsed = call_json(
system_prompt=EXTRACTOR_SYSTEM_PROMPT,
user_text=user_text,
images_b64=[page["base64"]],
max_tokens=config.EXTRACT_MAX_TOKENS,
)
if not isinstance(parsed, dict):
return {
"page_number": page["page_number"],
"sheet_number": None,
"discipline": "Unknown",
"sheet_title": f"Page {page['page_number']} (extraction failed)",
"level": None,
"scale": None,
"assertions": [],
}
return _normalize_sheet(parsed, page["page_number"])
def extract_assertions(pages: List[Dict], on_progress=None) -> List[Dict]:
"""
Run Stage 2 over all pages. Returns a list of per-sheet object dicts,
ordered by page_number. The 'assertions' key on each sheet holds the
enriched construction objects (backward-compatible with downstream stages).
"""
print(f"[Extract] Extracting construction objects from {len(pages)} pages...")
results: List[Dict] = []
completed = 0
lock = threading.Lock()
def work(page: Dict) -> Dict:
nonlocal completed
out = _extract_one(page)
with lock:
completed += 1
n = len(out["assertions"])
u = len(out.get("unresolved_items") or [])
print(f"[Extract] Page {page['page_number']} -> {out['discipline']} "
f"({out.get('sheet_number')}), {n} objects"
+ (f", {u} unresolved" if u else "")
+ f" [{completed}/{len(pages)}]")
if on_progress:
on_progress(completed, len(pages))
return out
with ThreadPoolExecutor(max_workers=config.EXTRACT_CONCURRENCY) as ex:
futures = {ex.submit(work, p): p for p in pages}
for fut in as_completed(futures):
try:
results.append(fut.result())
except Exception as e:
p = futures[fut]
print(f"[Extract] Page {p['page_number']} unexpected error: {e}")
results.sort(key=lambda s: s["page_number"])
total = sum(len(s["assertions"]) for s in results)
total_unresolved = sum(len(s.get("unresolved_items") or []) for s in results)
print(f"[Extract] Done - {total} grounded objects across {len(results)} sheets"
+ (f" ({total_unresolved} unresolved items)" if total_unresolved else ""))
return results
+37
View File
@@ -0,0 +1,37 @@
"""
jurisdiction.py - Stage 0: project intake and jurisdiction / code profile (LLM).
Turns whatever project metadata we have (intake form fields and/or values
derived from the cover sheet) into a code profile that tells Stage 7 which
review paths apply (IBC, ADA, TAS/TDLR, etc.). Returns {} on failure so the
rest of the pipeline degrades gracefully.
"""
import json
from typing import Dict, Optional
from backend import config
from backend.pipeline._stage import call_stage
from backend.prompts import JURISDICTION_SYSTEM_PROMPT, JURISDICTION_USER_INSTRUCTION
def run_jurisdiction(project_input: Optional[Dict]) -> Dict:
"""Build the project code profile from available project metadata."""
project_input = project_input or {}
parsed = call_stage(
JURISDICTION_SYSTEM_PROMPT,
JURISDICTION_USER_INSTRUCTION,
subs={"project_input": json.dumps(project_input, ensure_ascii=True)},
max_tokens=config.JURISDICTION_MAX_TOKENS,
)
if not isinstance(parsed, dict):
print("[Jurisdiction] no profile produced; code review will be limited")
return {}
# The prompt wraps the result in "project_code_profile"; unwrap when present.
profile = parsed.get("project_code_profile")
return profile if isinstance(profile, dict) else parsed
def active_review_paths(profile: Dict) -> Dict:
"""The review_paths sub-dict (which code checks are on), or {} if absent."""
return (profile or {}).get("review_paths") or {}
+97
View File
@@ -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
+184
View File
@@ -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,
}
+88
View File
@@ -0,0 +1,88 @@
"""
pdf_processor.py - Converts an uploaded PDF drawing set into page images.
Each page becomes a base64-encoded JPEG that the vision model can read.
Higher DPI = better accuracy but slower/heavier requests. Adapted from the
IronBid pipeline (AI_Takeoffs); behavior is intentionally identical so the
two projects stay comparable.
"""
import base64
from io import BytesIO
from typing import List, Dict
from pdf2image import convert_from_path
from PIL import Image
from backend import config
def convert_pdf_to_images(pdf_path: str) -> List[Dict]:
"""
Convert each page of a PDF into a base64-encoded JPEG.
Returns a list of dicts:
{ "page_number": int, "base64": str, "width": int, "height": int }
"""
print(f"[PDF] Converting {pdf_path} at {config.PDF_DPI} DPI...")
pages = convert_from_path(
pdf_path,
dpi=config.PDF_DPI,
fmt="jpeg",
thread_count=4,
use_pdftocairo=True,
)
results: List[Dict] = []
total = min(len(pages), config.MAX_PAGES)
for i, page in enumerate(pages[:total]):
# 2400px gives ~100px/inch on a 24x36 sheet, the minimum needed to
# read 8pt room annotations and dimension strings.
page = _resize_if_needed(page, max_dimension=config.MAX_DIMENSION)
buffer = BytesIO()
page.save(buffer, format="JPEG", quality=85)
b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
results.append({
"page_number": i + 1,
"base64": b64,
"width": page.width,
"height": page.height,
})
print(f"[PDF] Page {i+1}/{total} encoded ({page.width}x{page.height})")
print(f"[PDF] Done - {len(results)} pages ready for analysis")
return results
def render_page_jpeg(pdf_path: str, page: int, dpi: int = 150,
max_dimension: int = 3000) -> bytes:
"""
Render a single 1-based page of a PDF to JPEG bytes, for the sheet viewer.
Higher DPI than the analysis pass (default 150) so text is legible on
screen. Raises IndexError if the page is out of range.
"""
if page < 1:
raise IndexError(f"page {page} out of range")
pages = convert_from_path(pdf_path, dpi=dpi, fmt="jpeg",
first_page=page, last_page=page, use_pdftocairo=True)
if not pages:
raise IndexError(f"page {page} out of range")
img = _resize_if_needed(pages[0], max_dimension=max_dimension)
buffer = BytesIO()
img.save(buffer, format="JPEG", quality=85)
return buffer.getvalue()
def _resize_if_needed(image: Image.Image, max_dimension: int = 2400) -> Image.Image:
"""Resize image proportionally if either dimension exceeds max_dimension."""
w, h = image.size
if w <= max_dimension and h <= max_dimension:
return image
ratio = min(max_dimension / w, max_dimension / h)
new_size = (int(w * ratio), int(h * ratio))
return image.resize(new_size, Image.LANCZOS)
+34
View File
@@ -0,0 +1,34 @@
"""
qaqc_review.py - Stage 6: senior architect full-set QAQC review (LLM).
One whole-set reasoning pass that surfaces QAQC issues BEYOND the direct
cross-discipline conflicts already found in Stage 5 -- missing sheets/schedules/
details, incomplete information, bid/permit-readiness gaps. Text-based over the
already-extracted assertions, clusters, and conflicts; emits the canonical issue
schema. Returns [] on failure.
"""
from typing import Dict, List
from backend import config
from backend.pipeline._serialize import dumps, slim_clusters, slim_sheets
from backend.pipeline._stage import call_stage, collect_list, validate_issue
from backend.prompts import SENIOR_QAQC_SYSTEM_PROMPT, SENIOR_QAQC_USER_INSTRUCTION
def senior_review(sheets: List[Dict], clusters: List[Dict],
conflicts: List[Dict], sheet_index: Dict) -> List[Dict]:
parsed = call_stage(
SENIOR_QAQC_SYSTEM_PROMPT,
SENIOR_QAQC_USER_INSTRUCTION,
subs={
"sheet_index": dumps(sheet_index or {}),
"assertions": dumps(slim_sheets(sheets)),
"clusters": dumps(slim_clusters(clusters)),
"conflicts": dumps(conflicts),
},
max_tokens=config.QAQC_MAX_TOKENS,
)
issues = collect_list(parsed, "issues", lambda c: validate_issue(c, "qaqc"))
print(f"[QAQC] {len(issues)} full-set QAQC issue(s)")
return issues
+88
View File
@@ -0,0 +1,88 @@
"""
report.py - Stage 4: assemble the final report.
Produces a single JSON object (also the web API payload) and a human-readable
Markdown summary grouped by severity.
"""
from typing import List, Dict
from datetime import datetime, timezone
def build_report(conflicts: List[Dict], sheets: List[Dict], clusters: List[Dict],
source: str = "") -> Dict:
"""Assemble the structured report object."""
by_sev = {"high": 0, "medium": 0, "low": 0}
by_cat: Dict[str, int] = {}
for c in conflicts:
by_sev[c["severity"]] = by_sev.get(c["severity"], 0) + 1
by_cat[c["category"]] = by_cat.get(c["category"], 0) + 1
disciplines = sorted({s["discipline"] for s in sheets if s.get("discipline")})
return {
"source": source,
"generated_at": datetime.now(timezone.utc).isoformat(),
"summary": {
"sheets_analyzed": len(sheets),
"disciplines": disciplines,
"assertions_extracted": sum(len(s.get("assertions", [])) for s in sheets),
"clusters_checked": len(clusters),
"conflicts_found": len(conflicts),
"by_severity": by_sev,
"by_category": by_cat,
},
"conflicts": conflicts,
"sheets": [
{
"page_number": s.get("page_number"),
"sheet_number": s.get("sheet_number"),
"discipline": s.get("discipline"),
"sheet_title": s.get("sheet_title"),
"assertion_count": len(s.get("assertions", [])),
}
for s in sheets
],
}
def to_markdown(report: Dict) -> str:
s = report["summary"]
lines = [
f"# Conflict Check Report",
"",
f"- Source: `{report.get('source','')}`",
f"- Generated: {report.get('generated_at','')}",
f"- Sheets analyzed: {s['sheets_analyzed']} ({', '.join(s['disciplines']) or 'none'})",
f"- Assertions extracted: {s['assertions_extracted']}",
f"- Clusters checked: {s['clusters_checked']}",
f"- **Conflicts found: {s['conflicts_found']}** "
f"(high {s['by_severity']['high']}, medium {s['by_severity']['medium']}, low {s['by_severity']['low']})",
"",
]
if not report["conflicts"]:
lines += ["No cross-discipline conflicts detected.", ""]
return "\n".join(lines)
for sev in ("high", "medium", "low"):
group = [c for c in report["conflicts"] if c["severity"] == sev]
if not group:
continue
lines += [f"## {sev.capitalize()} severity ({len(group)})", ""]
for i, c in enumerate(group, 1):
lines += [
f"### {i}. [{c['category']}] {c['location']}",
f"- Disciplines: {', '.join(c.get('disciplines', []))}",
f"- Sheets: {', '.join(c.get('sheets', []))}",
f"- {c['description']}",
]
for ev in c.get("evidence", []):
lines.append(
f" - {ev.get('discipline','?')} ({ev.get('sheet','?')}): "
f"\"{ev.get('source_text','')}\""
)
if c.get("recommended_resolution"):
lines.append(f"- Resolution: {c['recommended_resolution']}")
lines.append(f"- Confidence: {c.get('confidence','')}")
lines.append("")
return "\n".join(lines)
+46
View File
@@ -0,0 +1,46 @@
"""
rfi.py - Stage 11: RFI / QAQC comment generation (LLM, text-only).
Drafts professional, evidence-based RFI / QAQC comments from the prioritized
issue list. Uses TEXT_MODEL (defaults to MODEL). Returns [] on failure.
"""
from typing import Dict, List
from backend import config
from backend.pipeline._serialize import dumps
from backend.pipeline._stage import call_stage, collect_list
from backend.prompts import RFI_SYSTEM_PROMPT, RFI_USER_INSTRUCTION
def _valid_rfi(r: Dict) -> Dict:
"""Keep an RFI only if it has a question or title; coerce list fields."""
if not isinstance(r, dict):
return None
if not (r.get("question") or r.get("title")):
return None
return {
"rfi_id": r.get("rfi_id") or "",
"issue_id": r.get("issue_id") or "",
"title": (r.get("title") or "").strip(),
"question": (r.get("question") or "").strip(),
"background": (r.get("background") or "").strip(),
"sheets_referenced": r.get("sheets_referenced") or [],
"disciplines_to_respond": r.get("disciplines_to_respond") or [],
"suggested_response_needed": (r.get("suggested_response_needed") or "").strip(),
"priority": (r.get("priority") or "medium").strip().lower(),
}
def generate_rfis(prioritized: List[Dict]) -> List[Dict]:
if not prioritized:
return []
parsed = call_stage(
RFI_SYSTEM_PROMPT,
RFI_USER_INSTRUCTION,
subs={"prioritized_issues": dumps(prioritized)},
max_tokens=config.RFI_MAX_TOKENS,
)
rfis = collect_list(parsed, "rfi_comments", _valid_rfi)
print(f"[RFI] drafted {len(rfis)} RFI/QAQC comment(s)")
return rfis
+63
View File
@@ -0,0 +1,63 @@
"""
risk.py - Stage 10: risk scoring and prioritization (LLM, text-only).
Scores each validated issue 1-100, annotates it with risk_score,
recommended_priority and risk_drivers, and returns the list sorted
highest-risk first. Uses TEXT_MODEL (defaults to MODEL). On failure it falls
back to a deterministic severity-based ordering so the pipeline still produces
a prioritized list.
"""
from typing import Dict, List
from backend import config
from backend.pipeline._serialize import dumps
from backend.pipeline._stage import call_stage
from backend.prompts import RISK_SYSTEM_PROMPT, RISK_USER_INSTRUCTION
_SEV_RANK = {"critical": 90, "high": 70, "medium": 40, "low": 15}
def _ensure_ids(issues: List[Dict]) -> None:
for i, issue in enumerate(issues, 1):
if not issue.get("issue_id"):
issue["issue_id"] = f"ISSUE-{i:03d}"
def score_and_prioritize(validated: List[Dict]) -> List[Dict]:
if not validated:
return []
_ensure_ids(validated)
parsed = call_stage(
RISK_SYSTEM_PROMPT,
RISK_USER_INSTRUCTION,
subs={"validated_issues": dumps(validated)},
max_tokens=config.RISK_MAX_TOKENS,
)
if isinstance(parsed, list):
rows = parsed
elif isinstance(parsed, dict):
rows = parsed.get("prioritized_issues") or []
else:
rows = []
scores: Dict[str, Dict] = {}
for p in rows:
if isinstance(p, dict) and p.get("issue_id"):
scores[p["issue_id"]] = p
for issue in validated:
p = scores.get(issue["issue_id"])
if p and isinstance(p.get("overall_risk_score"), (int, float)):
issue["risk_score"] = int(p["overall_risk_score"])
issue["recommended_priority"] = p.get("recommended_priority")
issue["risk_drivers"] = p.get("risk_drivers") or []
else:
# Deterministic fallback from severity.
issue["risk_score"] = _SEV_RANK.get(issue.get("severity"), 40)
validated.sort(key=lambda i: i.get("risk_score", 0), reverse=True)
print(f"[Risk] scored {len(validated)} issue(s)"
f" ({len(scores)} from model, rest by severity)")
return validated
+185
View File
@@ -0,0 +1,185 @@
"""
runner.py - End-to-end pipeline orchestration (full senior-architect QAQC).
Single entry point shared by the CLI and the web API so both run identical
logic. Optionally dumps intermediate artifacts for debugging/tuning.
Stage map (display label -> prompt stage):
images -> Stage 0 (pdf_processor)
extract -> Stage 2 (extractor, LLM)
sheet index -> Stage 1 (sheet_index, LLM)
jurisdiction -> Stage 0 (jurisdiction, LLM; runs after we have cover data)
normalize -> Stage 3 (normalizer, LLM)
cluster -> Stage 4 (clusterer, deterministic)
conflicts -> Stage 5 (conflict_checker, LLM)
qaqc -> Stage 6 (qaqc_review, LLM)
code -> Stage 7 (code_review, LLM + retrieval)
constructab. -> Stage 8 (constructability, LLM)
validate -> Stage 9 (validator, LLM)
risk -> Stage 10 (risk, LLM text-only)
rfis -> Stage 11 (rfi, LLM text-only)
report -> Stage 12 (report, deterministic)
"""
import os
import json
from typing import Dict, Optional, Callable
from backend.pipeline.pdf_processor import convert_pdf_to_images
from backend.pipeline.extractor import extract_assertions
from backend.pipeline.sheet_index import classify_sheets, derive_project_meta_from_cover
from backend.pipeline.jurisdiction import run_jurisdiction
from backend.pipeline.normalizer import normalize_assertions, build_project_intelligence
from backend.pipeline.clusterer import cluster_by_location
from backend.pipeline.llm_clusterer import cluster_by_location_llm
from backend import config
from backend.pipeline.conflict_checker import check_conflicts
from backend.pipeline.qaqc_review import senior_review
from backend.pipeline.code_review import code_review
from backend.pipeline.constructability import constructability_review
from backend.pipeline.validator import dedup_validate
from backend.pipeline.risk import score_and_prioritize
from backend.pipeline.rfi import generate_rfis
from backend.pipeline.report import build_report, to_markdown
from backend.pipeline._stage import validate_issue
from backend.llm import reset_cost, get_cost, set_stage, set_text_backend
def run_pipeline(
pdf_path: str,
out_dir: Optional[str] = None,
on_stage: Optional[Callable[[str], None]] = None,
project_input: Optional[Dict] = None,
source_name: Optional[str] = None,
text_local: bool = False,
) -> Dict:
"""
Run the full QAQC pipeline on one PDF and return the report dict.
project_input: optional intake fields (project_name, address, occupancy,
work_type). Cover-sheet-derived values fill any gaps; intake fields win.
If out_dir is given, writes conflicts.json, report.md, and the intermediate
artifacts (assertions.json, clusters.json, and one json per QAQC stage).
"""
def stage(name: str):
print(f"\n=== {name} ===")
set_stage(name)
if on_stage:
on_stage(name)
reset_cost()
set_text_backend(text_local)
stage("PDF -> images")
pages = convert_pdf_to_images(pdf_path)
stage("Extract assertions")
sheets = extract_assertions(pages)
stage("Classify sheet index")
sheet_index = classify_sheets(sheets)
stage("Jurisdiction profile")
cover_meta = derive_project_meta_from_cover(sheets, source_name or os.path.basename(pdf_path))
merged_input = {**cover_meta, **(project_input or {})}
jurisdiction = run_jurisdiction(merged_input)
stage("Normalize assertions")
sheets = normalize_assertions(sheets)
stage("Build project intelligence (GOIDs + relationships)")
project_intel = build_project_intelligence(sheets)
stage(f"Cluster by location ({config.CLUSTERER})")
if config.CLUSTERER == "llm":
clusters = cluster_by_location_llm(sheets)
if not clusters: # LLM failed -> fall back, don't lose the run
print("[Cluster] LLM clustering empty; falling back to deterministic")
clusters = cluster_by_location(sheets)
else:
clusters = cluster_by_location(sheets)
stage("Reason over clusters (conflicts)")
conflicts = check_conflicts(clusters, pages)
stage("Full-set QAQC review")
qaqc_issues = senior_review(sheets, clusters, conflicts, sheet_index)
stage("Code / ADA review")
code_issues = code_review(jurisdiction, sheets, sheet_index)
stage("Constructability review")
construct_issues = constructability_review(sheets, clusters, conflicts)
stage("Validate & deduplicate")
conflict_issues = [v for v in (validate_issue(c, "conflict") for c in conflicts) if v]
all_issues = conflict_issues + qaqc_issues + code_issues + construct_issues
validated = dedup_validate(all_issues)
stage("Risk scoring & prioritization")
prioritized = score_and_prioritize(validated)
stage("RFI / QAQC comments")
rfis = generate_rfis(prioritized)
stage("Build report")
report = build_report(conflicts, sheets, clusters, source=os.path.basename(pdf_path))
# Extend the deterministic report with the new QAQC stage outputs.
report["project_input"] = merged_input
report["jurisdiction"] = jurisdiction
report["sheet_index"] = sheet_index
report["project_intelligence"] = project_intel
report["validated_issues"] = prioritized
report["rfis"] = rfis
report["summary"]["by_stage"] = {
"conflicts": len(conflicts),
"qaqc": len(qaqc_issues),
"code": len(code_issues),
"constructability": len(construct_issues),
"validated": len(validated),
"rfis": len(rfis),
}
cost = get_cost()
report["summary"]["cost_usd"] = round(cost["usd"], 4)
report["summary"]["llm_calls"] = cost["calls"]
report["summary"]["cached_calls"] = cost.get("cached", 0)
report["summary"]["cost_by_stage"] = cost.get("by_stage", {})
report["summary"]["text_backend"] = "local" if text_local else "openrouter"
report["summary"]["models_used"] = cost.get("models", {})
print(f"[Runner] LLM cost: ${cost['usd']:.4f} over {cost['calls']} live calls"
f" ({cost.get('cached', 0)} cached)")
if out_dir:
os.makedirs(out_dir, exist_ok=True)
_dump(out_dir, "assertions.json", sheets)
_dump(out_dir, "clusters.json", [_cluster_slim(c) for c in clusters])
_dump(out_dir, "sheet_index.json", sheet_index)
_dump(out_dir, "jurisdiction.json", jurisdiction)
_dump(out_dir, "project_intelligence.json", project_intel)
_dump(out_dir, "qaqc_issues.json", qaqc_issues)
_dump(out_dir, "code_issues.json", code_issues)
_dump(out_dir, "constructability.json", construct_issues)
_dump(out_dir, "validated_issues.json", prioritized)
_dump(out_dir, "rfis.json", rfis)
_dump(out_dir, "conflicts.json", report)
with open(os.path.join(out_dir, "report.md"), "w") as f:
f.write(to_markdown(report))
print(f"\n[Runner] Wrote artifacts to {out_dir}")
return report
def _cluster_slim(c: Dict) -> Dict:
"""Clusters without base64 noise, for artifact dumps."""
return {k: v for k, v in c.items() if k != "assertions"} | {
"assertions": [
{kk: vv for kk, vv in a.items() if kk != "base64"}
for a in c.get("assertions", [])
]
}
def _dump(out_dir: str, name: str, obj) -> None:
with open(os.path.join(out_dir, name), "w") as f:
json.dump(obj, f, indent=2)
+117
View File
@@ -0,0 +1,117 @@
"""
sheet_index.py - Stage 1: sheet index and drawing classification (LLM).
Classifies the set's sheets (discipline, drawing type, level, QAQC relevance)
and flags missing expected sheets, reusing the title-block data the extractor
already produced -- no extra image calls. Also exposes a cheap, no-LLM helper
that scrapes best-effort project metadata off the cover sheet so Stage 0
(jurisdiction) has something to work with when the intake form is blank.
"""
import json
import re
from typing import Dict, List, Optional
from backend import config
from backend.pipeline._stage import call_stage
from backend.prompts import SHEET_INDEX_SYSTEM_PROMPT, SHEET_INDEX_USER_INSTRUCTION
def _index_input(sheets: List[Dict]) -> List[Dict]:
"""Compact per-sheet records for the classification call."""
return [
{
"sheet_id": s.get("sheet_number") or f"page-{s.get('page_number')}",
"sheet_number": s.get("sheet_number"),
"sheet_title": s.get("sheet_title"),
"discipline": s.get("discipline"),
"level": s.get("level"),
"page_number": s.get("page_number"),
}
for s in sheets
]
def classify_sheets(sheets: List[Dict]) -> Dict:
"""Return the classified sheet index, or {} on failure."""
parsed = call_stage(
SHEET_INDEX_SYSTEM_PROMPT,
SHEET_INDEX_USER_INSTRUCTION,
subs={"sheet_index_input": json.dumps(_index_input(sheets), ensure_ascii=True)},
max_tokens=config.SHEET_INDEX_MAX_TOKENS,
)
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list):
# Model returned a bare array of sheet entries -> wrap it.
return {"sheet_index": parsed, "missing_expected_sheets": []}
return {}
# --- cover-sheet metadata (no LLM) -----------------------------------------
_ADDR_RE = re.compile(r"\d{1,6}\s+\w[\w .]*\b(?:st|street|ave|avenue|rd|road|blvd|"
r"boulevard|dr|drive|ln|lane|way|hwy|highway|ct|court)\b",
re.IGNORECASE)
def _is_cover(sheet: Dict) -> bool:
title = (sheet.get("sheet_title") or "").lower()
num = (sheet.get("sheet_number") or "").upper()
return ("cover" in title or "title sheet" in title
or num in ("G000", "G-000", "G0.0", "T0.0", "G001", "G-001")
or sheet.get("page_number") == 1)
# Words that, alone, never make up a real project name (so a title built only
# from these is "generic" and should not be used as the project name).
_GENERIC_WORDS = {"cover", "sheet", "title", "index", "general", "notes",
"drawing", "drawings", "of", "the", "and"}
_FILE_NOISE_RE = re.compile(
r"(?i)(?:\d{2,3}%|\breview set\b|\bpermit set\b|\bbid set\b|"
r"\bconstruction set\b|\bdrawings?\b|\bset\b|\bissued for \w+\b|"
r"\bifc\b|\bifp\b)")
_TMP_PREFIX_RE = re.compile(r"^tmp\w+?_")
_DATE_RE = re.compile(r"\b\d{6,8}\b")
def _is_generic_title(title: str) -> bool:
t = re.sub(r"[^a-z ]", "", (title or "").lower()).strip()
return (not t) or all(w in _GENERIC_WORDS for w in t.split())
def _name_from_filename(source_name: Optional[str]) -> Optional[str]:
"""'tmpXXXX_20260618_Verizon Elyson_100% Review Set.pdf' -> 'Verizon Elyson'."""
if not source_name:
return None
stem = re.sub(r"\.pdf$", "", source_name, flags=re.IGNORECASE)
stem = _TMP_PREFIX_RE.sub("", stem)
stem = stem.replace("_", " ") # spaces first so dates get boundaries
stem = _DATE_RE.sub(" ", stem)
stem = _FILE_NOISE_RE.sub(" ", stem)
stem = re.sub(r"\s+", " ", stem).strip(" -")
return stem or None
def derive_project_meta_from_cover(sheets: List[Dict],
source_name: Optional[str] = None) -> Dict:
"""Best-effort {project_name, address} from cover-sheet assertions + filename."""
meta: Dict[str, str] = {}
covers = [s for s in sheets if _is_cover(s)] or sheets[:1]
for s in covers:
for a in s.get("assertions", []):
text = (a.get("source_text") or a.get("value") or "").strip()
if text and "address" not in meta:
m = _ADDR_RE.search(text)
if m:
meta["address"] = m.group(0).strip()
# Use the cover title as the name only if it isn't a generic label.
title = (s.get("sheet_title") or "").strip()
if "project_name" not in meta and title and not _is_generic_title(title):
meta["project_name"] = title
# Filename is the most reliable name for retail/prototype sets.
if "project_name" not in meta:
fn = _name_from_filename(source_name)
if fn:
meta["project_name"] = fn
return meta
+36
View File
@@ -0,0 +1,36 @@
"""
validator.py - Stage 9: issue deduplication and validation (LLM).
Consolidates findings from Stages 5-8 (conflicts, full-set QAQC, code, and
constructability) into one clean, deduplicated canonical-issue list, dropping
unsupported or vague items. On failure it returns the input unchanged so no
findings are lost.
"""
from typing import Dict, List
from backend import config
from backend.pipeline._serialize import dumps
from backend.pipeline._stage import call_stage, collect_list, validate_issue
from backend.prompts import (
DEDUP_VALIDATE_SYSTEM_PROMPT,
DEDUP_VALIDATE_USER_INSTRUCTION,
)
def dedup_validate(all_issues: List[Dict]) -> List[Dict]:
if not all_issues:
return []
parsed = call_stage(
DEDUP_VALIDATE_SYSTEM_PROMPT,
DEDUP_VALIDATE_USER_INSTRUCTION,
subs={"issues": dumps(all_issues)},
max_tokens=config.VALIDATE_MAX_TOKENS,
)
validated = collect_list(parsed, "issues", validate_issue)
if not validated:
# Model failed or returned nothing usable -- keep the raw findings.
print(f"[Validate] dedup produced no list; keeping {len(all_issues)} raw issue(s)")
return all_issues
print(f"[Validate] {len(all_issues)} raw -> {len(validated)} consolidated issue(s)")
return validated