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>
124 lines
4.4 KiB
Python
124 lines
4.4 KiB
Python
"""
|
|
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
|