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.
140 lines
5.1 KiB
Python
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
|