Files
bakery-ia/services/alert_processor/app/services/enrichment/email_digest.py
2025-11-27 15:52:40 +01:00

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"),
}