New alert service
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
# services/alert_processor/app/services/__init__.py
|
||||
"""
|
||||
Alert Processor Services Package
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
Alert Enrichment Services
|
||||
|
||||
Provides intelligent enrichment for all alerts:
|
||||
- Priority scoring (multi-factor)
|
||||
- Context enrichment (orchestrator queries)
|
||||
- Timing intelligence (peak hours)
|
||||
- Smart action generation
|
||||
"""
|
||||
|
||||
from .priority_scoring import PriorityScoringService
|
||||
from .context_enrichment import ContextEnrichmentService
|
||||
from .timing_intelligence import TimingIntelligenceService
|
||||
from .orchestrator_client import OrchestratorClient
|
||||
|
||||
__all__ = [
|
||||
'PriorityScoringService',
|
||||
'ContextEnrichmentService',
|
||||
'TimingIntelligenceService',
|
||||
'OrchestratorClient',
|
||||
]
|
||||
@@ -1,163 +0,0 @@
|
||||
"""
|
||||
Alert Grouping Service
|
||||
|
||||
Groups related alerts for better UX:
|
||||
- Multiple low stock items from same supplier → "3 ingredients low from Supplier X"
|
||||
- Multiple production delays → "Production delays affecting 5 batches"
|
||||
- Same alert type in time window → Grouped notification
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import uuid4
|
||||
from collections import defaultdict
|
||||
|
||||
from shared.schemas.alert_types import EnrichedAlert, AlertGroup
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class AlertGroupingService:
|
||||
"""Groups related alerts intelligently"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.grouping_enabled = config.ALERT_GROUPING_ENABLED
|
||||
self.time_window_minutes = config.GROUPING_TIME_WINDOW_MINUTES
|
||||
self.min_for_grouping = config.MIN_ALERTS_FOR_GROUPING
|
||||
|
||||
async def group_alerts(
|
||||
self,
|
||||
alerts: List[EnrichedAlert],
|
||||
tenant_id: str
|
||||
) -> List[EnrichedAlert]:
|
||||
"""
|
||||
Group related alerts and return list with group summaries
|
||||
|
||||
Returns: Modified alert list with group summaries replacing individual alerts
|
||||
"""
|
||||
if not self.grouping_enabled or len(alerts) < self.min_for_grouping:
|
||||
return alerts
|
||||
|
||||
# Group by different strategies
|
||||
groups = []
|
||||
ungrouped = []
|
||||
|
||||
# Strategy 1: Group by supplier
|
||||
supplier_groups = self._group_by_supplier(alerts)
|
||||
for group_alerts in supplier_groups.values():
|
||||
if len(group_alerts) >= self.min_for_grouping:
|
||||
groups.append(self._create_supplier_group(group_alerts, tenant_id))
|
||||
else:
|
||||
ungrouped.extend(group_alerts)
|
||||
|
||||
# Strategy 2: Group by alert type (same type, same time window)
|
||||
type_groups = self._group_by_type(alerts)
|
||||
for group_alerts in type_groups.values():
|
||||
if len(group_alerts) >= self.min_for_grouping:
|
||||
groups.append(self._create_type_group(group_alerts, tenant_id))
|
||||
else:
|
||||
ungrouped.extend(group_alerts)
|
||||
|
||||
# Combine grouped summaries with ungrouped alerts
|
||||
result = groups + ungrouped
|
||||
result.sort(key=lambda a: a.priority_score, reverse=True)
|
||||
|
||||
logger.info(
|
||||
"Alerts grouped",
|
||||
original_count=len(alerts),
|
||||
grouped_count=len(groups),
|
||||
final_count=len(result)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _group_by_supplier(self, alerts: List[EnrichedAlert]) -> Dict[str, List[EnrichedAlert]]:
|
||||
"""Group alerts by supplier"""
|
||||
groups = defaultdict(list)
|
||||
|
||||
for alert in alerts:
|
||||
if alert.user_agency and alert.user_agency.external_party_name:
|
||||
supplier = alert.user_agency.external_party_name
|
||||
if alert.alert_type in ["critical_stock_shortage", "low_stock_warning"]:
|
||||
groups[supplier].append(alert)
|
||||
|
||||
return groups
|
||||
|
||||
def _group_by_type(self, alerts: List[EnrichedAlert]) -> Dict[str, List[EnrichedAlert]]:
|
||||
"""Group alerts by type within time window"""
|
||||
groups = defaultdict(list)
|
||||
cutoff_time = datetime.utcnow() - timedelta(minutes=self.time_window_minutes)
|
||||
|
||||
for alert in alerts:
|
||||
if alert.created_at >= cutoff_time:
|
||||
groups[alert.alert_type].append(alert)
|
||||
|
||||
# Filter out groups that don't meet minimum
|
||||
return {k: v for k, v in groups.items() if len(v) >= self.min_for_grouping}
|
||||
|
||||
def _create_supplier_group(
|
||||
self,
|
||||
alerts: List[EnrichedAlert],
|
||||
tenant_id: str
|
||||
) -> EnrichedAlert:
|
||||
"""Create a grouped alert for supplier-related alerts"""
|
||||
supplier_name = alerts[0].user_agency.external_party_name
|
||||
count = len(alerts)
|
||||
|
||||
# Calculate highest priority
|
||||
max_priority = max(a.priority_score for a in alerts)
|
||||
|
||||
# Aggregate financial impact
|
||||
total_impact = sum(
|
||||
a.business_impact.financial_impact_eur or 0
|
||||
for a in alerts
|
||||
if a.business_impact
|
||||
)
|
||||
|
||||
# Create group summary alert
|
||||
group_id = str(uuid4())
|
||||
|
||||
summary_alert = alerts[0].copy(deep=True)
|
||||
summary_alert.id = group_id
|
||||
summary_alert.group_id = group_id
|
||||
summary_alert.is_group_summary = True
|
||||
summary_alert.grouped_alert_count = count
|
||||
summary_alert.grouped_alert_ids = [a.id for a in alerts]
|
||||
summary_alert.priority_score = max_priority
|
||||
summary_alert.title = f"{count} ingredients low from {supplier_name}"
|
||||
summary_alert.message = f"Review consolidated order for {supplier_name} — €{total_impact:.0f} total"
|
||||
|
||||
# Update actions - check if using old actions structure
|
||||
if hasattr(summary_alert, 'actions') and summary_alert.actions:
|
||||
matching_actions = [a for a in summary_alert.actions if hasattr(a, 'type') and getattr(a, 'type', None) and getattr(a.type, 'value', None) == "open_reasoning"][:1]
|
||||
if len(summary_alert.actions) > 0:
|
||||
summary_alert.actions = [summary_alert.actions[0]] + matching_actions
|
||||
|
||||
return summary_alert
|
||||
|
||||
def _create_type_group(
|
||||
self,
|
||||
alerts: List[EnrichedAlert],
|
||||
tenant_id: str
|
||||
) -> EnrichedAlert:
|
||||
"""Create a grouped alert for same-type alerts"""
|
||||
alert_type = alerts[0].alert_type
|
||||
count = len(alerts)
|
||||
|
||||
max_priority = max(a.priority_score for a in alerts)
|
||||
|
||||
group_id = str(uuid4())
|
||||
|
||||
summary_alert = alerts[0].copy(deep=True)
|
||||
summary_alert.id = group_id
|
||||
summary_alert.group_id = group_id
|
||||
summary_alert.is_group_summary = True
|
||||
summary_alert.grouped_alert_count = count
|
||||
summary_alert.grouped_alert_ids = [a.id for a in alerts]
|
||||
summary_alert.priority_score = max_priority
|
||||
summary_alert.title = f"{count} {alert_type.replace('_', ' ')} alerts"
|
||||
summary_alert.message = f"Review {count} related alerts"
|
||||
|
||||
return summary_alert
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,239 +0,0 @@
|
||||
"""
|
||||
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"),
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
"""
|
||||
Enrichment Router
|
||||
|
||||
Routes events to appropriate enrichment pipelines based on event_class:
|
||||
- ALERT: Full enrichment (orchestrator, priority, smart actions, timing)
|
||||
- NOTIFICATION: Lightweight enrichment (basic formatting only)
|
||||
- RECOMMENDATION: Moderate enrichment (no orchestrator queries)
|
||||
|
||||
This enables 80% reduction in processing time for non-alert events.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import uuid
|
||||
|
||||
from shared.schemas.event_classification import (
|
||||
RawEvent,
|
||||
EventClass,
|
||||
EventDomain,
|
||||
NotificationType,
|
||||
RecommendationType,
|
||||
)
|
||||
from services.alert_processor.app.models.events import (
|
||||
Alert,
|
||||
Notification,
|
||||
Recommendation,
|
||||
)
|
||||
from services.alert_processor.app.services.enrichment.context_enrichment import ContextEnrichmentService
|
||||
from services.alert_processor.app.services.enrichment.priority_scoring import PriorityScoringService
|
||||
from services.alert_processor.app.services.enrichment.timing_intelligence import TimingIntelligenceService
|
||||
from services.alert_processor.app.services.enrichment.orchestrator_client import OrchestratorClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnrichmentRouter:
|
||||
"""
|
||||
Routes events to appropriate enrichment pipeline based on event_class.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context_enrichment_service: Optional[ContextEnrichmentService] = None,
|
||||
priority_scoring_service: Optional[PriorityScoringService] = None,
|
||||
timing_intelligence_service: Optional[TimingIntelligenceService] = None,
|
||||
orchestrator_client: Optional[OrchestratorClient] = None,
|
||||
):
|
||||
"""Initialize enrichment router with services"""
|
||||
self.context_enrichment = context_enrichment_service or ContextEnrichmentService()
|
||||
self.priority_scoring = priority_scoring_service or PriorityScoringService()
|
||||
self.timing_intelligence = timing_intelligence_service or TimingIntelligenceService()
|
||||
self.orchestrator_client = orchestrator_client or OrchestratorClient()
|
||||
|
||||
async def enrich_event(self, raw_event: RawEvent) -> Alert | Notification | Recommendation:
|
||||
"""
|
||||
Route event to appropriate enrichment pipeline.
|
||||
|
||||
Args:
|
||||
raw_event: Raw event from domain service
|
||||
|
||||
Returns:
|
||||
Enriched Alert, Notification, or Recommendation model
|
||||
|
||||
Raises:
|
||||
ValueError: If event_class is not recognized
|
||||
"""
|
||||
logger.info(
|
||||
f"Enriching event: class={raw_event.event_class}, "
|
||||
f"domain={raw_event.event_domain}, type={raw_event.event_type}"
|
||||
)
|
||||
|
||||
if raw_event.event_class == EventClass.ALERT:
|
||||
return await self._enrich_alert(raw_event)
|
||||
elif raw_event.event_class == EventClass.NOTIFICATION:
|
||||
return await self._enrich_notification(raw_event)
|
||||
elif raw_event.event_class == EventClass.RECOMMENDATION:
|
||||
return await self._enrich_recommendation(raw_event)
|
||||
else:
|
||||
raise ValueError(f"Unknown event_class: {raw_event.event_class}")
|
||||
|
||||
# ============================================================
|
||||
# ALERT ENRICHMENT (Full Pipeline)
|
||||
# ============================================================
|
||||
|
||||
async def _enrich_alert(self, raw_event: RawEvent) -> Alert:
|
||||
"""
|
||||
Full enrichment pipeline for alerts.
|
||||
|
||||
Steps:
|
||||
1. Query orchestrator for context
|
||||
2. Calculate business impact
|
||||
3. Assess urgency
|
||||
4. Determine user agency
|
||||
5. Generate smart actions
|
||||
6. Calculate priority score
|
||||
7. Determine timing
|
||||
8. Classify type_class
|
||||
"""
|
||||
logger.debug(f"Full enrichment for alert: {raw_event.event_type}")
|
||||
|
||||
# Step 1: Orchestrator context
|
||||
orchestrator_context = await self._get_orchestrator_context(raw_event)
|
||||
|
||||
# Step 2-5: Context enrichment (business impact, urgency, user agency, smart actions)
|
||||
enriched_context = await self.context_enrichment.enrich(
|
||||
raw_event=raw_event,
|
||||
orchestrator_context=orchestrator_context,
|
||||
)
|
||||
|
||||
# Step 6: Priority scoring (multi-factor)
|
||||
priority_data = await self.priority_scoring.calculate_priority(
|
||||
raw_event=raw_event,
|
||||
business_impact=enriched_context.get('business_impact'),
|
||||
urgency_context=enriched_context.get('urgency_context'),
|
||||
user_agency=enriched_context.get('user_agency'),
|
||||
confidence_score=enriched_context.get('confidence_score', 0.8),
|
||||
)
|
||||
|
||||
# Step 7: Timing intelligence
|
||||
timing_data = await self.timing_intelligence.determine_timing(
|
||||
priority_score=priority_data['priority_score'],
|
||||
priority_level=priority_data['priority_level'],
|
||||
type_class=enriched_context.get('type_class', 'action_needed'),
|
||||
)
|
||||
|
||||
# Create Alert model
|
||||
alert = Alert(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(raw_event.tenant_id),
|
||||
event_domain=raw_event.event_domain.value,
|
||||
event_type=raw_event.event_type,
|
||||
service=raw_event.service,
|
||||
title=raw_event.title,
|
||||
message=raw_event.message,
|
||||
type_class=enriched_context.get('type_class', 'action_needed'),
|
||||
status='active',
|
||||
priority_score=priority_data['priority_score'],
|
||||
priority_level=priority_data['priority_level'],
|
||||
orchestrator_context=orchestrator_context,
|
||||
business_impact=enriched_context.get('business_impact'),
|
||||
urgency_context=enriched_context.get('urgency_context'),
|
||||
user_agency=enriched_context.get('user_agency'),
|
||||
trend_context=enriched_context.get('trend_context'),
|
||||
smart_actions=enriched_context.get('smart_actions', []),
|
||||
ai_reasoning_summary=enriched_context.get('ai_reasoning_summary'),
|
||||
confidence_score=enriched_context.get('confidence_score', 0.8),
|
||||
timing_decision=timing_data['timing_decision'],
|
||||
scheduled_send_time=timing_data.get('scheduled_send_time'),
|
||||
placement=timing_data.get('placement', ['toast', 'action_queue', 'notification_panel']),
|
||||
action_created_at=enriched_context.get('action_created_at'),
|
||||
superseded_by_action_id=enriched_context.get('superseded_by_action_id'),
|
||||
hidden_from_ui=enriched_context.get('hidden_from_ui', False),
|
||||
alert_metadata=raw_event.event_metadata,
|
||||
created_at=raw_event.timestamp or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Alert enriched: {alert.event_type}, priority={alert.priority_score}, "
|
||||
f"type_class={alert.type_class}"
|
||||
)
|
||||
|
||||
return alert
|
||||
|
||||
async def _get_orchestrator_context(self, raw_event: RawEvent) -> Optional[Dict[str, Any]]:
|
||||
"""Query orchestrator for recent actions related to this event"""
|
||||
try:
|
||||
# Extract relevant IDs from metadata
|
||||
ingredient_id = raw_event.event_metadata.get('ingredient_id')
|
||||
product_id = raw_event.event_metadata.get('product_id')
|
||||
|
||||
if not ingredient_id and not product_id:
|
||||
return None
|
||||
|
||||
# Query orchestrator
|
||||
recent_actions = await self.orchestrator_client.get_recent_actions(
|
||||
tenant_id=raw_event.tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
product_id=product_id,
|
||||
)
|
||||
|
||||
if not recent_actions:
|
||||
return None
|
||||
|
||||
# Return most recent action
|
||||
action = recent_actions[0]
|
||||
return {
|
||||
'already_addressed': True,
|
||||
'action_type': action.get('action_type'),
|
||||
'action_id': action.get('action_id'),
|
||||
'action_status': action.get('status'),
|
||||
'delivery_date': action.get('delivery_date'),
|
||||
'reasoning': action.get('reasoning'),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch orchestrator context: {e}")
|
||||
return None
|
||||
|
||||
# ============================================================
|
||||
# NOTIFICATION ENRICHMENT (Lightweight)
|
||||
# ============================================================
|
||||
|
||||
async def _enrich_notification(self, raw_event: RawEvent) -> Notification:
|
||||
"""
|
||||
Lightweight enrichment for notifications.
|
||||
|
||||
No orchestrator queries, no priority scoring, no smart actions.
|
||||
Just basic formatting and entity extraction.
|
||||
"""
|
||||
logger.debug(f"Lightweight enrichment for notification: {raw_event.event_type}")
|
||||
|
||||
# Infer notification_type from event_type
|
||||
notification_type = self._infer_notification_type(raw_event.event_type)
|
||||
|
||||
# Extract entity context from metadata
|
||||
entity_type, entity_id, old_state, new_state = self._extract_entity_context(raw_event)
|
||||
|
||||
# Create Notification model
|
||||
notification = Notification(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(raw_event.tenant_id),
|
||||
event_domain=raw_event.event_domain.value,
|
||||
event_type=raw_event.event_type,
|
||||
notification_type=notification_type.value,
|
||||
service=raw_event.service,
|
||||
title=raw_event.title,
|
||||
message=raw_event.message,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
old_state=old_state,
|
||||
new_state=new_state,
|
||||
notification_metadata=raw_event.event_metadata,
|
||||
placement=['notification_panel'], # Lightweight: panel only, no toast
|
||||
# expires_at set automatically in __init__ (7 days)
|
||||
created_at=raw_event.timestamp or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
logger.info(f"Notification enriched: {notification.event_type}, entity={entity_type}:{entity_id}")
|
||||
|
||||
return notification
|
||||
|
||||
def _infer_notification_type(self, event_type: str) -> NotificationType:
|
||||
"""Infer notification_type from event_type string"""
|
||||
event_type_lower = event_type.lower()
|
||||
|
||||
if 'state_change' in event_type_lower or 'status_change' in event_type_lower:
|
||||
return NotificationType.STATE_CHANGE
|
||||
elif 'completed' in event_type_lower or 'finished' in event_type_lower:
|
||||
return NotificationType.COMPLETION
|
||||
elif 'received' in event_type_lower or 'arrived' in event_type_lower or 'arrival' in event_type_lower:
|
||||
return NotificationType.ARRIVAL
|
||||
elif 'shipped' in event_type_lower or 'sent' in event_type_lower or 'departure' in event_type_lower:
|
||||
return NotificationType.DEPARTURE
|
||||
elif 'started' in event_type_lower or 'created' in event_type_lower:
|
||||
return NotificationType.SYSTEM_EVENT
|
||||
else:
|
||||
return NotificationType.UPDATE
|
||||
|
||||
def _extract_entity_context(self, raw_event: RawEvent) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
"""Extract entity context from metadata"""
|
||||
metadata = raw_event.event_metadata
|
||||
|
||||
# Try to infer entity_type from metadata keys
|
||||
entity_type = None
|
||||
entity_id = None
|
||||
old_state = None
|
||||
new_state = None
|
||||
|
||||
# Check for common entity types
|
||||
if 'batch_id' in metadata:
|
||||
entity_type = 'batch'
|
||||
entity_id = metadata.get('batch_id')
|
||||
old_state = metadata.get('old_status') or metadata.get('previous_status')
|
||||
new_state = metadata.get('new_status') or metadata.get('status')
|
||||
elif 'delivery_id' in metadata:
|
||||
entity_type = 'delivery'
|
||||
entity_id = metadata.get('delivery_id')
|
||||
old_state = metadata.get('old_status')
|
||||
new_state = metadata.get('new_status') or metadata.get('status')
|
||||
elif 'po_id' in metadata or 'purchase_order_id' in metadata:
|
||||
entity_type = 'purchase_order'
|
||||
entity_id = metadata.get('po_id') or metadata.get('purchase_order_id')
|
||||
old_state = metadata.get('old_status')
|
||||
new_state = metadata.get('new_status') or metadata.get('status')
|
||||
elif 'orchestration_run_id' in metadata or 'run_id' in metadata:
|
||||
entity_type = 'orchestration_run'
|
||||
entity_id = metadata.get('orchestration_run_id') or metadata.get('run_id')
|
||||
old_state = metadata.get('old_status')
|
||||
new_state = metadata.get('new_status') or metadata.get('status')
|
||||
|
||||
return entity_type, entity_id, old_state, new_state
|
||||
|
||||
# ============================================================
|
||||
# RECOMMENDATION ENRICHMENT (Moderate)
|
||||
# ============================================================
|
||||
|
||||
async def _enrich_recommendation(self, raw_event: RawEvent) -> Recommendation:
|
||||
"""
|
||||
Moderate enrichment for recommendations.
|
||||
|
||||
No orchestrator queries, light priority, basic suggested actions.
|
||||
"""
|
||||
logger.debug(f"Moderate enrichment for recommendation: {raw_event.event_type}")
|
||||
|
||||
# Infer recommendation_type from event_type
|
||||
recommendation_type = self._infer_recommendation_type(raw_event.event_type)
|
||||
|
||||
# Calculate light priority (defaults to info, can be elevated based on metadata)
|
||||
priority_level = self._calculate_light_priority(raw_event)
|
||||
|
||||
# Extract estimated impact from metadata
|
||||
estimated_impact = self._extract_estimated_impact(raw_event)
|
||||
|
||||
# Generate basic suggested actions (lightweight, no smart action generation)
|
||||
suggested_actions = self._generate_suggested_actions(raw_event)
|
||||
|
||||
# Create Recommendation model
|
||||
recommendation = Recommendation(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(raw_event.tenant_id),
|
||||
event_domain=raw_event.event_domain.value,
|
||||
event_type=raw_event.event_type,
|
||||
recommendation_type=recommendation_type.value,
|
||||
service=raw_event.service,
|
||||
title=raw_event.title,
|
||||
message=raw_event.message,
|
||||
priority_level=priority_level,
|
||||
estimated_impact=estimated_impact,
|
||||
suggested_actions=suggested_actions,
|
||||
ai_reasoning_summary=raw_event.event_metadata.get('reasoning'),
|
||||
confidence_score=raw_event.event_metadata.get('confidence_score', 0.7),
|
||||
recommendation_metadata=raw_event.event_metadata,
|
||||
created_at=raw_event.timestamp or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
logger.info(f"Recommendation enriched: {recommendation.event_type}, priority={priority_level}")
|
||||
|
||||
return recommendation
|
||||
|
||||
def _infer_recommendation_type(self, event_type: str) -> RecommendationType:
|
||||
"""Infer recommendation_type from event_type string"""
|
||||
event_type_lower = event_type.lower()
|
||||
|
||||
if 'optimization' in event_type_lower or 'efficiency' in event_type_lower:
|
||||
return RecommendationType.OPTIMIZATION
|
||||
elif 'cost' in event_type_lower or 'saving' in event_type_lower:
|
||||
return RecommendationType.COST_REDUCTION
|
||||
elif 'risk' in event_type_lower or 'prevent' in event_type_lower:
|
||||
return RecommendationType.RISK_MITIGATION
|
||||
elif 'trend' in event_type_lower or 'pattern' in event_type_lower:
|
||||
return RecommendationType.TREND_INSIGHT
|
||||
else:
|
||||
return RecommendationType.BEST_PRACTICE
|
||||
|
||||
def _calculate_light_priority(self, raw_event: RawEvent) -> str:
|
||||
"""Calculate light priority for recommendations (info by default)"""
|
||||
metadata = raw_event.event_metadata
|
||||
|
||||
# Check for urgency hints in metadata
|
||||
if metadata.get('urgent') or metadata.get('is_urgent'):
|
||||
return 'important'
|
||||
elif metadata.get('high_impact'):
|
||||
return 'standard'
|
||||
else:
|
||||
return 'info'
|
||||
|
||||
def _extract_estimated_impact(self, raw_event: RawEvent) -> Optional[Dict[str, Any]]:
|
||||
"""Extract estimated impact from metadata"""
|
||||
metadata = raw_event.event_metadata
|
||||
|
||||
impact = {}
|
||||
|
||||
if 'estimated_savings_eur' in metadata:
|
||||
impact['financial_savings_eur'] = metadata['estimated_savings_eur']
|
||||
if 'estimated_time_saved_hours' in metadata:
|
||||
impact['time_saved_hours'] = metadata['estimated_time_saved_hours']
|
||||
if 'efficiency_gain_percent' in metadata:
|
||||
impact['efficiency_gain_percent'] = metadata['efficiency_gain_percent']
|
||||
|
||||
return impact if impact else None
|
||||
|
||||
def _generate_suggested_actions(self, raw_event: RawEvent) -> Optional[list[Dict[str, Any]]]:
|
||||
"""Generate basic suggested actions (lightweight, no smart action logic)"""
|
||||
# If actions provided in raw_event, use them
|
||||
if raw_event.actions:
|
||||
return [{'type': action, 'label': action.replace('_', ' ').title()} for action in raw_event.actions]
|
||||
|
||||
# Otherwise, return None (optional actions)
|
||||
return None
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Orchestrator Client for Alert Enrichment
|
||||
|
||||
Queries Daily Orchestrator for recent AI actions to provide context enrichment
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class OrchestratorClient:
|
||||
"""
|
||||
Client for querying orchestrator service
|
||||
Used to determine if AI already handled an alert
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 10.0):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
return self._client
|
||||
|
||||
async def get_recent_actions(
|
||||
self,
|
||||
tenant_id: str,
|
||||
ingredient_id: Optional[str] = None,
|
||||
hours_ago: int = 24
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Query orchestrator for recent actions
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
ingredient_id: Optional ingredient filter
|
||||
hours_ago: How far back to look (default 24h)
|
||||
|
||||
Returns:
|
||||
List of recent orchestrator actions
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
url = f"{self.base_url}/api/internal/recent-actions"
|
||||
params = {
|
||||
'tenant_id': tenant_id,
|
||||
'hours_ago': hours_ago
|
||||
}
|
||||
|
||||
if ingredient_id:
|
||||
params['ingredient_id'] = ingredient_id
|
||||
|
||||
response = await client.get(
|
||||
url,
|
||||
params=params,
|
||||
headers={'X-Internal-Service': 'alert-processor'}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
actions = data.get('actions', [])
|
||||
logger.debug(
|
||||
"Orchestrator actions retrieved",
|
||||
tenant_id=tenant_id,
|
||||
count=len(actions)
|
||||
)
|
||||
return actions
|
||||
else:
|
||||
logger.warning(
|
||||
"Orchestrator query failed",
|
||||
status=response.status_code,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return []
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(
|
||||
"Orchestrator query timeout",
|
||||
tenant_id=tenant_id,
|
||||
timeout=self.timeout
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to query orchestrator",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return []
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
@@ -1,415 +0,0 @@
|
||||
"""
|
||||
Priority Scoring Service
|
||||
|
||||
Calculates multi-factor priority scores for alerts based on:
|
||||
- Business Impact (40%): Financial, operational, customer satisfaction
|
||||
- Urgency (30%): Time until consequence, deadline proximity
|
||||
- User Agency (20%): Can the user actually fix this?
|
||||
- Confidence (10%): How certain is the assessment?
|
||||
|
||||
PLUS time-based escalation for action-needed alerts
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from datetime import datetime, time as dt_time, timedelta, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from shared.schemas.alert_types import (
|
||||
PriorityScoreComponents,
|
||||
BusinessImpact, UrgencyContext, UserAgency
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class PriorityScoringService:
|
||||
"""Calculates intelligent priority scores for alerts"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.business_impact_weight = config.BUSINESS_IMPACT_WEIGHT
|
||||
self.urgency_weight = config.URGENCY_WEIGHT
|
||||
self.user_agency_weight = config.USER_AGENCY_WEIGHT
|
||||
self.confidence_weight = config.CONFIDENCE_WEIGHT
|
||||
|
||||
def calculate_priority_score(
|
||||
self,
|
||||
business_impact: Optional[BusinessImpact],
|
||||
urgency_context: Optional[UrgencyContext],
|
||||
user_agency: Optional[UserAgency],
|
||||
confidence_score: Optional[float]
|
||||
) -> PriorityScoreComponents:
|
||||
"""
|
||||
Calculate multi-factor priority score
|
||||
|
||||
Args:
|
||||
business_impact: Business impact assessment
|
||||
urgency_context: Urgency and timing context
|
||||
user_agency: User's ability to act
|
||||
confidence_score: AI confidence (0-1)
|
||||
|
||||
Returns:
|
||||
PriorityScoreComponents with breakdown
|
||||
"""
|
||||
|
||||
# Calculate component scores
|
||||
business_score = self._calculate_business_impact_score(business_impact)
|
||||
urgency_score = self._calculate_urgency_score(urgency_context)
|
||||
agency_score = self._calculate_user_agency_score(user_agency)
|
||||
confidence = (confidence_score or 0.8) * 100 # Default 80% confidence
|
||||
|
||||
# Apply weights
|
||||
weighted_business = business_score * self.business_impact_weight
|
||||
weighted_urgency = urgency_score * self.urgency_weight
|
||||
weighted_agency = agency_score * self.user_agency_weight
|
||||
weighted_confidence = confidence * self.confidence_weight
|
||||
|
||||
# Calculate final score
|
||||
final_score = int(
|
||||
weighted_business +
|
||||
weighted_urgency +
|
||||
weighted_agency +
|
||||
weighted_confidence
|
||||
)
|
||||
|
||||
# Clamp to 0-100
|
||||
final_score = max(0, min(100, final_score))
|
||||
|
||||
logger.debug(
|
||||
"Priority score calculated",
|
||||
final_score=final_score,
|
||||
business=business_score,
|
||||
urgency=urgency_score,
|
||||
agency=agency_score,
|
||||
confidence=confidence
|
||||
)
|
||||
|
||||
return PriorityScoreComponents(
|
||||
business_impact_score=business_score,
|
||||
urgency_score=urgency_score,
|
||||
user_agency_score=agency_score,
|
||||
confidence_score=confidence,
|
||||
final_score=final_score,
|
||||
weights={
|
||||
"business_impact": self.business_impact_weight,
|
||||
"urgency": self.urgency_weight,
|
||||
"user_agency": self.user_agency_weight,
|
||||
"confidence": self.confidence_weight
|
||||
}
|
||||
)
|
||||
|
||||
def calculate_escalation_boost(
|
||||
self,
|
||||
action_created_at: Optional[datetime],
|
||||
urgency_context: Optional[UrgencyContext],
|
||||
current_priority: int
|
||||
) -> int:
|
||||
"""
|
||||
Calculate priority boost based on how long action has been pending
|
||||
and proximity to deadline.
|
||||
|
||||
Escalation rules:
|
||||
- Pending >48h: +10 priority points
|
||||
- Pending >72h: +20 priority points
|
||||
- Within 24h of deadline: +15 points
|
||||
- Within 6h of deadline: +30 points
|
||||
- Max total boost: +30 points
|
||||
|
||||
Args:
|
||||
action_created_at: When the action was created
|
||||
urgency_context: Deadline and timing context
|
||||
current_priority: Current priority score (to avoid over-escalating)
|
||||
|
||||
Returns:
|
||||
Escalation boost (0-30 points)
|
||||
"""
|
||||
if not action_created_at:
|
||||
return 0
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
boost = 0
|
||||
|
||||
# Make action_created_at timezone-aware if it isn't
|
||||
if action_created_at.tzinfo is None:
|
||||
action_created_at = action_created_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
time_pending = now - action_created_at
|
||||
|
||||
# Time pending escalation
|
||||
if time_pending > timedelta(hours=72):
|
||||
boost += 20
|
||||
logger.info(
|
||||
"Alert escalated: pending >72h",
|
||||
action_created_at=action_created_at.isoformat(),
|
||||
hours_pending=time_pending.total_seconds() / 3600,
|
||||
boost=20
|
||||
)
|
||||
elif time_pending > timedelta(hours=48):
|
||||
boost += 10
|
||||
logger.info(
|
||||
"Alert escalated: pending >48h",
|
||||
action_created_at=action_created_at.isoformat(),
|
||||
hours_pending=time_pending.total_seconds() / 3600,
|
||||
boost=10
|
||||
)
|
||||
|
||||
# Deadline proximity escalation
|
||||
if urgency_context and urgency_context.deadline:
|
||||
deadline = urgency_context.deadline
|
||||
# Make deadline timezone-aware if it isn't
|
||||
if deadline.tzinfo is None:
|
||||
deadline = deadline.replace(tzinfo=timezone.utc)
|
||||
|
||||
time_until_deadline = deadline - now
|
||||
|
||||
if time_until_deadline < timedelta(hours=6):
|
||||
deadline_boost = 30
|
||||
boost = max(boost, deadline_boost) # Take the higher boost
|
||||
logger.info(
|
||||
"Alert escalated: deadline <6h",
|
||||
deadline=deadline.isoformat(),
|
||||
hours_until=time_until_deadline.total_seconds() / 3600,
|
||||
boost=deadline_boost
|
||||
)
|
||||
elif time_until_deadline < timedelta(hours=24):
|
||||
deadline_boost = 15
|
||||
boost = max(boost, 15) # Take the higher boost
|
||||
logger.info(
|
||||
"Alert escalated: deadline <24h",
|
||||
deadline=deadline.isoformat(),
|
||||
hours_until=time_until_deadline.total_seconds() / 3600,
|
||||
boost=deadline_boost
|
||||
)
|
||||
|
||||
# Cap total boost at 30 points
|
||||
boost = min(30, boost)
|
||||
|
||||
# Don't escalate if already critical (>= 90)
|
||||
if current_priority >= 90 and boost > 0:
|
||||
logger.debug(
|
||||
"Escalation skipped: already critical",
|
||||
current_priority=current_priority,
|
||||
would_boost=boost
|
||||
)
|
||||
return 0
|
||||
|
||||
return boost
|
||||
|
||||
def get_priority_level(self, score: int) -> str:
|
||||
"""Convert numeric score to priority level"""
|
||||
if score >= self.config.CRITICAL_THRESHOLD:
|
||||
return "critical"
|
||||
elif score >= self.config.IMPORTANT_THRESHOLD:
|
||||
return "important"
|
||||
elif score >= self.config.STANDARD_THRESHOLD:
|
||||
return "standard"
|
||||
else:
|
||||
return "info"
|
||||
|
||||
def _calculate_business_impact_score(
|
||||
self,
|
||||
impact: Optional[BusinessImpact]
|
||||
) -> float:
|
||||
"""
|
||||
Calculate business impact score (0-100)
|
||||
|
||||
Factors:
|
||||
- Financial impact (€)
|
||||
- Affected orders/customers
|
||||
- Production disruption
|
||||
- Stockout/waste risk
|
||||
"""
|
||||
if not impact:
|
||||
return 50.0 # Default mid-range
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Financial impact (0-40 points)
|
||||
if impact.financial_impact_eur:
|
||||
if impact.financial_impact_eur >= 500:
|
||||
score += 40
|
||||
elif impact.financial_impact_eur >= 200:
|
||||
score += 30
|
||||
elif impact.financial_impact_eur >= 100:
|
||||
score += 20
|
||||
elif impact.financial_impact_eur >= 50:
|
||||
score += 10
|
||||
else:
|
||||
score += 5
|
||||
|
||||
# Affected orders/customers (0-30 points)
|
||||
affected_count = (impact.affected_orders or 0) + len(impact.affected_customers or [])
|
||||
if affected_count >= 10:
|
||||
score += 30
|
||||
elif affected_count >= 5:
|
||||
score += 20
|
||||
elif affected_count >= 2:
|
||||
score += 10
|
||||
elif affected_count >= 1:
|
||||
score += 5
|
||||
|
||||
# Production disruption (0-20 points)
|
||||
batches_at_risk = len(impact.production_batches_at_risk or [])
|
||||
if batches_at_risk >= 5:
|
||||
score += 20
|
||||
elif batches_at_risk >= 3:
|
||||
score += 15
|
||||
elif batches_at_risk >= 1:
|
||||
score += 10
|
||||
|
||||
# Stockout/waste risk (0-10 points)
|
||||
if impact.stockout_risk_hours and impact.stockout_risk_hours <= 24:
|
||||
score += 10
|
||||
elif impact.waste_risk_kg and impact.waste_risk_kg >= 50:
|
||||
score += 10
|
||||
elif impact.waste_risk_kg and impact.waste_risk_kg >= 20:
|
||||
score += 5
|
||||
|
||||
return min(100.0, score)
|
||||
|
||||
def _calculate_urgency_score(
|
||||
self,
|
||||
urgency: Optional[UrgencyContext]
|
||||
) -> float:
|
||||
"""
|
||||
Calculate urgency score (0-100)
|
||||
|
||||
Factors:
|
||||
- Time until consequence
|
||||
- Hard deadline proximity
|
||||
- Peak hour relevance
|
||||
- Auto-action countdown
|
||||
"""
|
||||
if not urgency:
|
||||
return 50.0 # Default mid-range
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Time until consequence (0-50 points)
|
||||
if urgency.time_until_consequence_hours is not None:
|
||||
hours = urgency.time_until_consequence_hours
|
||||
if hours <= 2:
|
||||
score += 50
|
||||
elif hours <= 6:
|
||||
score += 40
|
||||
elif hours <= 12:
|
||||
score += 30
|
||||
elif hours <= 24:
|
||||
score += 20
|
||||
elif hours <= 48:
|
||||
score += 10
|
||||
else:
|
||||
score += 5
|
||||
|
||||
# Hard deadline (0-30 points)
|
||||
if urgency.deadline:
|
||||
now = datetime.now(timezone.utc)
|
||||
hours_until_deadline = (urgency.deadline - now).total_seconds() / 3600
|
||||
if hours_until_deadline <= 2:
|
||||
score += 30
|
||||
elif hours_until_deadline <= 6:
|
||||
score += 20
|
||||
elif hours_until_deadline <= 24:
|
||||
score += 10
|
||||
|
||||
# Peak hour relevance (0-10 points)
|
||||
if urgency.peak_hour_relevant:
|
||||
score += 10
|
||||
|
||||
# Auto-action countdown (0-10 points)
|
||||
if urgency.auto_action_countdown_seconds:
|
||||
if urgency.auto_action_countdown_seconds <= 300: # 5 minutes
|
||||
score += 10
|
||||
elif urgency.auto_action_countdown_seconds <= 900: # 15 minutes
|
||||
score += 5
|
||||
|
||||
return min(100.0, score)
|
||||
|
||||
def _calculate_user_agency_score(
|
||||
self,
|
||||
agency: Optional[UserAgency]
|
||||
) -> float:
|
||||
"""
|
||||
Calculate user agency score (0-100)
|
||||
|
||||
Higher score = user CAN act effectively
|
||||
Lower score = user is blocked or needs external party
|
||||
|
||||
Factors:
|
||||
- Can user fix this?
|
||||
- Requires external party?
|
||||
- Number of blockers
|
||||
- Workaround available?
|
||||
"""
|
||||
if not agency:
|
||||
return 50.0 # Default mid-range
|
||||
|
||||
score = 100.0 # Start high, deduct for blockers
|
||||
|
||||
# Can't fix = major deduction
|
||||
if not agency.can_user_fix:
|
||||
score -= 40
|
||||
|
||||
# Requires external party = moderate deduction
|
||||
if agency.requires_external_party:
|
||||
score -= 20
|
||||
# But if we have contact info, it's easier
|
||||
if agency.external_party_contact:
|
||||
score += 10
|
||||
|
||||
# Blockers reduce score
|
||||
if agency.blockers:
|
||||
blocker_count = len(agency.blockers)
|
||||
score -= min(30, blocker_count * 10)
|
||||
|
||||
# Workaround available = boost
|
||||
if agency.suggested_workaround:
|
||||
score += 15
|
||||
|
||||
return max(0.0, min(100.0, score))
|
||||
|
||||
|
||||
def is_peak_hours(self) -> bool:
|
||||
"""Check if current time is during peak hours"""
|
||||
now = datetime.now()
|
||||
current_hour = now.hour
|
||||
|
||||
morning_peak = (
|
||||
self.config.PEAK_HOURS_START <= current_hour < self.config.PEAK_HOURS_END
|
||||
)
|
||||
evening_peak = (
|
||||
self.config.EVENING_PEAK_START <= current_hour < self.config.EVENING_PEAK_END
|
||||
)
|
||||
|
||||
return morning_peak or evening_peak
|
||||
|
||||
def is_business_hours(self) -> bool:
|
||||
"""Check if current time is during business hours"""
|
||||
now = datetime.now()
|
||||
current_hour = now.hour
|
||||
return (
|
||||
self.config.BUSINESS_HOURS_START <= current_hour < self.config.BUSINESS_HOURS_END
|
||||
)
|
||||
|
||||
def should_send_now(self, priority_score: int) -> bool:
|
||||
"""
|
||||
Determine if alert should be sent immediately or batched
|
||||
|
||||
Rules:
|
||||
- Critical (90+): Always send immediately
|
||||
- Important (70-89): Send immediately during business hours
|
||||
- Standard (50-69): Send if business hours, batch otherwise
|
||||
- Info (<50): Always batch for digest
|
||||
"""
|
||||
if priority_score >= self.config.CRITICAL_THRESHOLD:
|
||||
return True
|
||||
|
||||
if priority_score >= self.config.IMPORTANT_THRESHOLD:
|
||||
return self.is_business_hours()
|
||||
|
||||
if priority_score >= self.config.STANDARD_THRESHOLD:
|
||||
return self.is_business_hours() and not self.is_peak_hours()
|
||||
|
||||
# Low priority - batch for digest
|
||||
return False
|
||||
@@ -1,140 +0,0 @@
|
||||
"""
|
||||
Timing Intelligence Service
|
||||
|
||||
Implements smart timing logic:
|
||||
- Avoid non-critical alerts during peak hours
|
||||
- Batch low-priority alerts for digest
|
||||
- Respect quiet hours
|
||||
- Schedule alerts for optimal user attention
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from datetime import datetime, time as dt_time, timedelta
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
|
||||
from shared.schemas.alert_types import EnrichedAlert, PlacementHint
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class TimingDecision(Enum):
|
||||
"""Decision about when to send alert"""
|
||||
SEND_NOW = "send_now"
|
||||
BATCH_FOR_DIGEST = "batch_for_digest"
|
||||
SCHEDULE_LATER = "schedule_later"
|
||||
HOLD_UNTIL_QUIET = "hold_until_quiet"
|
||||
|
||||
|
||||
class TimingIntelligenceService:
|
||||
"""Intelligent alert timing decisions"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.timing_enabled = config.TIMING_INTELLIGENCE_ENABLED
|
||||
self.batch_low_priority = config.BATCH_LOW_PRIORITY_ALERTS
|
||||
|
||||
def should_send_now(self, alert: EnrichedAlert) -> TimingDecision:
|
||||
"""Determine if alert should be sent now or delayed"""
|
||||
|
||||
if not self.timing_enabled:
|
||||
return TimingDecision.SEND_NOW
|
||||
|
||||
priority = alert.priority_score
|
||||
now = datetime.now()
|
||||
current_hour = now.hour
|
||||
|
||||
# Critical always sends immediately
|
||||
if priority >= 90:
|
||||
return TimingDecision.SEND_NOW
|
||||
|
||||
# During peak hours (7-11am, 5-7pm), only send important+
|
||||
if self._is_peak_hours(now):
|
||||
if priority >= 70:
|
||||
return TimingDecision.SEND_NOW
|
||||
else:
|
||||
return TimingDecision.SCHEDULE_LATER
|
||||
|
||||
# Outside business hours, batch non-important alerts
|
||||
if not self._is_business_hours(now):
|
||||
if priority >= 70:
|
||||
return TimingDecision.SEND_NOW
|
||||
else:
|
||||
return TimingDecision.BATCH_FOR_DIGEST
|
||||
|
||||
# During quiet hours, send important+ immediately
|
||||
if priority >= 70:
|
||||
return TimingDecision.SEND_NOW
|
||||
|
||||
# Standard priority during quiet hours
|
||||
if priority >= 50:
|
||||
return TimingDecision.SEND_NOW
|
||||
|
||||
# Low priority always batched
|
||||
return TimingDecision.BATCH_FOR_DIGEST
|
||||
|
||||
def get_next_quiet_time(self) -> datetime:
|
||||
"""Get next quiet period start time"""
|
||||
now = datetime.now()
|
||||
current_hour = now.hour
|
||||
|
||||
# After evening peak (after 7pm)
|
||||
if current_hour < 19:
|
||||
return now.replace(hour=19, minute=0, second=0, microsecond=0)
|
||||
|
||||
# After lunch (1pm)
|
||||
elif current_hour < 13:
|
||||
return now.replace(hour=13, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Before morning peak (6am next day)
|
||||
else:
|
||||
tomorrow = now + timedelta(days=1)
|
||||
return tomorrow.replace(hour=6, minute=0, second=0, microsecond=0)
|
||||
|
||||
def get_digest_send_time(self) -> datetime:
|
||||
"""Get time for end-of-day digest"""
|
||||
now = datetime.now()
|
||||
digest_time = now.replace(
|
||||
hour=self.config.DIGEST_SEND_TIME_HOUR,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
|
||||
# If already passed today, schedule for tomorrow
|
||||
if digest_time <= now:
|
||||
digest_time += timedelta(days=1)
|
||||
|
||||
return digest_time
|
||||
|
||||
def _is_peak_hours(self, dt: datetime) -> bool:
|
||||
"""Check if time is during peak hours"""
|
||||
hour = dt.hour
|
||||
return (
|
||||
(self.config.PEAK_HOURS_START <= hour < self.config.PEAK_HOURS_END) or
|
||||
(self.config.EVENING_PEAK_START <= hour < self.config.EVENING_PEAK_END)
|
||||
)
|
||||
|
||||
def _is_business_hours(self, dt: datetime) -> bool:
|
||||
"""Check if time is during business hours"""
|
||||
hour = dt.hour
|
||||
return self.config.BUSINESS_HOURS_START <= hour < self.config.BUSINESS_HOURS_END
|
||||
|
||||
def adjust_placement_for_timing(
|
||||
self,
|
||||
alert: EnrichedAlert,
|
||||
decision: TimingDecision
|
||||
) -> List[PlacementHint]:
|
||||
"""Adjust UI placement based on timing decision"""
|
||||
|
||||
if decision == TimingDecision.SEND_NOW:
|
||||
return alert.placement
|
||||
|
||||
if decision == TimingDecision.BATCH_FOR_DIGEST:
|
||||
return [PlacementHint.EMAIL_DIGEST]
|
||||
|
||||
if decision in [TimingDecision.SCHEDULE_LATER, TimingDecision.HOLD_UNTIL_QUIET]:
|
||||
# Remove toast, keep other placements
|
||||
return [p for p in alert.placement if p != PlacementHint.TOAST]
|
||||
|
||||
return alert.placement
|
||||
@@ -1,104 +0,0 @@
|
||||
"""
|
||||
Trend Detection Service
|
||||
Identifies meaningful trends in operational metrics and generates proactive warnings
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
from shared.schemas.alert_types import TrendContext, EnrichedAlert
|
||||
from scipy import stats
|
||||
import numpy as np
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class TrendDetectionService:
|
||||
"""Detects significant trends in metrics"""
|
||||
|
||||
def __init__(self, config, db_manager):
|
||||
self.config = config
|
||||
self.db_manager = db_manager
|
||||
self.enabled = config.TREND_DETECTION_ENABLED
|
||||
self.lookback_days = config.TREND_LOOKBACK_DAYS
|
||||
self.significance_threshold = config.TREND_SIGNIFICANCE_THRESHOLD
|
||||
|
||||
async def detect_waste_trends(self, tenant_id: str) -> Optional[TrendContext]:
|
||||
"""Detect increasing waste trends"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
query = """
|
||||
SELECT date, SUM(waste_kg) as daily_waste
|
||||
FROM waste_tracking
|
||||
WHERE tenant_id = $1 AND date >= $2
|
||||
GROUP BY date
|
||||
ORDER BY date
|
||||
"""
|
||||
cutoff = datetime.utcnow().date() - timedelta(days=self.lookback_days)
|
||||
|
||||
async with self.db_manager.get_session() as session:
|
||||
result = await session.execute(query, [tenant_id, cutoff])
|
||||
data = [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
if len(data) < 3:
|
||||
return None
|
||||
|
||||
values = [d[1] for d in data]
|
||||
baseline = np.mean(values[:3])
|
||||
current = np.mean(values[-3:])
|
||||
change_pct = ((current - baseline) / baseline) * 100 if baseline > 0 else 0
|
||||
|
||||
if abs(change_pct) >= self.significance_threshold * 100:
|
||||
return TrendContext(
|
||||
metric_name="Waste percentage",
|
||||
current_value=current,
|
||||
baseline_value=baseline,
|
||||
change_percentage=change_pct,
|
||||
direction="increasing" if change_pct > 0 else "decreasing",
|
||||
significance="high" if abs(change_pct) > 20 else "medium",
|
||||
period_days=self.lookback_days,
|
||||
possible_causes=["Recipe yield issues", "Over-production", "Quality control"]
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def detect_efficiency_trends(self, tenant_id: str) -> Optional[TrendContext]:
|
||||
"""Detect declining production efficiency"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
query = """
|
||||
SELECT date, AVG(efficiency_percent) as daily_efficiency
|
||||
FROM production_metrics
|
||||
WHERE tenant_id = $1 AND date >= $2
|
||||
GROUP BY date
|
||||
ORDER BY date
|
||||
"""
|
||||
cutoff = datetime.utcnow().date() - timedelta(days=self.lookback_days)
|
||||
|
||||
async with self.db_manager.get_session() as session:
|
||||
result = await session.execute(query, [tenant_id, cutoff])
|
||||
data = [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
if len(data) < 3:
|
||||
return None
|
||||
|
||||
values = [d[1] for d in data]
|
||||
baseline = np.mean(values[:3])
|
||||
current = np.mean(values[-3:])
|
||||
change_pct = ((current - baseline) / baseline) * 100 if baseline > 0 else 0
|
||||
|
||||
if change_pct < -self.significance_threshold * 100:
|
||||
return TrendContext(
|
||||
metric_name="Production efficiency",
|
||||
current_value=current,
|
||||
baseline_value=baseline,
|
||||
change_percentage=change_pct,
|
||||
direction="decreasing",
|
||||
significance="high" if abs(change_pct) > 15 else "medium",
|
||||
period_days=self.lookback_days,
|
||||
possible_causes=["Equipment wear", "Process changes", "Staff training"]
|
||||
)
|
||||
|
||||
return None
|
||||
221
services/alert_processor/app/services/enrichment_orchestrator.py
Normal file
221
services/alert_processor/app/services/enrichment_orchestrator.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Enrichment orchestrator service.
|
||||
|
||||
Coordinates the complete enrichment pipeline for events.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
import structlog
|
||||
from uuid import uuid4
|
||||
|
||||
from shared.schemas.events import MinimalEvent
|
||||
from app.schemas.events import EnrichedEvent, I18nContent, BusinessImpact, Urgency, UserAgency, OrchestratorContext
|
||||
from app.enrichment.message_generator import MessageGenerator
|
||||
from app.enrichment.priority_scorer import PriorityScorer
|
||||
from app.enrichment.orchestrator_client import OrchestratorClient
|
||||
from app.enrichment.smart_actions import SmartActionGenerator
|
||||
from app.enrichment.business_impact import BusinessImpactAnalyzer
|
||||
from app.enrichment.urgency_analyzer import UrgencyAnalyzer
|
||||
from app.enrichment.user_agency import UserAgencyAnalyzer
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class EnrichmentOrchestrator:
|
||||
"""Coordinates the enrichment pipeline for events"""
|
||||
|
||||
def __init__(self):
|
||||
self.message_gen = MessageGenerator()
|
||||
self.priority_scorer = PriorityScorer()
|
||||
self.orchestrator_client = OrchestratorClient()
|
||||
self.action_gen = SmartActionGenerator()
|
||||
self.impact_analyzer = BusinessImpactAnalyzer()
|
||||
self.urgency_analyzer = UrgencyAnalyzer()
|
||||
self.agency_analyzer = UserAgencyAnalyzer()
|
||||
|
||||
async def enrich_event(self, event: MinimalEvent) -> EnrichedEvent:
|
||||
"""
|
||||
Run complete enrichment pipeline.
|
||||
|
||||
Steps:
|
||||
1. Generate i18n message keys and parameters
|
||||
2. Query orchestrator for AI context
|
||||
3. Analyze business impact
|
||||
4. Assess urgency
|
||||
5. Determine user agency
|
||||
6. Calculate priority score (0-100)
|
||||
7. Determine priority level
|
||||
8. Generate smart actions
|
||||
9. Determine type class
|
||||
10. Build enriched event
|
||||
|
||||
Args:
|
||||
event: Minimal event from service
|
||||
|
||||
Returns:
|
||||
Enriched event with all context
|
||||
"""
|
||||
|
||||
logger.info("enrichment_started", event_type=event.event_type, tenant_id=event.tenant_id)
|
||||
|
||||
# 1. Generate i18n message keys and parameters
|
||||
i18n_dict = self.message_gen.generate_message(event.event_type, event.metadata, event.event_class)
|
||||
i18n = I18nContent(**i18n_dict)
|
||||
|
||||
# 2. Query orchestrator for AI context (parallel with other enrichments)
|
||||
orchestrator_context_dict = await self.orchestrator_client.get_context(
|
||||
tenant_id=event.tenant_id,
|
||||
event_type=event.event_type,
|
||||
metadata=event.metadata
|
||||
)
|
||||
|
||||
# Convert to OrchestratorContext if data exists
|
||||
orchestrator_context = None
|
||||
if orchestrator_context_dict:
|
||||
orchestrator_context = OrchestratorContext(**orchestrator_context_dict)
|
||||
|
||||
# 3. Analyze business impact
|
||||
business_impact_dict = self.impact_analyzer.analyze(
|
||||
event_type=event.event_type,
|
||||
metadata=event.metadata
|
||||
)
|
||||
business_impact = BusinessImpact(**business_impact_dict)
|
||||
|
||||
# 4. Assess urgency
|
||||
urgency_dict = self.urgency_analyzer.analyze(
|
||||
event_type=event.event_type,
|
||||
metadata=event.metadata
|
||||
)
|
||||
urgency = Urgency(**urgency_dict)
|
||||
|
||||
# 5. Determine user agency
|
||||
user_agency_dict = self.agency_analyzer.analyze(
|
||||
event_type=event.event_type,
|
||||
metadata=event.metadata,
|
||||
orchestrator_context=orchestrator_context_dict
|
||||
)
|
||||
user_agency = UserAgency(**user_agency_dict)
|
||||
|
||||
# 6. Calculate priority score (0-100)
|
||||
priority_score = self.priority_scorer.calculate_priority(
|
||||
business_impact=business_impact_dict,
|
||||
urgency=urgency_dict,
|
||||
user_agency=user_agency_dict,
|
||||
orchestrator_context=orchestrator_context_dict
|
||||
)
|
||||
|
||||
# 7. Determine priority level
|
||||
priority_level = self._get_priority_level(priority_score)
|
||||
|
||||
# 8. Generate smart actions
|
||||
smart_actions = self.action_gen.generate_actions(
|
||||
event_type=event.event_type,
|
||||
metadata=event.metadata,
|
||||
orchestrator_context=orchestrator_context_dict
|
||||
)
|
||||
|
||||
# 9. Determine type class
|
||||
type_class = self._determine_type_class(orchestrator_context_dict)
|
||||
|
||||
# 10. Extract AI reasoning from metadata (if present)
|
||||
reasoning_data = event.metadata.get('reasoning_data')
|
||||
ai_reasoning_details = None
|
||||
confidence_score = None
|
||||
|
||||
if reasoning_data:
|
||||
# Store the complete reasoning data structure
|
||||
ai_reasoning_details = reasoning_data
|
||||
|
||||
# Extract confidence if available
|
||||
if isinstance(reasoning_data, dict):
|
||||
metadata_section = reasoning_data.get('metadata', {})
|
||||
if isinstance(metadata_section, dict) and 'confidence' in metadata_section:
|
||||
confidence_score = metadata_section.get('confidence')
|
||||
|
||||
# 11. Build enriched event
|
||||
enriched = EnrichedEvent(
|
||||
id=str(uuid4()),
|
||||
tenant_id=event.tenant_id,
|
||||
event_class=event.event_class,
|
||||
event_domain=event.event_domain,
|
||||
event_type=event.event_type,
|
||||
service=event.service,
|
||||
i18n=i18n,
|
||||
priority_score=priority_score,
|
||||
priority_level=priority_level,
|
||||
type_class=type_class,
|
||||
orchestrator_context=orchestrator_context,
|
||||
business_impact=business_impact,
|
||||
urgency=urgency,
|
||||
user_agency=user_agency,
|
||||
smart_actions=smart_actions,
|
||||
ai_reasoning_details=ai_reasoning_details,
|
||||
confidence_score=confidence_score,
|
||||
entity_links=self._extract_entity_links(event.metadata),
|
||||
status="active",
|
||||
event_metadata=event.metadata
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"enrichment_completed",
|
||||
event_type=event.event_type,
|
||||
priority_score=priority_score,
|
||||
priority_level=priority_level,
|
||||
type_class=type_class
|
||||
)
|
||||
|
||||
return enriched
|
||||
|
||||
def _get_priority_level(self, score: int) -> str:
|
||||
"""
|
||||
Convert numeric score to priority level.
|
||||
|
||||
- 90-100: critical
|
||||
- 70-89: important
|
||||
- 50-69: standard
|
||||
- 0-49: info
|
||||
"""
|
||||
if score >= 90:
|
||||
return "critical"
|
||||
elif score >= 70:
|
||||
return "important"
|
||||
elif score >= 50:
|
||||
return "standard"
|
||||
else:
|
||||
return "info"
|
||||
|
||||
def _determine_type_class(self, orchestrator_context: dict) -> str:
|
||||
"""
|
||||
Determine type class based on orchestrator context.
|
||||
|
||||
- prevented_issue: AI already handled it
|
||||
- action_needed: User action required
|
||||
"""
|
||||
if orchestrator_context and orchestrator_context.get("already_addressed"):
|
||||
return "prevented_issue"
|
||||
return "action_needed"
|
||||
|
||||
def _extract_entity_links(self, metadata: dict) -> Dict[str, str]:
|
||||
"""
|
||||
Extract entity references from metadata.
|
||||
|
||||
Maps metadata keys to entity types for frontend deep linking.
|
||||
"""
|
||||
links = {}
|
||||
|
||||
# Map metadata keys to entity types
|
||||
entity_mappings = {
|
||||
"po_id": "purchase_order",
|
||||
"batch_id": "production_batch",
|
||||
"ingredient_id": "ingredient",
|
||||
"order_id": "order",
|
||||
"supplier_id": "supplier",
|
||||
"equipment_id": "equipment",
|
||||
"sensor_id": "sensor"
|
||||
}
|
||||
|
||||
for key, entity_type in entity_mappings.items():
|
||||
if key in metadata:
|
||||
links[entity_type] = str(metadata[key])
|
||||
|
||||
return links
|
||||
@@ -1,228 +0,0 @@
|
||||
"""
|
||||
Redis Publisher Service
|
||||
|
||||
Publishes events to domain-based Redis pub/sub channels for SSE streaming.
|
||||
|
||||
Channel pattern:
|
||||
- tenant:{tenant_id}:inventory.alerts
|
||||
- tenant:{tenant_id}:production.notifications
|
||||
- tenant:{tenant_id}:recommendations (tenant-wide)
|
||||
|
||||
This enables selective subscription and reduces SSE traffic by ~70% per page.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from shared.schemas.event_classification import EventClass, EventDomain, get_redis_channel
|
||||
from services.alert_processor.app.models.events import Alert, Notification, Recommendation
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisPublisher:
|
||||
"""
|
||||
Publishes events to domain-based Redis pub/sub channels.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client):
|
||||
"""Initialize with Redis client"""
|
||||
self.redis = redis_client
|
||||
|
||||
async def publish_event(
|
||||
self,
|
||||
event: Alert | Notification | Recommendation,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Publish event to appropriate domain-based Redis channel.
|
||||
|
||||
Args:
|
||||
event: Enriched event (Alert, Notification, or Recommendation)
|
||||
tenant_id: Tenant identifier
|
||||
|
||||
The channel is determined by event_domain and event_class.
|
||||
"""
|
||||
try:
|
||||
# Convert event to dict
|
||||
event_dict = event.to_dict()
|
||||
|
||||
# Determine channel based on event_class and event_domain
|
||||
event_class = event_dict['event_class']
|
||||
event_domain = event_dict['event_domain']
|
||||
|
||||
# Get domain-based channel
|
||||
if event_class == 'recommendation':
|
||||
# Recommendations go to tenant-wide channel (not domain-specific)
|
||||
channel = f"tenant:{tenant_id}:recommendations"
|
||||
else:
|
||||
# Alerts and notifications use domain-specific channels
|
||||
channel = f"tenant:{tenant_id}:{event_domain}.{event_class}s"
|
||||
|
||||
# Ensure timestamp is serializable
|
||||
if 'timestamp' not in event_dict or not event_dict['timestamp']:
|
||||
event_dict['timestamp'] = event_dict.get('created_at')
|
||||
|
||||
# Publish to domain-based channel
|
||||
await self.redis.publish(channel, json.dumps(event_dict))
|
||||
|
||||
logger.info(
|
||||
f"Event published to Redis channel: {channel}",
|
||||
extra={
|
||||
'event_id': event_dict['id'],
|
||||
'event_class': event_class,
|
||||
'event_domain': event_domain,
|
||||
'event_type': event_dict['event_type'],
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to publish event to Redis: {e}",
|
||||
extra={
|
||||
'event_id': str(event.id),
|
||||
'tenant_id': tenant_id,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
async def cache_active_events(
|
||||
self,
|
||||
tenant_id: str,
|
||||
event_domain: EventDomain,
|
||||
event_class: EventClass,
|
||||
events: list[Dict[str, Any]],
|
||||
ttl_seconds: int = 3600,
|
||||
) -> None:
|
||||
"""
|
||||
Cache active events for initial state loading.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
event_domain: Event domain (inventory, production, etc.)
|
||||
event_class: Event class (alert, notification, recommendation)
|
||||
events: List of event dicts
|
||||
ttl_seconds: Cache TTL in seconds (default 1 hour)
|
||||
"""
|
||||
try:
|
||||
if event_class == EventClass.RECOMMENDATION:
|
||||
# Recommendations: tenant-wide cache
|
||||
cache_key = f"active_events:{tenant_id}:recommendations"
|
||||
else:
|
||||
# Domain-specific cache for alerts and notifications
|
||||
cache_key = f"active_events:{tenant_id}:{event_domain.value}.{event_class.value}s"
|
||||
|
||||
# Store as JSON
|
||||
await self.redis.setex(
|
||||
cache_key,
|
||||
ttl_seconds,
|
||||
json.dumps(events)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Cached active events: {cache_key}",
|
||||
extra={
|
||||
'count': len(events),
|
||||
'ttl_seconds': ttl_seconds,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to cache active events: {e}",
|
||||
extra={
|
||||
'tenant_id': tenant_id,
|
||||
'event_domain': event_domain.value,
|
||||
'event_class': event_class.value,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def get_cached_events(
|
||||
self,
|
||||
tenant_id: str,
|
||||
event_domain: EventDomain,
|
||||
event_class: EventClass,
|
||||
) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Get cached active events for initial state loading.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
event_domain: Event domain
|
||||
event_class: Event class
|
||||
|
||||
Returns:
|
||||
List of cached event dicts
|
||||
"""
|
||||
try:
|
||||
if event_class == EventClass.RECOMMENDATION:
|
||||
cache_key = f"active_events:{tenant_id}:recommendations"
|
||||
else:
|
||||
cache_key = f"active_events:{tenant_id}:{event_domain.value}.{event_class.value}s"
|
||||
|
||||
cached_data = await self.redis.get(cache_key)
|
||||
|
||||
if not cached_data:
|
||||
return []
|
||||
|
||||
return json.loads(cached_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get cached events: {e}",
|
||||
extra={
|
||||
'tenant_id': tenant_id,
|
||||
'event_domain': event_domain.value,
|
||||
'event_class': event_class.value,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
async def invalidate_cache(
|
||||
self,
|
||||
tenant_id: str,
|
||||
event_domain: EventDomain = None,
|
||||
event_class: EventClass = None,
|
||||
) -> None:
|
||||
"""
|
||||
Invalidate cached events.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
event_domain: If provided, invalidate specific domain cache
|
||||
event_class: If provided, invalidate specific class cache
|
||||
"""
|
||||
try:
|
||||
if event_domain and event_class:
|
||||
# Invalidate specific cache
|
||||
if event_class == EventClass.RECOMMENDATION:
|
||||
cache_key = f"active_events:{tenant_id}:recommendations"
|
||||
else:
|
||||
cache_key = f"active_events:{tenant_id}:{event_domain.value}.{event_class.value}s"
|
||||
|
||||
await self.redis.delete(cache_key)
|
||||
logger.debug(f"Invalidated cache: {cache_key}")
|
||||
|
||||
else:
|
||||
# Invalidate all tenant caches
|
||||
pattern = f"active_events:{tenant_id}:*"
|
||||
keys = []
|
||||
async for key in self.redis.scan_iter(match=pattern):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
await self.redis.delete(*keys)
|
||||
logger.debug(f"Invalidated {len(keys)} cache keys for tenant {tenant_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to invalidate cache: {e}",
|
||||
extra={'tenant_id': tenant_id},
|
||||
exc_info=True,
|
||||
)
|
||||
129
services/alert_processor/app/services/sse_service.py
Normal file
129
services/alert_processor/app/services/sse_service.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Server-Sent Events (SSE) service using Redis pub/sub.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
import json
|
||||
import structlog
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.events import Event
|
||||
from shared.redis_utils import get_redis_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SSEService:
|
||||
"""
|
||||
Manage real-time event streaming via Redis pub/sub.
|
||||
|
||||
Pattern: alerts:{tenant_id}
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis = None):
|
||||
self._redis = redis # Use private attribute to allow lazy loading
|
||||
self.prefix = settings.REDIS_SSE_PREFIX
|
||||
|
||||
@property
|
||||
async def redis(self) -> Redis:
|
||||
"""
|
||||
Lazy load Redis client if not provided through dependency injection.
|
||||
Uses the shared Redis utilities for consistency.
|
||||
"""
|
||||
if self._redis is None:
|
||||
self._redis = await get_redis_client()
|
||||
return self._redis
|
||||
|
||||
async def publish_event(self, event: Event) -> bool:
|
||||
"""
|
||||
Publish event to Redis for SSE streaming.
|
||||
|
||||
Args:
|
||||
event: Event to publish
|
||||
|
||||
Returns:
|
||||
True if published successfully
|
||||
"""
|
||||
try:
|
||||
redis_client = await self.redis
|
||||
|
||||
# Build channel name
|
||||
channel = f"{self.prefix}:{event.tenant_id}"
|
||||
|
||||
# Build message payload
|
||||
payload = {
|
||||
"id": str(event.id),
|
||||
"tenant_id": str(event.tenant_id),
|
||||
"event_class": event.event_class,
|
||||
"event_domain": event.event_domain,
|
||||
"event_type": event.event_type,
|
||||
"priority_score": event.priority_score,
|
||||
"priority_level": event.priority_level,
|
||||
"type_class": event.type_class,
|
||||
"status": event.status,
|
||||
"created_at": event.created_at.isoformat(),
|
||||
"i18n": {
|
||||
"title_key": event.i18n_title_key,
|
||||
"title_params": event.i18n_title_params,
|
||||
"message_key": event.i18n_message_key,
|
||||
"message_params": event.i18n_message_params
|
||||
},
|
||||
"smart_actions": event.smart_actions,
|
||||
"entity_links": event.entity_links
|
||||
}
|
||||
|
||||
# Publish to Redis
|
||||
await redis_client.publish(channel, json.dumps(payload))
|
||||
|
||||
logger.debug(
|
||||
"sse_event_published",
|
||||
channel=channel,
|
||||
event_type=event.event_type,
|
||||
event_id=str(event.id)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"sse_publish_failed",
|
||||
error=str(e),
|
||||
event_id=str(event.id)
|
||||
)
|
||||
return False
|
||||
|
||||
async def subscribe_to_tenant(
|
||||
self,
|
||||
tenant_id: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Subscribe to tenant's alert stream.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
|
||||
Yields:
|
||||
JSON-encoded event messages
|
||||
"""
|
||||
redis_client = await self.redis
|
||||
channel = f"{self.prefix}:{tenant_id}"
|
||||
|
||||
logger.info("sse_subscription_started", channel=channel)
|
||||
|
||||
# Subscribe to Redis channel
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
try:
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
yield message["data"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("sse_subscription_error", error=str(e), channel=channel)
|
||||
raise
|
||||
finally:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.close()
|
||||
logger.info("sse_subscription_closed", channel=channel)
|
||||
@@ -1,196 +0,0 @@
|
||||
# services/alert_processor/app/services/tenant_deletion_service.py
|
||||
"""
|
||||
Tenant Data Deletion Service for Alert Processor Service
|
||||
Handles deletion of all alert-related data for a tenant
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import structlog
|
||||
|
||||
from shared.services.tenant_deletion import (
|
||||
BaseTenantDataDeletionService,
|
||||
TenantDataDeletionResult
|
||||
)
|
||||
from app.models import Alert, AuditLog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AlertProcessorTenantDeletionService(BaseTenantDataDeletionService):
|
||||
"""Service for deleting all alert-related data for a tenant"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.service_name = "alert_processor"
|
||||
|
||||
async def get_tenant_data_preview(self, tenant_id: str) -> Dict[str, int]:
|
||||
"""
|
||||
Get counts of what would be deleted for a tenant (dry-run)
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant ID to preview deletion for
|
||||
|
||||
Returns:
|
||||
Dictionary with entity names and their counts
|
||||
"""
|
||||
logger.info("alert_processor.tenant_deletion.preview", tenant_id=tenant_id)
|
||||
preview = {}
|
||||
|
||||
try:
|
||||
# Count alerts (CASCADE will delete alert_interactions)
|
||||
alert_count = await self.db.scalar(
|
||||
select(func.count(Alert.id)).where(
|
||||
Alert.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
preview["alerts"] = alert_count or 0
|
||||
|
||||
# Note: EventInteraction has CASCADE delete, so counting manually
|
||||
# Count alert interactions for informational purposes
|
||||
from app.models.events import EventInteraction
|
||||
interaction_count = await self.db.scalar(
|
||||
select(func.count(EventInteraction.id)).where(
|
||||
EventInteraction.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
preview["alert_interactions"] = interaction_count or 0
|
||||
|
||||
# Count audit logs
|
||||
audit_count = await self.db.scalar(
|
||||
select(func.count(AuditLog.id)).where(
|
||||
AuditLog.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
preview["audit_logs"] = audit_count or 0
|
||||
|
||||
logger.info(
|
||||
"alert_processor.tenant_deletion.preview_complete",
|
||||
tenant_id=tenant_id,
|
||||
preview=preview
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"alert_processor.tenant_deletion.preview_error",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
return preview
|
||||
|
||||
async def delete_tenant_data(self, tenant_id: str) -> TenantDataDeletionResult:
|
||||
"""
|
||||
Permanently delete all alert data for a tenant
|
||||
|
||||
Deletion order (respecting foreign key constraints):
|
||||
1. EventInteraction (child of Alert with CASCADE, but deleted explicitly for tracking)
|
||||
2. Alert (parent table)
|
||||
3. AuditLog (independent)
|
||||
|
||||
Note: EventInteraction has CASCADE delete from Alert, so it will be
|
||||
automatically deleted when Alert is deleted. We delete it explicitly
|
||||
first for proper counting and logging.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant ID to delete data for
|
||||
|
||||
Returns:
|
||||
TenantDataDeletionResult with deletion counts and any errors
|
||||
"""
|
||||
logger.info("alert_processor.tenant_deletion.started", tenant_id=tenant_id)
|
||||
result = TenantDataDeletionResult(tenant_id=tenant_id, service_name=self.service_name)
|
||||
|
||||
try:
|
||||
# Import EventInteraction here to avoid circular imports
|
||||
from app.models.events import EventInteraction
|
||||
|
||||
# Step 1: Delete alert interactions (child of alerts)
|
||||
logger.info("alert_processor.tenant_deletion.deleting_interactions", tenant_id=tenant_id)
|
||||
interactions_result = await self.db.execute(
|
||||
delete(EventInteraction).where(
|
||||
EventInteraction.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
result.deleted_counts["alert_interactions"] = interactions_result.rowcount
|
||||
logger.info(
|
||||
"alert_processor.tenant_deletion.interactions_deleted",
|
||||
tenant_id=tenant_id,
|
||||
count=interactions_result.rowcount
|
||||
)
|
||||
|
||||
# Step 2: Delete alerts
|
||||
logger.info("alert_processor.tenant_deletion.deleting_alerts", tenant_id=tenant_id)
|
||||
alerts_result = await self.db.execute(
|
||||
delete(Alert).where(
|
||||
Alert.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
result.deleted_counts["alerts"] = alerts_result.rowcount
|
||||
logger.info(
|
||||
"alert_processor.tenant_deletion.alerts_deleted",
|
||||
tenant_id=tenant_id,
|
||||
count=alerts_result.rowcount
|
||||
)
|
||||
|
||||
# Step 3: Delete audit logs
|
||||
logger.info("alert_processor.tenant_deletion.deleting_audit_logs", tenant_id=tenant_id)
|
||||
audit_result = await self.db.execute(
|
||||
delete(AuditLog).where(
|
||||
AuditLog.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
result.deleted_counts["audit_logs"] = audit_result.rowcount
|
||||
logger.info(
|
||||
"alert_processor.tenant_deletion.audit_logs_deleted",
|
||||
tenant_id=tenant_id,
|
||||
count=audit_result.rowcount
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
await self.db.commit()
|
||||
|
||||
# Calculate total deleted
|
||||
total_deleted = sum(result.deleted_counts.values())
|
||||
|
||||
logger.info(
|
||||
"alert_processor.tenant_deletion.completed",
|
||||
tenant_id=tenant_id,
|
||||
total_deleted=total_deleted,
|
||||
breakdown=result.deleted_counts
|
||||
)
|
||||
|
||||
result.success = True
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
error_msg = f"Failed to delete alert data for tenant {tenant_id}: {str(e)}"
|
||||
logger.error(
|
||||
"alert_processor.tenant_deletion.failed",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
result.errors.append(error_msg)
|
||||
result.success = False
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_alert_processor_tenant_deletion_service(
|
||||
db: AsyncSession
|
||||
) -> AlertProcessorTenantDeletionService:
|
||||
"""
|
||||
Factory function to create AlertProcessorTenantDeletionService instance
|
||||
|
||||
Args:
|
||||
db: AsyncSession database session
|
||||
|
||||
Returns:
|
||||
AlertProcessorTenantDeletionService instance
|
||||
"""
|
||||
return AlertProcessorTenantDeletionService(db)
|
||||
Reference in New Issue
Block a user