system-prompts-and-models-o.../salesflow-saas/backend/app/services/alert_delivery.py
Claude b0c3d038f8
feat: Finalize all systems + add 20 production libraries
Finalized implementations:
- skill_registry.py: CRM skill system with policy enforcement
- autopilot.py: Safe autopilot (simulation/approval-gated modes)
- escalation.py: Human escalation with Arabic packets
- signal_intelligence.py: Real-time signal scoring and watchlists
- alert_delivery.py: Multi-channel alerts with Arabic templates
- behavior_intelligence.py: Rep performance and pattern detection
- intelligence.py: Full API for signals/alerts/patterns/escalations

Added 20 production libraries to requirements.txt:
- Security: PyJWT (replaces abandoned python-jose), slowapi
- Arabic: camel-tools, pyarabic, hijridate, phonenumbers
- AI: litellm (unified LLM), instructor (structured outputs), statsforecast
- WhatsApp: pywa (direct Cloud API)
- Email: resend (transactional)
- PDF: weasyprint (Arabic RTL)
- Performance: fastapi-cache2, celery-redbeat, structlog
- Monitoring: sentry-sdk, prometheus-fastapi-instrumentator
- Testing: pytest-asyncio, pytest-cov, factory-boy

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
2026-04-11 07:56:24 +00:00

214 lines
9.8 KiB
Python

