1062 lines
42 KiB
Python
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
|