240 lines
9.9 KiB
Python
240 lines
9.9 KiB
Python
"""
|
|
Email Digest Service - Enriched Alert System
|
|
Sends daily/weekly summaries highlighting AI wins and prevented issues
|
|
"""
|
|
|
|
import structlog
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Optional
|
|
from uuid import UUID
|
|
import httpx
|
|
|
|
from shared.schemas.alert_types import EnrichedAlert
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class EmailDigestService:
|
|
"""
|
|
Manages email digests for enriched alerts.
|
|
|
|
Philosophy: Celebrate AI wins, build trust, show prevented issues prominently.
|
|
"""
|
|
|
|
def __init__(self, config):
|
|
self.config = config
|
|
self.enabled = getattr(config, 'EMAIL_DIGEST_ENABLED', False)
|
|
self.send_hour = getattr(config, 'DIGEST_SEND_TIME_HOUR', 18) # 6 PM default
|
|
self.min_alerts = getattr(config, 'DIGEST_MIN_ALERTS', 1)
|
|
self.notification_service_url = "http://notification-service:8000"
|
|
|
|
async def send_daily_digest(
|
|
self,
|
|
tenant_id: UUID,
|
|
alerts: List[EnrichedAlert],
|
|
user_email: str,
|
|
user_name: Optional[str] = None
|
|
) -> bool:
|
|
"""
|
|
Send daily email digest highlighting AI impact and prevented issues.
|
|
|
|
Email structure:
|
|
1. AI Impact Summary (prevented issues count, savings)
|
|
2. Prevented Issues List (top 5 with AI reasoning)
|
|
3. Action Needed Alerts (critical/important requiring attention)
|
|
4. Trend Warnings (optional)
|
|
"""
|
|
if not self.enabled or len(alerts) == 0:
|
|
return False
|
|
|
|
# Categorize alerts by type_class
|
|
prevented_issues = [a for a in alerts if a.type_class == 'prevented_issue']
|
|
action_needed = [a for a in alerts if a.type_class == 'action_needed']
|
|
trend_warnings = [a for a in alerts if a.type_class == 'trend_warning']
|
|
escalations = [a for a in alerts if a.type_class == 'escalation']
|
|
|
|
# Calculate AI impact metrics
|
|
total_savings = sum(
|
|
(a.orchestrator_context or {}).get('estimated_savings_eur', 0)
|
|
for a in prevented_issues
|
|
)
|
|
|
|
ai_handling_rate = (len(prevented_issues) / len(alerts) * 100) if alerts else 0
|
|
|
|
# Build email content
|
|
email_data = {
|
|
"to": user_email,
|
|
"subject": self._build_subject_line(len(prevented_issues), len(action_needed)),
|
|
"template": "enriched_alert_digest",
|
|
"context": {
|
|
"tenant_id": str(tenant_id),
|
|
"user_name": user_name or "there",
|
|
"date": datetime.utcnow().strftime("%B %d, %Y"),
|
|
"total_alerts": len(alerts),
|
|
|
|
# AI Impact Section
|
|
"prevented_issues_count": len(prevented_issues),
|
|
"total_savings_eur": round(total_savings, 2),
|
|
"ai_handling_rate": round(ai_handling_rate, 1),
|
|
"prevented_issues": [self._serialize_prevented_issue(a) for a in prevented_issues[:5]],
|
|
|
|
# Action Needed Section
|
|
"action_needed_count": len(action_needed),
|
|
"critical_actions": [
|
|
self._serialize_action_alert(a)
|
|
for a in action_needed
|
|
if a.priority_level == 'critical'
|
|
][:3],
|
|
"important_actions": [
|
|
self._serialize_action_alert(a)
|
|
for a in action_needed
|
|
if a.priority_level == 'important'
|
|
][:5],
|
|
|
|
# Trend Warnings Section
|
|
"trend_warnings_count": len(trend_warnings),
|
|
"trend_warnings": [self._serialize_trend_warning(a) for a in trend_warnings[:3]],
|
|
|
|
# Escalations Section
|
|
"escalations_count": len(escalations),
|
|
"escalations": [self._serialize_escalation(a) for a in escalations[:3]],
|
|
}
|
|
}
|
|
|
|
# Send via notification service
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(
|
|
f"{self.notification_service_url}/api/email/send",
|
|
json=email_data,
|
|
timeout=10.0
|
|
)
|
|
success = response.status_code == 200
|
|
logger.info(
|
|
"Enriched email digest sent",
|
|
tenant_id=str(tenant_id),
|
|
alert_count=len(alerts),
|
|
prevented_count=len(prevented_issues),
|
|
savings_eur=total_savings,
|
|
success=success
|
|
)
|
|
return success
|
|
except Exception as e:
|
|
logger.error("Failed to send email digest", error=str(e), tenant_id=str(tenant_id))
|
|
return False
|
|
|
|
async def send_weekly_digest(
|
|
self,
|
|
tenant_id: UUID,
|
|
alerts: List[EnrichedAlert],
|
|
user_email: str,
|
|
user_name: Optional[str] = None
|
|
) -> bool:
|
|
"""
|
|
Send weekly email digest with aggregated AI impact metrics.
|
|
|
|
Focus: Week-over-week trends, total savings, top prevented issues.
|
|
"""
|
|
if not self.enabled or len(alerts) == 0:
|
|
return False
|
|
|
|
prevented_issues = [a for a in alerts if a.type_class == 'prevented_issue']
|
|
total_savings = sum(
|
|
(a.orchestrator_context or {}).get('estimated_savings_eur', 0)
|
|
for a in prevented_issues
|
|
)
|
|
|
|
email_data = {
|
|
"to": user_email,
|
|
"subject": f"Weekly AI Impact Summary - {len(prevented_issues)} Issues Prevented",
|
|
"template": "weekly_alert_digest",
|
|
"context": {
|
|
"tenant_id": str(tenant_id),
|
|
"user_name": user_name or "there",
|
|
"week_start": (datetime.utcnow() - timedelta(days=7)).strftime("%B %d"),
|
|
"week_end": datetime.utcnow().strftime("%B %d, %Y"),
|
|
"prevented_issues_count": len(prevented_issues),
|
|
"total_savings_eur": round(total_savings, 2),
|
|
"top_prevented_issues": [
|
|
self._serialize_prevented_issue(a)
|
|
for a in sorted(
|
|
prevented_issues,
|
|
key=lambda x: (x.orchestrator_context or {}).get('estimated_savings_eur', 0),
|
|
reverse=True
|
|
)[:10]
|
|
],
|
|
}
|
|
}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(
|
|
f"{self.notification_service_url}/api/email/send",
|
|
json=email_data,
|
|
timeout=10.0
|
|
)
|
|
return response.status_code == 200
|
|
except Exception as e:
|
|
logger.error("Failed to send weekly digest", error=str(e))
|
|
return False
|
|
|
|
def _build_subject_line(self, prevented_count: int, action_count: int) -> str:
|
|
"""Build dynamic subject line based on alert counts"""
|
|
if prevented_count > 0 and action_count == 0:
|
|
return f"🎉 Great News! AI Prevented {prevented_count} Issue{'s' if prevented_count > 1 else ''} Today"
|
|
elif prevented_count > 0 and action_count > 0:
|
|
return f"Daily Summary: {prevented_count} Prevented, {action_count} Need{'s' if action_count == 1 else ''} Attention"
|
|
elif action_count > 0:
|
|
return f"⚠️ {action_count} Alert{'s' if action_count > 1 else ''} Require{'s' if action_count == 1 else ''} Your Attention"
|
|
else:
|
|
return "Daily Alert Summary"
|
|
|
|
def _serialize_prevented_issue(self, alert: EnrichedAlert) -> dict:
|
|
"""Serialize prevented issue for email with celebration tone"""
|
|
return {
|
|
"title": alert.title,
|
|
"message": alert.message,
|
|
"ai_reasoning": alert.ai_reasoning_summary,
|
|
"savings_eur": (alert.orchestrator_context or {}).get('estimated_savings_eur', 0),
|
|
"action_taken": (alert.orchestrator_context or {}).get('action_taken', 'AI intervention'),
|
|
"created_at": alert.created_at.strftime("%I:%M %p"),
|
|
"priority_score": alert.priority_score,
|
|
}
|
|
|
|
def _serialize_action_alert(self, alert: EnrichedAlert) -> dict:
|
|
"""Serialize action-needed alert with urgency context"""
|
|
return {
|
|
"title": alert.title,
|
|
"message": alert.message,
|
|
"priority_level": alert.priority_level.value,
|
|
"priority_score": alert.priority_score,
|
|
"financial_impact_eur": (alert.business_impact or {}).get('financial_impact_eur'),
|
|
"time_sensitive": (alert.urgency_context or {}).get('time_sensitive', False),
|
|
"deadline": (alert.urgency_context or {}).get('deadline'),
|
|
"actions": [a.get('label', '') for a in (alert.smart_actions or [])[:3] if isinstance(a, dict)],
|
|
"created_at": alert.created_at.strftime("%I:%M %p"),
|
|
}
|
|
|
|
def _serialize_trend_warning(self, alert: EnrichedAlert) -> dict:
|
|
"""Serialize trend warning with trend data"""
|
|
return {
|
|
"title": alert.title,
|
|
"message": alert.message,
|
|
"trend_direction": (alert.trend_context or {}).get('direction', 'stable'),
|
|
"historical_comparison": (alert.trend_context or {}).get('historical_comparison'),
|
|
"ai_reasoning": alert.ai_reasoning_summary,
|
|
"created_at": alert.created_at.strftime("%I:%M %p"),
|
|
}
|
|
|
|
def _serialize_escalation(self, alert: EnrichedAlert) -> dict:
|
|
"""Serialize escalation alert with auto-action context"""
|
|
return {
|
|
"title": alert.title,
|
|
"message": alert.message,
|
|
"action_countdown": (alert.orchestrator_context or {}).get('action_in_seconds'),
|
|
"action_description": (alert.orchestrator_context or {}).get('pending_action'),
|
|
"can_cancel": not (alert.alert_metadata or {}).get('auto_action_cancelled', False),
|
|
"financial_impact_eur": (alert.business_impact or {}).get('financial_impact_eur'),
|
|
"created_at": alert.created_at.strftime("%I:%M %p"),
|
|
}
|