392 lines
17 KiB
Python
392 lines
17 KiB
Python
"""
|
|
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
|