Files
email_alerts_v2/src/alerts.py
T

167 lines
5.0 KiB
Python
Raw Normal View History

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy.orm import Session
from src.ai import analyze_thread
from src.database import (
Message,
Thread,
get_last_incoming_outgoing,
get_thread_messages,
)
from src.whatsapp_sender import WhatsAppSender
@dataclass
class TimeFrame:
name: str
hours: int
alert_level: int
def _utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _load_frames_from_config(cfg: dict) -> List[TimeFrame]:
frames = []
for f in cfg.get("time_frames", []):
try:
frames.append(
TimeFrame(
name=f.get("name", ""),
hours=int(f.get("hours", 0)),
alert_level=int(f.get("alert_level", 0)),
)
)
except Exception:
continue
# sort ascending by hours
frames.sort(key=lambda x: x.hours)
return frames
def _format_alert(
level: int, hours: int, thread: Thread, ai: dict, last_in: Optional[Message]
) -> str:
title = {
1: "LEVEL 1 ALERT (24 Hours)",
2: "LEVEL 2 ALERT (48 Hours - URGENT)",
3: "LEVEL 3 ALERT (72 Hours - CRITICAL)",
}.get(level, f"LEVEL {level} ALERT ({hours} Hours)")
sender = last_in.from_email if last_in else "unknown"
subject = thread.subject or "(no subject)"
summary = ai.get("summary") or thread.ai_summary or ""
conf_pct = int(round((ai.get("confidence") or thread.ai_confidence or 0) * 100))
return (
f"🚨 {title} 🚨\n\n"
f"Conversation with: {sender}\n"
f"Subject: {subject}\n"
f"Contextual Summary: {summary}\n\n"
f"Thread ID: {thread.id}\n"
f"Confidence: {conf_pct}%"
)
def process_alerts(db: Session, cfg: dict) -> List[int]:
"""Analyze threads and send WhatsApp alerts when thresholds are met.
Returns list of thread IDs that had alerts sent this run.
"""
account_email = (cfg.get("email_address") or cfg.get("zoho_email") or "").lower()
if not account_email:
return []
frames = _load_frames_from_config(cfg)
if not frames:
return []
# Ensure thresholds unique by level
level_to_hours = {f.alert_level: f.hours for f in frames}
# Find candidate threads: requires_reply True and account matches
threads: List[Thread] = (
db.query(Thread)
.filter(Thread.account_email == account_email, Thread.requires_reply.is_(True))
.order_by(Thread.updated_at.desc())
.limit(200)
.all()
)
to_number = cfg.get("whatsapp_to") or None
sender = WhatsAppSender(to_number=to_number)
alerted = []
now = datetime.now(timezone.utc)
for t in threads:
last_in, last_out = get_last_incoming_outgoing(db, t.id)
if not last_in:
continue
last_in_dt = _utc(last_in.date_sent) if last_in.date_sent else None
last_out_dt = (
_utc(last_out.date_sent) if last_out and last_out.date_sent else None
)
# Only if last message is incoming or last_in is later than last_out
if last_out_dt and last_out_dt > last_in_dt:
# There's a reply from us after the last incoming; skip
continue
hours_since_last_in = (
(now - last_in_dt).total_seconds() / 3600.0 if last_in_dt else 0
)
# Determine highest frame crossed
target_level = 0
target_hours = 0
for level, hours in sorted(level_to_hours.items(), key=lambda x: x[1]):
if hours_since_last_in >= hours:
target_level = level
target_hours = hours
if target_level == 0:
continue
# Avoid re-sending same or lower level
if (t.last_alert_level_sent or 0) >= target_level:
continue
# Build AI input messages
msgs = [
{
"date_sent": m.date_sent.isoformat() if m.date_sent else None,
"subject": m.subject,
"from_email": m.from_email,
"to_email": m.to_email,
"body": m.body,
"is_incoming": m.is_incoming,
}
for m in get_thread_messages(db, t.id)[-4:]
]
ai = analyze_thread(t.subject or "", msgs)
# Persist AI decision on thread
t.actionable = bool(ai.get("actionable", False))
t.ai_summary = ai.get("summary")
t.ai_confidence = ai.get("confidence")
t.last_analyzed_at = now
if not t.actionable:
# Don't alert on non-actionable
continue
# Compose and send WhatsApp
msg = _format_alert(target_level, target_hours, t, ai, last_in)
res = sender.send_alert(msg, thread_id=str(t.id))
if res.get("status") == "success":
t.last_alert_level_sent = target_level
t.last_alert_sent_at = now
alerted.append(t.id)
db.commit()
return alerted