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