"""
Alert Delivery — Multi-channel routing with urgency-based channel selection,
acknowledgement tracking, and Arabic digest generation for Dealix CRM.
Channel matrix:
CRITICAL : dashboard + whatsapp + email + sms
HIGH : dashboard + whatsapp
MEDIUM : dashboard + email
LOW : dashboard (daily digest)
"""
from __future__ import annotations
import logging, uuid
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
logger = logging.getLogger("dealix.services.alert_delivery")
class AlertUrgency(str, Enum):
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class AlertChannel(str, Enum):
DASHBOARD = "dashboard"
EMAIL = "email"
WHATSAPP = "whatsapp"
SMS = "sms"
TELEGRAM = "telegram"
class Alert(BaseModel):
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
tenant_id: str
user_id: Optional[str] = None
title: str = ""
title_ar: str = ""
body: str = ""
body_ar: str = ""
urgency: AlertUrgency = AlertUrgency.MEDIUM
category: str = "system"
channels: List[AlertChannel] = [AlertChannel.DASHBOARD]
action_url: Optional[str] = None
action_label: Optional[str] = None
requires_acknowledgement: bool = False
acknowledged_at: Optional[datetime] = None
delivered_channels: List[str] = []
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
metadata: Dict[str, Any] = {}
TEMPLATES: Dict[str, Dict[str, str]] = {
"new_lead": {"title_ar": "عميل محتمل جديد",
"body_ar": "عميل محتمل جديد: {name} من {source}"},
"deal_won": {"title_ar": "صفقة ناجحة",
"body_ar": "تم إغلاق صفقة: {title} بقيمة {value} ر.س"},
"deal_at_risk": {"title_ar": "صفقة معرضة للخطر",
"body_ar": "صفقة معرضة للخطر: {title} - لا نشاط منذ {days} أيام"},
"consent_expiring": {"title_ar": "موافقة PDPL تنتهي قريبا",
"body_ar": "موافقة PDPL تنتهي خلال {days} أيام للعميل {name}"},
"escalation": {"title_ar": "تصعيد يتطلب انتباهك",
"body_ar": "يحتاج تدخلك: {title} - {reason}"},
"sequence_complete": {"title_ar": "تسلسل مكتمل",
"body_ar": "اكتمل تسلسل {name} للعميل {lead_name}"},
"meeting_booked": {"title_ar": "موعد جديد",
"body_ar": "تم حجز موعد مع {name} في {time}"},
"competitor_alert": {"title_ar": "تنبيه منافس",
"body_ar": "تغيير من المنافس {competitor}: {detail}"},
}
_CHANNEL_MATRIX: Dict[AlertUrgency, List[AlertChannel]] = {
AlertUrgency.CRITICAL: [AlertChannel.DASHBOARD, AlertChannel.WHATSAPP, AlertChannel.EMAIL, AlertChannel.SMS],
AlertUrgency.HIGH: [AlertChannel.DASHBOARD, AlertChannel.WHATSAPP],
AlertUrgency.MEDIUM: [AlertChannel.DASHBOARD, AlertChannel.EMAIL],
AlertUrgency.LOW: [AlertChannel.DASHBOARD],
}
_CAT_AR = {"lead": "العملاء المحتملون", "deal": "الصفقات", "system": "النظام",
"compliance": "الامتثال", "security": "الأمان"}
async def _dispatch(alert: Alert, channel: AlertChannel) -> bool:
logger.info("[%s] tenant=%s user=%s title=%s", channel.value.upper(),
alert.tenant_id[:8], (alert.user_id or "broadcast")[:8], alert.title_ar[:40])
return True
class AlertDelivery:
"""Multi-channel alert delivery with urgency routing and digest generation."""
def __init__(self) -> None:
self._alerts: Dict[str, List[Alert]] = defaultdict(list)
self._stats: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
async def send(self, alert: Alert) -> Dict[str, Any]:
targets = list(set(_CHANNEL_MATRIX.get(alert.urgency, [AlertChannel.DASHBOARD]) + alert.channels))
delivered, failed = [], []
for ch in targets:
ok = await self.send_to_channel(alert, ch)
(delivered if ok else failed).append(ch.value)
if ok:
self._stats[alert.tenant_id][ch.value] += 1
alert.delivered_channels = delivered
buf = self._alerts[alert.tenant_id]
buf.insert(0, alert)
if len(buf) > 10_000:
self._alerts[alert.tenant_id] = buf[:10_000]
self._stats[alert.tenant_id]["total"] += 1
logger.info("Alert %s [%s] delivered via %s", alert.id[:8], alert.urgency.value, ", ".join(delivered) or "none")
return {"alert_id": alert.id, "urgency": alert.urgency.value, "delivered": delivered, "failed": failed}
async def send_to_channel(self, alert: Alert, channel: AlertChannel) -> bool:
try:
return await _dispatch(alert, channel)
except Exception:
logger.exception("Channel %s dispatch failed for alert %s", channel.value, alert.id[:8])
return False
async def send_from_template(self, template_key: str, tenant_id: str, urgency: AlertUrgency,
category: str = "system", user_id: Optional[str] = None,
action_url: Optional[str] = None, requires_ack: bool = False,
**kwargs: Any) -> Dict[str, Any]:
tpl = TEMPLATES.get(template_key)
if not tpl:
return {"error": f"Unknown template: {template_key}"}
body_ar = tpl["body_ar"].format_map(defaultdict(lambda: "", **kwargs))
alert = Alert(tenant_id=tenant_id, user_id=user_id,
title=template_key.replace("_", " ").title(), title_ar=tpl["title_ar"],
body=body_ar, body_ar=body_ar, urgency=urgency, category=category,
action_url=action_url, requires_acknowledgement=requires_ack, metadata=dict(kwargs))
return await self.send(alert)
async def acknowledge(self, alert_id: str, user_id: str) -> bool:
for alerts in self._alerts.values():
for a in alerts:
if a.id == alert_id:
if a.acknowledged_at:
return True
a.acknowledged_at = datetime.now(timezone.utc)
logger.info("Alert %s acknowledged by %s", alert_id[:8], user_id[:8])
return True
return False
async def generate_digest(self, tenant_id: str, user_id: Optional[str] = None,
period: str = "daily") -> Dict[str, Any]:
hours = 24 if period == "daily" else 168
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
pending = [a for a in self._alerts.get(tenant_id, [])
if a.acknowledged_at is None and a.created_at >= cutoff
and (user_id is None or a.user_id is None or a.user_id == user_id)]
if not pending:
return {"tenant_id": tenant_id, "period": period, "count": 0,
"digest_ar": "لا توجد تنبيهات جديدة", "alerts": []}
by_cat: Dict[str, List[Alert]] = defaultdict(list)
for a in pending:
by_cat[a.category].append(a)
crit = sum(1 for a in pending if a.urgency == AlertUrgency.CRITICAL)
high = sum(1 for a in pending if a.urgency == AlertUrgency.HIGH)
lines = [f"ملخص التنبيهات — {'يومي' if period == 'daily' else 'أسبوعي'}",
f"إجمالي التنبيهات: {len(pending)}"]
if crit: lines.append(f"تنبيهات حرجة: {crit}")
if high: lines.append(f"تنبيهات عالية الأهمية: {high}")
lines.append("")
for cat, items in by_cat.items():
lines.append(f"{_CAT_AR.get(cat, cat)} ({len(items)}):")
for a in items[:10]:
tag = " [حرج]" if a.urgency == AlertUrgency.CRITICAL else (
" [مهم]" if a.urgency == AlertUrgency.HIGH else "")
lines.append(f" - {a.title_ar}{tag}")
if len(items) > 10:
lines.append(f" ... و {len(items) - 10} تنبيهات أخرى")
return {"tenant_id": tenant_id, "user_id": user_id, "period": period,
"count": len(pending), "critical": crit, "high": high,
"digest_ar": "\n".join(lines), "alerts": [a.model_dump() for a in pending[:50]]}
async def get_pending(self, tenant_id: str, user_id: Optional[str] = None) -> List[Alert]:
return [a for a in self._alerts.get(tenant_id, [])
if a.acknowledged_at is None
and (user_id is None or a.user_id is None or a.user_id == user_id)]
async def get_delivery_stats(self, tenant_id: str) -> Dict[str, Any]:
stats = dict(self._stats.get(tenant_id, {}))
total = stats.get("total", 0)
alerts = self._alerts.get(tenant_id, [])
acked = sum(1 for a in alerts if a.acknowledged_at is not None)
urg: Dict[str, int] = defaultdict(int)
cat: Dict[str, int] = defaultdict(int)
for a in alerts:
urg[a.urgency.value] += 1
cat[a.category] += 1
return {"tenant_id": tenant_id, "total_sent": total, "acknowledged": acked,
"pending": sum(1 for a in alerts if a.acknowledged_at is None),
"ack_rate": round(acked / max(total, 1) * 100, 1),
"by_channel": {k: v for k, v in stats.items() if k != "total"},
"by_urgency": dict(urg), "by_category": dict(cat)}
_instance: Optional[AlertDelivery] = None
def get_alert_delivery() -> AlertDelivery:
global _instance
if _instance is None:
_instance = AlertDelivery()
return _instance