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

1062 lines
42 KiB
Python

"""
Context Enrichment Service
Enriches raw alerts with contextual information from:
- Daily Orchestrator (AI actions taken)
- Inventory Service (stock levels, trends)
- Production Service (batch status, capacity)
- Historical alert data
INCLUDES alert chaining and deduplication to prevent duplicate alerts
"""
import structlog
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List
from uuid import UUID
import httpx
from sqlalchemy import select, update
from shared.schemas.alert_types import (
RawAlert, EnrichedAlert,
OrchestratorContext, BusinessImpact, UrgencyContext, UserAgency,
SmartAction, SmartActionType, PlacementHint, TrendContext
)
from .priority_scoring import PriorityScoringService
from shared.alerts.context_templates import generate_contextual_message
from app.models.events import Alert
logger = structlog.get_logger()
class ContextEnrichmentService:
"""Enriches alerts with contextual intelligence"""
def __init__(self, config, db_manager, redis_client):
self.config = config
self.db_manager = db_manager
self.redis = redis_client
self.priority_service = PriorityScoringService(config)
self.http_client = httpx.AsyncClient(
timeout=config.ENRICHMENT_TIMEOUT_SECONDS,
follow_redirects=True
)
async def enrich_alert(self, raw_alert: RawAlert) -> EnrichedAlert:
"""
Main enrichment pipeline
Steps:
1. Fetch orchestrator context
2. Calculate business impact
3. Assess urgency
4. Determine user agency
5. Calculate priority score
6. Generate smart actions
7. Determine UI placement
8. Classify alert type
"""
logger.info(
"Enriching alert",
alert_type=raw_alert.alert_type,
tenant_id=raw_alert.tenant_id
)
# Step 1: Get orchestrator context
orchestrator_context = await self._fetch_orchestrator_context(raw_alert)
# Step 2: Calculate business impact
business_impact = await self._calculate_business_impact(
raw_alert,
orchestrator_context
)
# Step 2.5: Add estimated_savings_eur to orchestrator_context if it's a prevented issue
if orchestrator_context and orchestrator_context.already_addressed:
# Calculate estimated savings from business impact
financial_impact = business_impact.financial_impact_eur if business_impact else 0
# Create a new orchestrator context with estimated savings
orchestrator_context = OrchestratorContext(
already_addressed=orchestrator_context.already_addressed,
action_type=orchestrator_context.action_type,
action_id=orchestrator_context.action_id,
action_status=orchestrator_context.action_status,
delivery_date=orchestrator_context.delivery_date,
reasoning=orchestrator_context.reasoning,
estimated_resolution_time=orchestrator_context.estimated_resolution_time,
estimated_savings_eur=financial_impact # Add savings
)
# Step 3: Assess urgency
urgency_context = self._assess_urgency(raw_alert, orchestrator_context)
# Step 4: Determine user agency
user_agency = self._assess_user_agency(raw_alert, orchestrator_context)
# Step 5: Get trend context if applicable
trend_context = await self._get_trend_context(raw_alert)
# Step 6: Calculate priority score
priority_components = self.priority_service.calculate_priority_score(
business_impact=business_impact,
urgency_context=urgency_context,
user_agency=user_agency,
confidence_score=raw_alert.alert_metadata.get("confidence_score")
)
priority_level = self.priority_service.get_priority_level(
priority_components.final_score
)
# Step 7: Classify alert type
type_class = self._classify_alert_type(
raw_alert,
orchestrator_context,
trend_context,
priority_level
)
# Step 8: Generate smart actions
actions = self._generate_smart_actions(
raw_alert,
orchestrator_context,
user_agency,
type_class
)
# Step 9: Determine UI placement
placement = self._determine_placement(
priority_components.final_score,
type_class,
orchestrator_context
)
# Step 10: Generate AI reasoning summary
ai_reasoning = self._generate_reasoning_summary(
raw_alert,
orchestrator_context,
business_impact
)
# Add reasoning parameters to alert metadata for i18n use
updated_alert_metadata = dict(raw_alert.alert_metadata)
if 'reasoning_data' in raw_alert.alert_metadata:
reasoning_data = raw_alert.alert_metadata['reasoning_data']
if reasoning_data and 'parameters' in reasoning_data:
# Add reasoning parameters to metadata to make them available for i18n
updated_alert_metadata['reasoning_parameters'] = reasoning_data['parameters']
# Also ensure full reasoning data is preserved
updated_alert_metadata['full_reasoning_data'] = reasoning_data
# Use message_params from raw alert if available, otherwise extract from reasoning_data
message_params = raw_alert.alert_metadata.get('message_params', {})
if not message_params and raw_alert.alert_metadata.get('reasoning_data'):
reasoning_data = raw_alert.alert_metadata['reasoning_data']
if reasoning_data and 'parameters' in reasoning_data:
message_params = reasoning_data['parameters']
# Build enriched alert (temporarily with raw title/message)
enriched_temp = EnrichedAlert(
id=raw_alert.alert_metadata.get("id", "temp-id"),
tenant_id=raw_alert.tenant_id,
service=raw_alert.service,
alert_type=raw_alert.alert_type,
title=raw_alert.title, # Temporary, will be regenerated
message=raw_alert.message, # Temporary, will be regenerated
type_class=type_class,
priority_level=priority_level,
priority_score=priority_components.final_score,
orchestrator_context=orchestrator_context,
business_impact=business_impact,
urgency_context=urgency_context,
user_agency=user_agency,
trend_context=trend_context,
ai_reasoning_summary=ai_reasoning,
reasoning_data=raw_alert.alert_metadata.get("reasoning_data") or (orchestrator_context.reasoning if orchestrator_context else None),
confidence_score=raw_alert.alert_metadata.get("confidence_score", 0.8),
actions=actions,
primary_action=actions[0] if actions else None,
placement=placement,
created_at=datetime.utcnow(),
enriched_at=datetime.utcnow(),
alert_metadata=updated_alert_metadata,
status="active"
)
# Step 11: Generate contextual message with enrichment data
message_data = self._generate_contextual_message(enriched_temp)
# Update enriched alert with generated message and preserve message_params
enriched = EnrichedAlert(
id=enriched_temp.id,
tenant_id=enriched_temp.tenant_id,
service=enriched_temp.service,
alert_type=enriched_temp.alert_type,
title=message_data.get('fallback_title', raw_alert.title),
message=message_data.get('fallback_message', raw_alert.message),
type_class=enriched_temp.type_class,
priority_level=enriched_temp.priority_level,
priority_score=enriched_temp.priority_score,
orchestrator_context=enriched_temp.orchestrator_context,
business_impact=enriched_temp.business_impact,
urgency_context=enriched_temp.urgency_context,
user_agency=enriched_temp.user_agency,
trend_context=enriched_temp.trend_context,
ai_reasoning_summary=enriched_temp.ai_reasoning_summary,
reasoning_data=enriched_temp.reasoning_data or raw_alert.alert_metadata.get("reasoning_data", None),
confidence_score=enriched_temp.confidence_score,
actions=enriched_temp.actions,
primary_action=enriched_temp.primary_action,
placement=enriched_temp.placement,
created_at=enriched_temp.created_at,
enriched_at=enriched_temp.enriched_at,
alert_metadata={
**enriched_temp.alert_metadata,
'message_params': message_params, # Preserve the structured message parameters for i18n ICU format
'i18n': {
'title_key': message_data.get('title_key'),
'title_params': message_data.get('title_params'),
'message_key': message_data.get('message_key'),
'message_params': message_params # Use the structured message_params for ICU format
}
},
status=enriched_temp.status
)
logger.info(
"Alert enriched successfully with contextual message",
alert_id=enriched.id,
priority_score=enriched.priority_score,
type_class=enriched.type_class.value,
message_key=message_data.get('message_key')
)
return enriched
async def _fetch_orchestrator_context(
self,
alert: RawAlert
) -> Optional[OrchestratorContext]:
"""
Query Daily Orchestrator for recent actions related to this alert
Example: If alert is "Low Stock: Flour", check if orchestrator
already created a PO for flour in the last 24 hours.
"""
# Special case: PO approval alerts with reasoning_data are orchestrator-created
if alert.alert_type == "po_approval_needed" and alert.alert_metadata.get("reasoning_data"):
reasoning_data = alert.alert_metadata.get("reasoning_data")
return OrchestratorContext(
already_addressed=True,
action_type="purchase_order",
action_id=alert.alert_metadata.get("po_id"),
action_status="pending_approval",
reasoning=reasoning_data
)
cache_key = f"orch_context:{alert.tenant_id}:{alert.alert_type}"
# Check cache first
cached = await self.redis.get(cache_key)
if cached:
import json
return OrchestratorContext(**json.loads(cached))
try:
# Extract relevant identifiers from alert metadata
ingredient_id = alert.alert_metadata.get("ingredient_id")
product_id = alert.alert_metadata.get("product_id")
# Query orchestrator service
response = await self.http_client.get(
f"{self.config.ORCHESTRATOR_SERVICE_URL}/api/internal/recent-actions",
params={
"tenant_id": alert.tenant_id,
"ingredient_id": ingredient_id,
"product_id": product_id,
"hours_ago": 24
},
headers={"X-Internal-Service": "alert-intelligence"}
)
if response.status_code == 200:
data = response.json()
if data.get("actions"):
action = data["actions"][0] # Most recent
context = OrchestratorContext(
already_addressed=True,
action_type=action.get("type"),
action_id=action.get("id"),
action_status=action.get("status"),
delivery_date=action.get("delivery_date"),
reasoning=action.get("reasoning"),
estimated_resolution_time=action.get("estimated_resolution")
)
# Cache for 5 minutes
import json
await self.redis.set(
cache_key,
json.dumps(context.dict()),
ex=self.config.ORCHESTRATOR_CONTEXT_CACHE_TTL
)
return context
except Exception as e:
logger.warning(
"Failed to fetch orchestrator context",
error=str(e),
alert_type=alert.alert_type
)
# No actions found or error occurred
return OrchestratorContext(already_addressed=False)
async def _calculate_business_impact(
self,
alert: RawAlert,
orch_context: Optional[OrchestratorContext]
) -> BusinessImpact:
"""Calculate business impact based on alert type and metadata"""
metadata = alert.alert_metadata
impact = BusinessImpact()
# Extract impact data from alert metadata
impact.financial_impact_eur = metadata.get("financial_impact")
impact.affected_orders = metadata.get("affected_orders")
impact.production_batches_at_risk = metadata.get("affected_batches", [])
impact.stockout_risk_hours = metadata.get("hours_until_stockout")
impact.waste_risk_kg = metadata.get("waste_risk_kg")
# Get affected customers if available
if metadata.get("affected_customers"):
impact.affected_customers = metadata["affected_customers"]
# Estimate financial impact if not provided
if not impact.financial_impact_eur:
impact.financial_impact_eur = self._estimate_financial_impact(alert)
# If orchestrator already addressed, reduce impact
if orch_context and orch_context.already_addressed:
if impact.financial_impact_eur:
impact.financial_impact_eur *= 0.3 # 70% reduction
impact.customer_satisfaction_impact = "low"
else:
# Determine customer satisfaction impact
if impact.stockout_risk_hours and impact.stockout_risk_hours <= 6:
impact.customer_satisfaction_impact = "high"
elif len(impact.affected_customers or []) > 5:
impact.customer_satisfaction_impact = "high"
elif len(impact.production_batches_at_risk) > 2:
impact.customer_satisfaction_impact = "medium"
else:
impact.customer_satisfaction_impact = "low"
return impact
def _estimate_financial_impact(self, alert: RawAlert) -> float:
"""Estimate financial impact based on alert type"""
impact_map = {
"critical_stock_shortage": 200.0,
"low_stock_warning": 100.0,
"overstock_warning": 50.0,
"expired_products": 150.0,
"production_delay": 180.0,
"equipment_failure": 300.0,
"quality_control_failure": 250.0,
"capacity_overload": 120.0
}
return impact_map.get(alert.alert_type, 50.0)
def _assess_urgency(
self,
alert: RawAlert,
orch_context: Optional[OrchestratorContext]
) -> UrgencyContext:
"""Assess urgency and timing context"""
metadata = alert.alert_metadata
urgency = UrgencyContext()
# Extract time-based data
hours_until = metadata.get("hours_until_stockout") or metadata.get("hours_until_consequence")
if hours_until:
urgency.time_until_consequence_hours = hours_until
urgency.can_wait_until_tomorrow = hours_until > 12
# Check for hard deadlines
if metadata.get("deadline"):
urgency.deadline = datetime.fromisoformat(metadata["deadline"])
# Peak hour relevance
if alert.alert_type in ["production_delay", "quality_control_failure"]:
urgency.peak_hour_relevant = True
# If orchestrator addressed it, urgency is lower
if orch_context and orch_context.already_addressed:
if urgency.time_until_consequence_hours:
urgency.time_until_consequence_hours *= 2 # Double the time
urgency.can_wait_until_tomorrow = True
return urgency
def _assess_user_agency(
self,
alert: RawAlert,
orch_context: Optional[OrchestratorContext]
) -> UserAgency:
"""Assess user's ability to act on this alert"""
metadata = alert.alert_metadata
agency = UserAgency(can_user_fix=True)
# If orchestrator already handled it, user just needs to approve
if orch_context and orch_context.already_addressed:
if orch_context.action_status == "pending_approval":
agency.can_user_fix = True
agency.requires_external_party = False
else:
# AI fully handled it
agency.can_user_fix = False
# Check if requires external party
if metadata.get("supplier_name"):
agency.requires_external_party = True
agency.external_party_name = metadata["supplier_name"]
agency.external_party_contact = metadata.get("supplier_phone")
if metadata.get("customer_name"):
agency.requires_external_party = True
agency.external_party_name = metadata["customer_name"]
agency.external_party_contact = metadata.get("customer_phone")
# Blockers
blockers = []
if metadata.get("outside_business_hours"):
blockers.append("Supplier closed until business hours")
if metadata.get("equipment_required"):
blockers.append(f"Requires {metadata['equipment_required']}")
if blockers:
agency.blockers = blockers
# Workarounds
if alert.alert_type == "critical_stock_shortage":
agency.suggested_workaround = "Consider alternative supplier or adjust production schedule"
return agency
async def _get_trend_context(self, alert: RawAlert) -> Optional[TrendContext]:
"""Get trend analysis if this is a trend warning"""
if not alert.alert_metadata.get("is_trend"):
return None
metadata = alert.alert_metadata
return TrendContext(
metric_name=metadata.get("metric_name", "Unknown metric"),
current_value=metadata.get("current_value", 0),
baseline_value=metadata.get("baseline_value", 0),
change_percentage=metadata.get("change_percentage", 0),
direction=metadata.get("direction", "unknown"),
significance=metadata.get("significance", "medium"),
period_days=metadata.get("period_days", 7),
possible_causes=metadata.get("possible_causes", [])
)
def _classify_alert_type(
self,
alert: RawAlert,
orch_context: Optional[OrchestratorContext],
trend_context: Optional[TrendContext],
priority_level: Optional[str] = None
) -> str:
"""Classify alert into high-level type"""
# SPECIAL CASE: PO approvals must ALWAYS be action_needed
# Even if orchestrator created the PO (already_addressed=true),
# it still requires user approval, so it should remain action_needed
if alert.alert_type == "po_approval_needed":
return "action_needed"
# SPECIAL CASE: Production batch start alerts must ALWAYS be action_needed
# These require user action to physically start the batch
if alert.alert_type == "production_batch_start":
return "action_needed"
# Prevented issue: AI already handled it
if orch_context and orch_context.already_addressed:
if orch_context.action_status in ["completed", "in_progress"]:
return "prevented_issue"
# Trend warning: analytical insight
if trend_context:
return "trend_warning"
# Escalation: has auto-action countdown
if alert.alert_metadata.get("auto_action_countdown"):
return "escalation"
# Action needed: requires user decision
if alert.item_type == "alert" and priority_level and priority_level in ["critical", "important"]:
return "action_needed"
# Information: everything else
return "information"
def _generate_smart_actions(
self,
alert: RawAlert,
orch_context: Optional[OrchestratorContext],
user_agency: UserAgency,
type_class: str
) -> List[SmartAction]:
"""Generate context-aware smart action buttons"""
actions = []
metadata = alert.alert_metadata
# Prevented Issue: View details action
if type_class == "prevented_issue":
actions.append(SmartAction(
label="See what I did",
type=SmartActionType.OPEN_REASONING,
variant="secondary",
metadata={"action_id": orch_context.action_id} if orch_context else {}
))
return actions
# PO Approval actions from orchestrator
if orch_context and orch_context.action_type == "purchase_order":
if orch_context.action_status == "pending_approval":
actions.append(SmartAction(
label=f"Approve €{metadata.get('po_amount', '???')} order",
type=SmartActionType.APPROVE_PO,
variant="primary",
metadata={"po_id": orch_context.action_id},
estimated_time_minutes=2
))
actions.append(SmartAction(
label="See full reasoning",
type=SmartActionType.OPEN_REASONING,
variant="secondary",
metadata={"po_id": orch_context.action_id}
))
actions.append(SmartAction(
label="Reject",
type=SmartActionType.REJECT_PO,
variant="tertiary",
metadata={"po_id": orch_context.action_id}
))
# Direct PO approval alerts (not from orchestrator)
if alert.alert_type == "po_approval_needed" and not (orch_context and orch_context.action_type == "purchase_order"):
po_id = metadata.get("po_id")
total_amount = metadata.get("total_amount", 0)
currency = metadata.get("currency", "EUR")
actions.append(SmartAction(
label=f"Approve {currency} {total_amount:.2f} order",
type=SmartActionType.APPROVE_PO,
variant="primary",
metadata={"po_id": po_id, "po_number": metadata.get("po_number")},
estimated_time_minutes=2,
consequence=f"Purchase order will be sent to {metadata.get('supplier_name', 'supplier')}"
))
actions.append(SmartAction(
label="View PO details",
type=SmartActionType.NAVIGATE,
variant="secondary",
metadata={"path": f"/procurement/purchase-orders/{po_id}"}
))
actions.append(SmartAction(
label="Reject",
type=SmartActionType.REJECT_PO,
variant="danger",
metadata={"po_id": po_id}
))
# Supplier contact action
if user_agency.requires_external_party and user_agency.external_party_contact:
phone = user_agency.external_party_contact
actions.append(SmartAction(
label=f"Call {user_agency.external_party_name} ({phone})",
type=SmartActionType.CALL_SUPPLIER,
variant="primary",
metadata={"phone": phone, "name": user_agency.external_party_name}
))
# Production adjustment
if alert.alert_type == "production_delay":
actions.append(SmartAction(
label="Adjust production schedule",
type=SmartActionType.ADJUST_PRODUCTION,
variant="primary",
metadata={"batch_id": metadata.get("batch_id")},
estimated_time_minutes=10
))
# Customer notification
if metadata.get("customer_name"):
actions.append(SmartAction(
label=f"Notify {metadata['customer_name']}",
type=SmartActionType.NOTIFY_CUSTOMER,
variant="secondary",
metadata={"customer_name": metadata["customer_name"]}
))
# Navigation actions
if metadata.get("navigate_to"):
actions.append(SmartAction(
label=metadata.get("navigate_label", "View details"),
type=SmartActionType.NAVIGATE,
variant="secondary",
metadata={"path": metadata["navigate_to"]}
))
# Cancel auto-action (for escalations)
if type_class == "escalation":
actions.append(SmartAction(
label="Cancel auto-action",
type=SmartActionType.CANCEL_AUTO_ACTION,
variant="danger",
metadata={"alert_id": alert.alert_metadata.get("id")}
))
# Always add snooze and dismiss
actions.append(SmartAction(
label="Snooze 2 hours",
type=SmartActionType.SNOOZE,
variant="tertiary",
metadata={"duration_hours": 2}
))
logger.info(
"Generated smart actions",
alert_type=alert.alert_type,
action_count=len(actions),
action_types=[a.type.value for a in actions]
)
return actions
def _determine_placement(
self,
priority_score: int,
type_class: str,
orch_context: Optional[OrchestratorContext]
) -> List[PlacementHint]:
"""Determine where alert should appear in UI"""
placement = [PlacementHint.NOTIFICATION_PANEL] # Always in panel
# Critical: Toast + Action Queue
if priority_score >= 90:
placement.append(PlacementHint.TOAST)
placement.append(PlacementHint.ACTION_QUEUE)
# Important: Toast (business hours) + Action Queue
elif priority_score >= 70:
if self.priority_service.is_business_hours():
placement.append(PlacementHint.TOAST)
placement.append(PlacementHint.ACTION_QUEUE)
# Standard: Action Queue only
elif priority_score >= 50:
placement.append(PlacementHint.ACTION_QUEUE)
# Info: Email digest
else:
placement.append(PlacementHint.EMAIL_DIGEST)
# Prevented issues: Show in orchestration summary section
if type_class == "prevented_issue":
placement = [PlacementHint.DASHBOARD_INLINE, PlacementHint.NOTIFICATION_PANEL]
return placement
def _generate_reasoning_summary(
self,
alert: RawAlert,
orch_context: Optional[OrchestratorContext],
impact: BusinessImpact
) -> str:
"""Generate plain language AI reasoning summary"""
# First, try to extract reasoning from alert metadata if it exists
alert_metadata = alert.alert_metadata
if alert_metadata and 'reasoning_data' in alert_metadata:
reasoning_data = alert_metadata['reasoning_data']
if reasoning_data:
# Extract reasoning from structured reasoning_data
reasoning_type = reasoning_data.get('type', 'unknown')
parameters = reasoning_data.get('parameters', {})
consequence = reasoning_data.get('consequence', {})
# Build a meaningful summary from the structured data
if reasoning_type == 'low_stock_detection':
threshold = parameters.get('threshold_percentage', 20)
current_stock = parameters.get('current_stock', 0)
required_stock = parameters.get('required_stock', 0)
days_until_stockout = parameters.get('days_until_stockout', 0)
product_names = parameters.get('product_names', ['items'])
summary = f"Low stock detected: {', '.join(product_names[:3])}{'...' if len(product_names) > 3 else ''}. "
summary += f"Current: {current_stock}kg, Required: {required_stock}kg. "
summary += f"Stockout expected in {days_until_stockout} days. "
# Add consequence information if available
if consequence:
severity = consequence.get('severity', 'medium')
affected_products = consequence.get('affected_products', [])
if affected_products:
summary += f"Would affect {len(affected_products)} products. "
return summary.strip()
elif reasoning_type == 'forecast_demand':
forecast_period = parameters.get('forecast_period_days', 7)
total_demand = parameters.get('total_demand', 0)
product_names = parameters.get('product_names', ['items'])
confidence = parameters.get('forecast_confidence', 0)
summary = f"Based on forecast demand of {total_demand} units over {forecast_period} days "
summary += f"for {', '.join(product_names[:3])}{'...' if len(product_names) > 3 else ''}. "
summary += f"Confidence: {confidence*100:.0f}%. "
return summary.strip()
elif reasoning_type in ['safety_stock_replenishment', 'supplier_contract', 'seasonal_demand', 'production_requirement']:
# Generic extraction for other reasoning types
product_names = parameters.get('product_names', ['items'])
supplier_name = parameters.get('supplier_name', 'supplier')
summary = f"Reasoning: {reasoning_type.replace('_', ' ').title()}. "
summary += f"Products: {', '.join(product_names[:3])}{'...' if len(product_names) > 3 else ''}. "
if reasoning_type == 'production_requirement':
required_by_date = parameters.get('required_by_date', 'date')
summary += f"Required by: {required_by_date}. "
elif reasoning_type == 'supplier_contract':
contract_terms = parameters.get('contract_terms', 'terms')
summary += f"Contract: {contract_terms}. "
elif reasoning_type == 'seasonal_demand':
season = parameters.get('season', 'period')
expected_increase = parameters.get('expected_demand_increase_pct', 0)
summary += f"Season: {season}, expected increase: {expected_increase}%. "
return summary.strip()
# Fallback to orchestrator context if no structured reasoning data
if not orch_context or not orch_context.already_addressed:
return f"Alert triggered due to {alert.alert_type.replace('_', ' ')}."
reasoning_parts = []
if orch_context.reasoning:
reasoning_type = orch_context.reasoning.get("type")
params = orch_context.reasoning.get("parameters", {})
if reasoning_type == "low_stock_detection":
days = params.get("min_depletion_days", 0)
reasoning_parts.append(
f"I detected stock running out in {days:.1f} days, so I created a purchase order."
)
if orch_context.delivery_date:
reasoning_parts.append(
f"Delivery scheduled for {orch_context.delivery_date.strftime('%A, %B %d')}."
)
if impact.financial_impact_eur:
reasoning_parts.append(
f"This prevents an estimated €{impact.financial_impact_eur:.0f} loss."
)
return " ".join(reasoning_parts) if reasoning_parts else "AI system took preventive action."
def _generate_contextual_message(self, enriched: EnrichedAlert) -> Dict[str, Any]:
"""
Generate contextual message with enrichment data
Uses context-aware template functions to create intelligent messages
that leverage orchestrator context, business impact, urgency, and user agency.
Returns dict with:
- title_key: i18n key for title
- title_params: parameters for title
- message_key: i18n key for message
- message_params: parameters for message
- fallback_title: fallback Spanish title
- fallback_message: fallback Spanish message
"""
try:
message_data = generate_contextual_message(enriched)
return message_data
except Exception as e:
logger.error(
"Error generating contextual message, using raw alert data",
alert_type=enriched.alert_type,
error=str(e)
)
# Fallback to raw alert title/message if generation fails
return {
'title_key': f'alerts.{enriched.alert_type}.title',
'title_params': {},
'message_key': f'alerts.{enriched.alert_type}.message',
'message_params': {},
'fallback_title': enriched.title,
'fallback_message': enriched.message
}
async def mark_alert_as_superseded(
self,
original_alert_id: UUID,
superseding_action_id: UUID,
tenant_id: UUID
) -> bool:
"""
Mark an alert as superseded by an orchestrator action.
Used when the orchestrator takes an action that addresses an alert,
allowing us to hide the original alert and show a combined
"prevented_issue" alert instead.
Args:
original_alert_id: UUID of the alert being superseded
superseding_action_id: UUID of the action that addresses the alert
tenant_id: Tenant UUID for security
Returns:
True if alert was marked, False otherwise
"""
try:
async with self.db_manager.get_session() as session:
# Update the original alert
stmt = (
update(Alert)
.where(
Alert.id == original_alert_id,
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE
)
.values(
superseded_by_action_id=superseding_action_id,
hidden_from_ui=True,
updated_at=datetime.utcnow()
)
)
result = await session.execute(stmt)
await session.commit()
if result.rowcount > 0:
logger.info(
"Alert marked as superseded",
alert_id=str(original_alert_id),
action_id=str(superseding_action_id),
tenant_id=str(tenant_id)
)
# Invalidate cache
cache_key = f"alert:{tenant_id}:{original_alert_id}"
await self.redis.delete(cache_key)
return True
else:
logger.warning(
"Alert not found or already superseded",
alert_id=str(original_alert_id)
)
return False
except Exception as e:
logger.error(
"Failed to mark alert as superseded",
alert_id=str(original_alert_id),
error=str(e)
)
return False
async def create_combined_alert(
self,
original_alert: RawAlert,
orchestrator_action: Dict[str, Any],
tenant_id: UUID
) -> Optional[EnrichedAlert]:
"""
Create a combined "prevented_issue" alert that shows both the problem
and the solution the orchestrator took.
This replaces showing separate alerts for:
1. "Low stock detected" (original alert)
2. "PO created for you" (separate orchestrator notification)
Instead shows single alert:
"I detected low stock and created PO-12345 for you. Please approve €150."
Args:
original_alert: The original problem alert
orchestrator_action: Dict with action details (type, id, status, etc.)
tenant_id: Tenant UUID
Returns:
Combined enriched alert or None on error
"""
try:
# Create orchestrator context from action
orch_context = OrchestratorContext(
already_addressed=True,
action_type=orchestrator_action.get("type"),
action_id=orchestrator_action.get("id"),
action_status=orchestrator_action.get("status", "pending_approval"),
delivery_date=orchestrator_action.get("delivery_date"),
reasoning=orchestrator_action.get("reasoning"),
estimated_resolution_time=orchestrator_action.get("estimated_resolution")
)
# Update raw alert metadata with action info
combined_metadata = {
**original_alert.alert_metadata,
"original_alert_type": original_alert.alert_type,
"combined_alert": True,
"action_id": orchestrator_action.get("id"),
"action_type": orchestrator_action.get("type"),
"po_amount": orchestrator_action.get("amount"),
}
# Create modified raw alert
combined_raw = RawAlert(
tenant_id=original_alert.tenant_id,
alert_type="ai_prevented_issue_" + original_alert.alert_type,
title=f"AI addressed: {original_alert.title}",
message=f"The system detected an issue and took action. {original_alert.message}",
service=original_alert.service,
alert_metadata=combined_metadata,
item_type="recommendation"
)
# Enrich with orchestrator context
enriched = await self.enrich_alert(combined_raw)
# Override type_class to prevented_issue
enriched.type_class = "prevented_issue"
# Update placement to dashboard inline
enriched.placement = [PlacementHint.DASHBOARD_INLINE, PlacementHint.NOTIFICATION_PANEL]
logger.info(
"Created combined prevented_issue alert",
original_alert_type=original_alert.alert_type,
action_type=orchestrator_action.get("type"),
tenant_id=str(tenant_id)
)
return enriched
except Exception as e:
logger.error(
"Failed to create combined alert",
original_alert_type=original_alert.alert_type if original_alert else None,
error=str(e)
)
return None
async def find_related_alert(
self,
tenant_id: UUID,
alert_type: str,
ingredient_id: Optional[UUID] = None,
product_id: Optional[UUID] = None,
hours_ago: int = 24
) -> Optional[Alert]:
"""
Find a recent related alert that might be superseded by a new action.
Used by the orchestrator when it takes an action to find and mark
the original problem alert as superseded.
Args:
tenant_id: Tenant UUID
alert_type: Type of alert to find
ingredient_id: Optional ingredient filter
product_id: Optional product filter
hours_ago: How far back to look (default 24 hours)
Returns:
Alert model or None if not found
"""
try:
async with self.db_manager.get_session() as session:
# Build query
stmt = select(Alert).where(
Alert.tenant_id == tenant_id,
Alert.alert_type == alert_type,
Alert.status == AlertStatus.ACTIVE,
Alert.hidden_from_ui == False,
Alert.created_at >= datetime.utcnow() - timedelta(hours=hours_ago)
)
# Add filters if provided
if ingredient_id:
stmt = stmt.where(
Alert.alert_metadata['ingredient_id'].astext == str(ingredient_id)
)
if product_id:
stmt = stmt.where(
Alert.alert_metadata['product_id'].astext == str(product_id)
)
# Get most recent
stmt = stmt.order_by(Alert.created_at.desc()).limit(1)
result = await session.execute(stmt)
alert = result.scalar_one_or_none()
if alert:
logger.info(
"Found related alert",
alert_id=str(alert.id),
alert_type=alert_type,
tenant_id=str(tenant_id)
)
return alert
except Exception as e:
logger.error(
"Error finding related alert",
alert_type=alert_type,
error=str(e)
)
return None
def should_hide_from_ui(self, alert: Alert) -> bool:
"""
Determine if an alert should be hidden from UI based on deduplication rules.
This is used by API queries to filter out superseded alerts.
Args:
alert: Alert model
Returns:
True if alert should be hidden, False if should be shown
"""
# Explicit hidden flag
if alert.hidden_from_ui:
return True
# Superseded by action
if alert.superseded_by_action_id:
return True
# Resolved or dismissed
if alert.status in ['resolved', 'dismissed']:
return True
return False