New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -1,6 +0,0 @@
# services/alert_processor/app/services/__init__.py
"""
Alert Processor Services Package
"""
__all__ = []

View File

@@ -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',
]

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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,
)

View 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)

View File

@@ -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)