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
+123
View File
@@ -0,0 +1,123 @@
"""
email_sender.py - Sends a completion notification for a conflict check.
Plain stdlib smtplib, all config from env (see config.py). Mirrors IronBid's
graceful behavior: if SMTP is not configured, or a send fails, it logs and
returns without ever crashing the pipeline.
"""
import os
import ssl
import smtplib
from email.message import EmailMessage
from typing import Dict, List, Optional
from backend import config
def _smtp_ready() -> bool:
return bool(config.SMTP_HOST and config.SMTP_USER and config.SMTP_PASSWORD)
def _send(msg: EmailMessage) -> bool:
try:
if config.SMTP_USE_SSL:
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(config.SMTP_HOST, config.SMTP_PORT, context=ctx) as s:
s.login(config.SMTP_USER, config.SMTP_PASSWORD)
s.send_message(msg)
else:
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as s:
if config.SMTP_USE_TLS:
s.starttls(context=ssl.create_default_context())
s.login(config.SMTP_USER, config.SMTP_PASSWORD)
s.send_message(msg)
return True
except Exception as e:
print(f"[Email] Failed to send: {e}")
return False
def send_conflict_report(
recipient_email: str,
report: Dict,
results_url: str = "",
attachments: Optional[List[str]] = None,
) -> bool:
"""
Email the conflict-check summary to recipient_email, with the report files
attached. Returns True if sent, False if skipped/failed.
"""
if not recipient_email:
return False
if not _smtp_ready():
print(f"[Email] SMTP not configured - skipping notification to {recipient_email}")
return False
s = report.get("summary", {})
source = report.get("source", "drawing set")
sev = s.get("by_severity", {})
found = s.get("conflicts_found", 0)
msg = EmailMessage()
msg["Subject"] = f"Conflict Checker - {source} - {found} conflict(s) found"
msg["From"] = config.SMTP_FROM or config.SMTP_USER
msg["To"] = recipient_email
lines = [
"Your conflict check is complete.",
"",
f"Drawing set: {source}",
f"Sheets: {s.get('sheets_analyzed', 0)} ({', '.join(s.get('disciplines', [])) or 'none'})",
f"Facts checked: {s.get('assertions_extracted', 0)} across {s.get('clusters_checked', 0)} location clusters",
"",
"RESULT",
f" Conflicts found: {found}",
f" High: {sev.get('high', 0)}",
f" Medium: {sev.get('medium', 0)}",
f" Low: {sev.get('low', 0)}",
]
by_stage = s.get("by_stage")
if by_stage:
lines += [
"",
"QAQC REVIEW",
f" Full-set QAQC issues: {by_stage.get('qaqc', 0)}",
f" Code / ADA issues: {by_stage.get('code', 0)}",
f" Constructability: {by_stage.get('constructability', 0)}",
f" Consolidated issues: {by_stage.get('validated', 0)}",
f" RFIs drafted: {by_stage.get('rfis', 0)}",
]
if s.get("cost_usd") is not None:
lines += ["", f"LLM cost: ${s['cost_usd']:.4f} over {s.get('llm_calls', 0)} calls"]
mu = s.get("models_used", {})
if mu:
lines += ["", "AI MODELS"]
if mu.get("vision"):
lines += [f" Vision: {', '.join(mu['vision'])}"]
if mu.get("text_local"):
lines += [f" Text local: {', '.join(mu['text_local'])}"]
if mu.get("text_cloud"):
label = "Text cloud" if not mu.get("text_local") else "Text cloud (fallback)"
lines += [f" {label}: {', '.join(mu['text_cloud'])}"]
if mu.get("fallback_count"):
lines += [f" Local->cloud fallbacks: {mu['fallback_count']}"]
if results_url:
lines += ["", "VIEW FULL REPORT", f" {results_url}"]
lines += ["", "Generated by Conflict Checker"]
msg.set_content("\n".join(lines))
for path in attachments or []:
if not path or not os.path.isfile(path):
continue
with open(path, "rb") as f:
data = f.read()
name = os.path.basename(path)
subtype = "json" if name.endswith(".json") else "plain"
maintype = "application" if name.endswith(".json") else "text"
msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=name)
if _send(msg):
print(f"[Email] Sent conflict report to {recipient_email}")
return True
return False