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