Files
Conflict_Checker/backend/email_sender.py
T
John Wilganowski 1c1d2ff21b
Docker Release / build-and-push (push) Successful in 1m10s
Docker Release / release (push) Skipped
Add required human review gate to the Agent pipeline.
Agent web jobs now stop after Brain consolidation and enter needs_review
with a persisted review queue (blocking: high-severity, low-confidence,
sensitive-category findings; audit sample of clean clusters). Humans
decide confirm/reject/unsure/needs_clarification via new review API and
frontend queue; a finalizer applies decisions (rejections suppressed with
reason codes), performs bounded targeted reruns for clarifications,
drafts RFIs only for kept issues, and only then marks the job done and
sends the final email. Two-phase email (review-required, then final
report), per-decision feedback labels with redacted aggregate metrics,
restart recovery from job artifacts, and CLI --no-review bypass.
Classic pipeline unchanged. 65 non-LLM tests.
2026-07-28 19:23:57 +00:00

140 lines
5.1 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_review_required(recipient_email: str, report: dict, review_url: str) -> bool:
if not recipient_email or not _smtp_ready():
return False
msg = EmailMessage()
msg["Subject"] = f"Conflict Checker - review required - {report.get('source', 'drawing set')}"
msg["From"] = config.SMTP_FROM or config.SMTP_USER
msg["To"] = recipient_email
review = report.get("summary", {}).get("review", {})
msg.set_content(
"Agent analysis is complete and waiting for human review.\n\n"
f"Required review items: {review.get('required', 0)}\n"
f"Review URL: {review_url}\n"
)
return _send(msg)
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