New alert system and panel de control page
This commit is contained in:
@@ -277,9 +277,34 @@ class BaseAlertService:
|
||||
|
||||
# Publishing (Updated for type)
|
||||
async def publish_item(self, tenant_id: UUID, item: Dict[str, Any], item_type: str = 'alert'):
|
||||
"""Publish alert or recommendation to RabbitMQ with deduplication"""
|
||||
"""Publish alert or recommendation to RabbitMQ with deduplication and validation"""
|
||||
|
||||
try:
|
||||
# Validate alert structure before publishing
|
||||
from shared.schemas.alert_types import RawAlert
|
||||
try:
|
||||
raw_alert = RawAlert(
|
||||
tenant_id=str(tenant_id),
|
||||
alert_type=item.get('type'),
|
||||
title=item.get('title'),
|
||||
message=item.get('message'),
|
||||
service=self.config.SERVICE_NAME,
|
||||
actions=item.get('actions', []),
|
||||
alert_metadata=item.get('metadata', {}),
|
||||
item_type=item_type
|
||||
)
|
||||
# Validation passed, continue with validated data
|
||||
logger.debug("Alert schema validation passed",
|
||||
service=self.config.SERVICE_NAME,
|
||||
alert_type=item.get('type'))
|
||||
except Exception as validation_error:
|
||||
logger.error("Alert schema validation failed",
|
||||
service=self.config.SERVICE_NAME,
|
||||
alert_type=item.get('type'),
|
||||
error=str(validation_error))
|
||||
self._errors_count += 1
|
||||
return False
|
||||
|
||||
# Generate proper deduplication key based on alert type and specific identifiers
|
||||
unique_id = self._generate_unique_identifier(item)
|
||||
item_key = f"{tenant_id}:{item_type}:{item['type']}:{unique_id}"
|
||||
@@ -350,6 +375,7 @@ class BaseAlertService:
|
||||
metadata = item.get('metadata', {})
|
||||
|
||||
# Generate unique identifier based on alert type
|
||||
# Inventory alerts
|
||||
if alert_type == 'overstock_warning':
|
||||
return metadata.get('ingredient_id', '')
|
||||
elif alert_type == 'critical_stock_shortage' or alert_type == 'low_stock_warning':
|
||||
@@ -377,6 +403,47 @@ class BaseAlertService:
|
||||
return f"opt:{metadata.get('ingredient_id', '')}:{metadata.get('recommendation_type', '')}"
|
||||
elif alert_type == 'waste_reduction':
|
||||
return f"waste:{metadata.get('ingredient_id', '')}"
|
||||
|
||||
# Procurement alerts
|
||||
elif alert_type == 'procurement_pos_pending_approval':
|
||||
# Use hash of PO IDs for grouped alerts
|
||||
pos = metadata.get('pos', [])
|
||||
if pos:
|
||||
po_ids = sorted([str(po.get('po_id', '')) for po in pos])
|
||||
import hashlib
|
||||
return hashlib.md5(':'.join(po_ids).encode()).hexdigest()[:16]
|
||||
return ''
|
||||
elif alert_type == 'procurement_approval_reminder':
|
||||
return metadata.get('po_id', '')
|
||||
elif alert_type == 'procurement_critical_po':
|
||||
return metadata.get('po_id', '')
|
||||
elif alert_type == 'procurement_po_approved':
|
||||
return metadata.get('po_id', '')
|
||||
elif alert_type == 'procurement_auto_approval_summary':
|
||||
# Daily summary - use date as identifier
|
||||
summary_date = metadata.get('summary_date', '')[:10] # Date only
|
||||
return f"summary:{summary_date}"
|
||||
|
||||
# Production alerts
|
||||
elif alert_type in ['severe_capacity_overload', 'capacity_overload', 'near_capacity']:
|
||||
return f"capacity:{metadata.get('planned_date', '')}"
|
||||
elif alert_type == 'production_delay':
|
||||
return metadata.get('batch_id', '')
|
||||
elif alert_type == 'quality_control_failure':
|
||||
return metadata.get('quality_check_id', '')
|
||||
elif alert_type in ['equipment_failure', 'maintenance_required', 'low_equipment_efficiency']:
|
||||
return metadata.get('equipment_id', '')
|
||||
elif alert_type == 'production_ingredient_shortage':
|
||||
return metadata.get('ingredient_id', '')
|
||||
|
||||
# Forecasting alerts
|
||||
elif alert_type in ['demand_surge_weekend', 'holiday_preparation', 'demand_spike_detected', 'unexpected_demand_spike']:
|
||||
return f"{alert_type}:{metadata.get('product_name', '')}:{metadata.get('forecast_date', '')}"
|
||||
elif alert_type == 'weather_impact_alert':
|
||||
return f"weather:{metadata.get('forecast_date', '')}"
|
||||
elif alert_type == 'severe_weather_impact':
|
||||
return f"severe_weather:{metadata.get('weather_type', '')}:{metadata.get('duration_hours', '')}"
|
||||
|
||||
else:
|
||||
# Fallback to generic metadata.id or empty string
|
||||
return metadata.get('id', '')
|
||||
@@ -485,25 +552,20 @@ class BaseAlertService:
|
||||
|
||||
class AlertServiceMixin:
|
||||
"""Mixin providing common alert helper methods"""
|
||||
|
||||
def format_spanish_message(self, template_key: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Format Spanish alert message"""
|
||||
from shared.alerts.templates import format_item_message
|
||||
return format_item_message(template_key, 'es', **kwargs)
|
||||
|
||||
|
||||
def get_business_hours_severity(self, base_severity: str) -> str:
|
||||
"""Adjust severity based on business hours"""
|
||||
current_hour = datetime.now().hour
|
||||
|
||||
|
||||
# Reduce non-critical severity outside business hours (7-20)
|
||||
if not (7 <= current_hour <= 20):
|
||||
if base_severity == 'medium':
|
||||
return 'low'
|
||||
elif base_severity == 'high' and current_hour < 6 or current_hour > 22:
|
||||
return 'medium'
|
||||
|
||||
|
||||
return base_severity
|
||||
|
||||
|
||||
def should_send_recommendation(self, tenant_id: UUID, rec_type: str) -> bool:
|
||||
"""Check if recommendation should be sent based on tenant preferences"""
|
||||
# Implement tenant-specific recommendation frequency limits
|
||||
|
||||
948
shared/alerts/context_templates.py
Normal file
948
shared/alerts/context_templates.py
Normal file
@@ -0,0 +1,948 @@
|
||||
"""
|
||||
Context-Aware Alert Message Templates with i18n Support
|
||||
|
||||
This module generates parametrized alert messages that can be translated by the frontend.
|
||||
Instead of hardcoded Spanish messages, we generate structured message keys and parameters.
|
||||
|
||||
Messages are generated AFTER enrichment, leveraging:
|
||||
- Orchestrator context (AI actions already taken)
|
||||
- Business impact (financial, customers affected)
|
||||
- Urgency context (hours until consequence, actual dates)
|
||||
- User agency (supplier contacts, external dependencies)
|
||||
|
||||
Frontend uses i18n to translate message keys with parameters.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, List
|
||||
from shared.schemas.alert_types import (
|
||||
EnrichedAlert, OrchestratorContext, BusinessImpact,
|
||||
UrgencyContext, UserAgency, TrendContext, SmartAction
|
||||
)
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
def format_date_spanish(dt: datetime) -> str:
|
||||
"""Format datetime in Spanish (for backwards compatibility)"""
|
||||
days = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
||||
months = ["enero", "febrero", "marzo", "abril", "mayo", "junio",
|
||||
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
|
||||
|
||||
day_name = days[dt.weekday()]
|
||||
month_name = months[dt.month - 1]
|
||||
|
||||
return f"{day_name} {dt.day} de {month_name}"
|
||||
|
||||
|
||||
def format_iso_date(dt: datetime) -> str:
|
||||
"""Format datetime as ISO date for frontend i18n"""
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
def get_production_date(metadata: Dict[str, Any], default_days: int = 1) -> datetime:
|
||||
"""Get actual production date from metadata or estimate"""
|
||||
if metadata.get('production_date'):
|
||||
if isinstance(metadata['production_date'], str):
|
||||
return datetime.fromisoformat(metadata['production_date'])
|
||||
return metadata['production_date']
|
||||
else:
|
||||
return datetime.now() + timedelta(days=default_days)
|
||||
|
||||
|
||||
class ContextualMessageGenerator:
|
||||
"""Generates context-aware parametrized messages for i18n"""
|
||||
|
||||
@staticmethod
|
||||
def generate_message_data(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate contextual message data with i18n support
|
||||
|
||||
Returns dict with:
|
||||
- title_key: i18n key for title
|
||||
- title_params: parameters for title translation
|
||||
- message_key: i18n key for message
|
||||
- message_params: parameters for message translation
|
||||
- fallback_title: fallback if i18n not available
|
||||
- fallback_message: fallback if i18n not available
|
||||
"""
|
||||
alert_type = enriched.alert_type
|
||||
|
||||
# Dispatch to specific generator based on alert type
|
||||
generators = {
|
||||
# Inventory alerts
|
||||
'critical_stock_shortage': ContextualMessageGenerator._stock_shortage,
|
||||
'low_stock_warning': ContextualMessageGenerator._low_stock,
|
||||
'stock_depleted_by_order': ContextualMessageGenerator._stock_depleted,
|
||||
'production_ingredient_shortage': ContextualMessageGenerator._ingredient_shortage,
|
||||
'expired_products': ContextualMessageGenerator._expired_products,
|
||||
|
||||
# Production alerts
|
||||
'production_delay': ContextualMessageGenerator._production_delay,
|
||||
'equipment_failure': ContextualMessageGenerator._equipment_failure,
|
||||
'maintenance_required': ContextualMessageGenerator._maintenance_required,
|
||||
'low_equipment_efficiency': ContextualMessageGenerator._low_efficiency,
|
||||
'order_overload': ContextualMessageGenerator._order_overload,
|
||||
|
||||
# Supplier alerts
|
||||
'supplier_delay': ContextualMessageGenerator._supplier_delay,
|
||||
|
||||
# Procurement alerts
|
||||
'po_approval_needed': ContextualMessageGenerator._po_approval_needed,
|
||||
'production_batch_start': ContextualMessageGenerator._production_batch_start,
|
||||
|
||||
# Environmental alerts
|
||||
'temperature_breach': ContextualMessageGenerator._temperature_breach,
|
||||
|
||||
# Forecasting alerts
|
||||
'demand_surge_weekend': ContextualMessageGenerator._demand_surge,
|
||||
'weather_impact_alert': ContextualMessageGenerator._weather_impact,
|
||||
'holiday_preparation': ContextualMessageGenerator._holiday_prep,
|
||||
'severe_weather_impact': ContextualMessageGenerator._severe_weather,
|
||||
'unexpected_demand_spike': ContextualMessageGenerator._demand_spike,
|
||||
'demand_pattern_optimization': ContextualMessageGenerator._demand_pattern,
|
||||
|
||||
# Recommendations
|
||||
'inventory_optimization': ContextualMessageGenerator._inventory_optimization,
|
||||
'production_efficiency': ContextualMessageGenerator._production_efficiency,
|
||||
'sales_opportunity': ContextualMessageGenerator._sales_opportunity,
|
||||
'seasonal_adjustment': ContextualMessageGenerator._seasonal_adjustment,
|
||||
'cost_reduction': ContextualMessageGenerator._cost_reduction,
|
||||
'waste_reduction': ContextualMessageGenerator._waste_reduction,
|
||||
'quality_improvement': ContextualMessageGenerator._quality_improvement,
|
||||
'customer_satisfaction': ContextualMessageGenerator._customer_satisfaction,
|
||||
'energy_optimization': ContextualMessageGenerator._energy_optimization,
|
||||
'staff_optimization': ContextualMessageGenerator._staff_optimization,
|
||||
}
|
||||
|
||||
generator_func = generators.get(alert_type)
|
||||
if generator_func:
|
||||
return generator_func(enriched)
|
||||
else:
|
||||
# Fallback for unknown alert types
|
||||
return {
|
||||
'title_key': f'alerts.{alert_type}.title',
|
||||
'title_params': {},
|
||||
'message_key': f'alerts.{alert_type}.message',
|
||||
'message_params': {'alert_type': alert_type},
|
||||
'fallback_title': f"Alerta: {alert_type}",
|
||||
'fallback_message': f"Se detectó una situación que requiere atención: {alert_type}"
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# INVENTORY ALERTS
|
||||
# ===================================================================
|
||||
|
||||
@staticmethod
|
||||
def _stock_shortage(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Critical stock shortage with AI context"""
|
||||
metadata = enriched.alert_metadata
|
||||
orch = enriched.orchestrator_context
|
||||
urgency = enriched.urgency_context
|
||||
agency = enriched.user_agency
|
||||
|
||||
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
|
||||
current_stock = round(metadata.get('current_stock', 0), 1)
|
||||
required_stock = round(metadata.get('required_stock', metadata.get('tomorrow_needed', 0)), 1)
|
||||
|
||||
# Base parameters
|
||||
params = {
|
||||
'ingredient_name': ingredient_name,
|
||||
'current_stock': current_stock,
|
||||
'required_stock': required_stock
|
||||
}
|
||||
|
||||
# Determine message variant based on context
|
||||
if orch and orch.already_addressed and orch.action_type == "purchase_order":
|
||||
# AI already created PO
|
||||
params['po_id'] = orch.action_id
|
||||
params['po_amount'] = metadata.get('po_amount', 0)
|
||||
|
||||
if orch.delivery_date:
|
||||
params['delivery_date'] = format_iso_date(orch.delivery_date)
|
||||
params['delivery_day_name'] = format_date_spanish(orch.delivery_date)
|
||||
|
||||
if orch.action_status == "pending_approval":
|
||||
message_key = 'alerts.critical_stock_shortage.message_with_po_pending'
|
||||
else:
|
||||
message_key = 'alerts.critical_stock_shortage.message_with_po_created'
|
||||
|
||||
elif urgency and urgency.time_until_consequence_hours:
|
||||
# Time-specific message
|
||||
hours = urgency.time_until_consequence_hours
|
||||
params['hours_until'] = round(hours, 1)
|
||||
message_key = 'alerts.critical_stock_shortage.message_with_hours'
|
||||
|
||||
elif metadata.get('production_date'):
|
||||
# Date-specific message
|
||||
prod_date = get_production_date(metadata)
|
||||
params['production_date'] = format_iso_date(prod_date)
|
||||
params['production_day_name'] = format_date_spanish(prod_date)
|
||||
message_key = 'alerts.critical_stock_shortage.message_with_date'
|
||||
|
||||
else:
|
||||
# Generic message
|
||||
message_key = 'alerts.critical_stock_shortage.message_generic'
|
||||
|
||||
# Add supplier contact if available
|
||||
if agency and agency.requires_external_party and agency.external_party_contact:
|
||||
params['supplier_name'] = agency.external_party_name
|
||||
params['supplier_contact'] = agency.external_party_contact
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.critical_stock_shortage.title',
|
||||
'title_params': {'ingredient_name': ingredient_name},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': f"🚨 Stock Crítico: {ingredient_name}",
|
||||
'fallback_message': f"Solo {current_stock}kg de {ingredient_name} disponibles (necesitas {required_stock}kg)."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _low_stock(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Low stock warning"""
|
||||
metadata = enriched.alert_metadata
|
||||
orch = enriched.orchestrator_context
|
||||
|
||||
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
|
||||
current_stock = round(metadata.get('current_stock', 0), 1)
|
||||
minimum_stock = round(metadata.get('minimum_stock', 0), 1)
|
||||
|
||||
params = {
|
||||
'ingredient_name': ingredient_name,
|
||||
'current_stock': current_stock,
|
||||
'minimum_stock': minimum_stock
|
||||
}
|
||||
|
||||
if orch and orch.already_addressed and orch.action_type == "purchase_order":
|
||||
params['po_id'] = orch.action_id
|
||||
message_key = 'alerts.low_stock.message_with_po'
|
||||
else:
|
||||
message_key = 'alerts.low_stock.message_generic'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.low_stock.title',
|
||||
'title_params': {'ingredient_name': ingredient_name},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': f"⚠️ Stock Bajo: {ingredient_name}",
|
||||
'fallback_message': f"Stock de {ingredient_name}: {current_stock}kg (mínimo: {minimum_stock}kg)."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stock_depleted(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Stock depleted by order"""
|
||||
metadata = enriched.alert_metadata
|
||||
agency = enriched.user_agency
|
||||
|
||||
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
|
||||
order_id = metadata.get('order_id', '???')
|
||||
current_stock = round(metadata.get('current_stock', 0), 1)
|
||||
minimum_stock = round(metadata.get('minimum_stock', 0), 1)
|
||||
|
||||
params = {
|
||||
'ingredient_name': ingredient_name,
|
||||
'order_id': order_id,
|
||||
'current_stock': current_stock,
|
||||
'minimum_stock': minimum_stock
|
||||
}
|
||||
|
||||
if agency and agency.requires_external_party and agency.external_party_contact:
|
||||
params['supplier_name'] = agency.external_party_name
|
||||
params['supplier_contact'] = agency.external_party_contact
|
||||
message_key = 'alerts.stock_depleted.message_with_supplier'
|
||||
else:
|
||||
message_key = 'alerts.stock_depleted.message_generic'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.stock_depleted.title',
|
||||
'title_params': {'ingredient_name': ingredient_name},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': f"⚠️ Stock Agotado por Pedido: {ingredient_name}",
|
||||
'fallback_message': f"El pedido #{order_id} agotaría el stock de {ingredient_name}."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _ingredient_shortage(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Production ingredient shortage"""
|
||||
metadata = enriched.alert_metadata
|
||||
impact = enriched.business_impact
|
||||
|
||||
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
|
||||
shortage_amount = round(metadata.get('shortage_amount', 0), 1)
|
||||
affected_batches = metadata.get('affected_batches_count', 0)
|
||||
|
||||
params = {
|
||||
'ingredient_name': ingredient_name,
|
||||
'shortage_amount': shortage_amount,
|
||||
'affected_batches': affected_batches
|
||||
}
|
||||
|
||||
if impact and impact.affected_customers:
|
||||
params['customer_count'] = len(impact.affected_customers)
|
||||
params['customer_names'] = ', '.join(impact.affected_customers[:2])
|
||||
if len(impact.affected_customers) > 2:
|
||||
params['additional_customers'] = len(impact.affected_customers) - 2
|
||||
message_key = 'alerts.ingredient_shortage.message_with_customers'
|
||||
else:
|
||||
message_key = 'alerts.ingredient_shortage.message_generic'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.ingredient_shortage.title',
|
||||
'title_params': {'ingredient_name': ingredient_name},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': f"🚨 Escasez en Producción: {ingredient_name}",
|
||||
'fallback_message': f"Faltan {shortage_amount}kg de {ingredient_name}."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _expired_products(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Expired products alert"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
product_count = metadata.get('product_count', 0)
|
||||
expired_items = metadata.get('expired_items', [])
|
||||
|
||||
params = {
|
||||
'product_count': product_count
|
||||
}
|
||||
|
||||
if len(expired_items) > 0:
|
||||
params['product_names'] = ', '.join([item.get('name', 'Producto') for item in expired_items[:2]])
|
||||
if len(expired_items) > 2:
|
||||
params['additional_count'] = len(expired_items) - 2
|
||||
message_key = 'alerts.expired_products.message_with_names'
|
||||
else:
|
||||
message_key = 'alerts.expired_products.message_generic'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.expired_products.title',
|
||||
'title_params': {},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': "📅 Productos Caducados",
|
||||
'fallback_message': f"{product_count} producto(s) caducado(s). Retirar inmediatamente."
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# PRODUCTION ALERTS
|
||||
# ===================================================================
|
||||
|
||||
@staticmethod
|
||||
def _production_delay(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Production delay with affected orders"""
|
||||
metadata = enriched.alert_metadata
|
||||
impact = enriched.business_impact
|
||||
|
||||
batch_name = metadata.get('batch_name', 'Lote')
|
||||
delay_minutes = metadata.get('delay_minutes', 0)
|
||||
|
||||
params = {
|
||||
'batch_name': batch_name,
|
||||
'delay_minutes': delay_minutes
|
||||
}
|
||||
|
||||
if impact and impact.affected_customers:
|
||||
params['customer_names'] = ', '.join(impact.affected_customers[:2])
|
||||
if len(impact.affected_customers) > 2:
|
||||
params['additional_count'] = len(impact.affected_customers) - 2
|
||||
message_key = 'alerts.production_delay.message_with_customers'
|
||||
elif impact and impact.affected_orders:
|
||||
params['affected_orders'] = impact.affected_orders
|
||||
message_key = 'alerts.production_delay.message_with_orders'
|
||||
else:
|
||||
message_key = 'alerts.production_delay.message_generic'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.production_delay.title',
|
||||
'title_params': {},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': "⏰ Retraso en Producción",
|
||||
'fallback_message': f"Lote {batch_name} con {delay_minutes} minutos de retraso."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _equipment_failure(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Equipment failure"""
|
||||
metadata = enriched.alert_metadata
|
||||
impact = enriched.business_impact
|
||||
|
||||
equipment_name = metadata.get('equipment_name', 'Equipo')
|
||||
|
||||
params = {
|
||||
'equipment_name': equipment_name
|
||||
}
|
||||
|
||||
if impact and impact.production_batches_at_risk:
|
||||
params['batch_count'] = len(impact.production_batches_at_risk)
|
||||
message_key = 'alerts.equipment_failure.message_with_batches'
|
||||
else:
|
||||
message_key = 'alerts.equipment_failure.message_generic'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.equipment_failure.title',
|
||||
'title_params': {'equipment_name': equipment_name},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': f"⚙️ Fallo de Equipo: {equipment_name}",
|
||||
'fallback_message': f"{equipment_name} no está funcionando correctamente."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _maintenance_required(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Maintenance required"""
|
||||
metadata = enriched.alert_metadata
|
||||
urgency = enriched.urgency_context
|
||||
|
||||
equipment_name = metadata.get('equipment_name', 'Equipo')
|
||||
|
||||
params = {
|
||||
'equipment_name': equipment_name
|
||||
}
|
||||
|
||||
if urgency and urgency.time_until_consequence_hours:
|
||||
params['hours_until'] = round(urgency.time_until_consequence_hours, 1)
|
||||
message_key = 'alerts.maintenance_required.message_with_hours'
|
||||
else:
|
||||
params['days_until'] = metadata.get('days_until_maintenance', 0)
|
||||
message_key = 'alerts.maintenance_required.message_with_days'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.maintenance_required.title',
|
||||
'title_params': {'equipment_name': equipment_name},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': f"🔧 Mantenimiento Requerido: {equipment_name}",
|
||||
'fallback_message': f"Equipo {equipment_name} requiere mantenimiento."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _low_efficiency(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Low equipment efficiency"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
equipment_name = metadata.get('equipment_name', 'Equipo')
|
||||
efficiency = round(metadata.get('efficiency_percent', 0), 1)
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.low_efficiency.title',
|
||||
'title_params': {'equipment_name': equipment_name},
|
||||
'message_key': 'alerts.low_efficiency.message',
|
||||
'message_params': {
|
||||
'equipment_name': equipment_name,
|
||||
'efficiency_percent': efficiency
|
||||
},
|
||||
'fallback_title': f"📉 Baja Eficiencia: {equipment_name}",
|
||||
'fallback_message': f"Eficiencia del {equipment_name} bajó a {efficiency}%."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _order_overload(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Order capacity overload"""
|
||||
metadata = enriched.alert_metadata
|
||||
impact = enriched.business_impact
|
||||
|
||||
percentage = round(metadata.get('percentage', 0), 1)
|
||||
|
||||
params = {
|
||||
'percentage': percentage
|
||||
}
|
||||
|
||||
if impact and impact.affected_orders:
|
||||
params['affected_orders'] = impact.affected_orders
|
||||
message_key = 'alerts.order_overload.message_with_orders'
|
||||
else:
|
||||
message_key = 'alerts.order_overload.message_generic'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.order_overload.title',
|
||||
'title_params': {},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': "📋 Sobrecarga de Pedidos",
|
||||
'fallback_message': f"Capacidad excedida en {percentage}%."
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# SUPPLIER ALERTS
|
||||
# ===================================================================
|
||||
|
||||
@staticmethod
|
||||
def _supplier_delay(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Supplier delivery delay"""
|
||||
metadata = enriched.alert_metadata
|
||||
impact = enriched.business_impact
|
||||
agency = enriched.user_agency
|
||||
|
||||
supplier_name = metadata.get('supplier_name', 'Proveedor')
|
||||
hours = round(metadata.get('hours', metadata.get('delay_hours', 0)), 0)
|
||||
products = metadata.get('products', metadata.get('affected_products', ''))
|
||||
|
||||
params = {
|
||||
'supplier_name': supplier_name,
|
||||
'hours': hours,
|
||||
'products': products
|
||||
}
|
||||
|
||||
if impact and impact.production_batches_at_risk:
|
||||
params['batch_count'] = len(impact.production_batches_at_risk)
|
||||
|
||||
if agency and agency.external_party_contact:
|
||||
params['supplier_contact'] = agency.external_party_contact
|
||||
|
||||
message_key = 'alerts.supplier_delay.message'
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.supplier_delay.title',
|
||||
'title_params': {'supplier_name': supplier_name},
|
||||
'message_key': message_key,
|
||||
'message_params': params,
|
||||
'fallback_title': f"🚚 Retraso de Proveedor: {supplier_name}",
|
||||
'fallback_message': f"Entrega de {supplier_name} retrasada {hours} hora(s)."
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# PROCUREMENT ALERTS
|
||||
# ===================================================================
|
||||
|
||||
@staticmethod
|
||||
def _po_approval_needed(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Purchase order approval needed"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
po_number = metadata.get('po_number', 'PO-XXXX')
|
||||
supplier_name = metadata.get('supplier_name', 'Proveedor')
|
||||
total_amount = metadata.get('total_amount', 0)
|
||||
currency = metadata.get('currency', '€')
|
||||
required_delivery_date = metadata.get('required_delivery_date')
|
||||
|
||||
# Format required delivery date for i18n
|
||||
required_delivery_date_iso = None
|
||||
if required_delivery_date:
|
||||
if isinstance(required_delivery_date, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(required_delivery_date.replace('Z', '+00:00'))
|
||||
required_delivery_date_iso = format_iso_date(dt)
|
||||
except:
|
||||
required_delivery_date_iso = required_delivery_date
|
||||
elif isinstance(required_delivery_date, datetime):
|
||||
required_delivery_date_iso = format_iso_date(required_delivery_date)
|
||||
|
||||
params = {
|
||||
'po_number': po_number,
|
||||
'supplier_name': supplier_name,
|
||||
'total_amount': round(total_amount, 2),
|
||||
'currency': currency,
|
||||
'required_delivery_date': required_delivery_date_iso or 'fecha no especificada'
|
||||
}
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.po_approval_needed.title',
|
||||
'title_params': {'po_number': po_number},
|
||||
'message_key': 'alerts.po_approval_needed.message',
|
||||
'message_params': params
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _production_batch_start(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Production batch ready to start"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
batch_number = metadata.get('batch_number', 'BATCH-XXXX')
|
||||
product_name = metadata.get('product_name', 'Producto')
|
||||
quantity_planned = metadata.get('quantity_planned', 0)
|
||||
unit = metadata.get('unit', 'kg')
|
||||
priority = metadata.get('priority', 'normal')
|
||||
|
||||
params = {
|
||||
'batch_number': batch_number,
|
||||
'product_name': product_name,
|
||||
'quantity_planned': round(quantity_planned, 1),
|
||||
'unit': unit,
|
||||
'priority': priority
|
||||
}
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.production_batch_start.title',
|
||||
'title_params': {'product_name': product_name},
|
||||
'message_key': 'alerts.production_batch_start.message',
|
||||
'message_params': params
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# ENVIRONMENTAL ALERTS
|
||||
# ===================================================================
|
||||
|
||||
@staticmethod
|
||||
def _temperature_breach(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Temperature breach alert"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
location = metadata.get('location', 'Ubicación')
|
||||
temperature = round(metadata.get('temperature', 0), 1)
|
||||
duration = metadata.get('duration', 0)
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.temperature_breach.title',
|
||||
'title_params': {'location': location},
|
||||
'message_key': 'alerts.temperature_breach.message',
|
||||
'message_params': {
|
||||
'location': location,
|
||||
'temperature': temperature,
|
||||
'duration': duration
|
||||
}
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# FORECASTING ALERTS
|
||||
# ===================================================================
|
||||
|
||||
@staticmethod
|
||||
def _demand_surge(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Weekend demand surge"""
|
||||
metadata = enriched.alert_metadata
|
||||
urgency = enriched.urgency_context
|
||||
|
||||
product_name = metadata.get('product_name', 'Producto')
|
||||
percentage = round(metadata.get('percentage', metadata.get('growth_percentage', 0)), 0)
|
||||
predicted_demand = metadata.get('predicted_demand', 0)
|
||||
current_stock = metadata.get('current_stock', 0)
|
||||
|
||||
params = {
|
||||
'product_name': product_name,
|
||||
'percentage': percentage
|
||||
}
|
||||
|
||||
if predicted_demand and current_stock:
|
||||
params['predicted_demand'] = round(predicted_demand, 0)
|
||||
params['current_stock'] = round(current_stock, 0)
|
||||
|
||||
if urgency and urgency.time_until_consequence_hours:
|
||||
params['hours_until'] = round(urgency.time_until_consequence_hours, 1)
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.demand_surge.title',
|
||||
'title_params': {'product_name': product_name},
|
||||
'message_key': 'alerts.demand_surge.message',
|
||||
'message_params': params
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _weather_impact(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Weather impact on demand"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
weather_type = metadata.get('weather_type', 'Lluvia')
|
||||
impact_percentage = round(metadata.get('impact_percentage', -20), 0)
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.weather_impact.title',
|
||||
'title_params': {},
|
||||
'message_key': 'alerts.weather_impact.message',
|
||||
'message_params': {
|
||||
'weather_type': weather_type,
|
||||
'impact_percentage': abs(impact_percentage),
|
||||
'is_negative': impact_percentage < 0
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _holiday_prep(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Holiday preparation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
holiday_name = metadata.get('holiday_name', 'Festividad')
|
||||
days = metadata.get('days', 0)
|
||||
percentage = round(metadata.get('percentage', metadata.get('increase_percentage', 0)), 0)
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.holiday_prep.title',
|
||||
'title_params': {'holiday_name': holiday_name},
|
||||
'message_key': 'alerts.holiday_prep.message',
|
||||
'message_params': {
|
||||
'holiday_name': holiday_name,
|
||||
'days': days,
|
||||
'percentage': percentage
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _severe_weather(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Severe weather impact"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
weather_type = metadata.get('weather_type', 'Tormenta')
|
||||
duration_hours = metadata.get('duration_hours', 0)
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.severe_weather.title',
|
||||
'title_params': {'weather_type': weather_type},
|
||||
'message_key': 'alerts.severe_weather.message',
|
||||
'message_params': {
|
||||
'weather_type': weather_type,
|
||||
'duration_hours': duration_hours
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _demand_spike(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Unexpected demand spike"""
|
||||
metadata = enriched.alert_metadata
|
||||
trend = enriched.trend_context
|
||||
|
||||
product_name = metadata.get('product_name', 'Producto')
|
||||
spike_percentage = round(metadata.get('spike_percentage', metadata.get('growth_percentage', 0)), 0)
|
||||
|
||||
params = {
|
||||
'product_name': product_name,
|
||||
'spike_percentage': spike_percentage
|
||||
}
|
||||
|
||||
if trend:
|
||||
params['current_value'] = round(trend.current_value, 0)
|
||||
params['baseline_value'] = round(trend.baseline_value, 0)
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.demand_spike.title',
|
||||
'title_params': {'product_name': product_name},
|
||||
'message_key': 'alerts.demand_spike.message',
|
||||
'message_params': params
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _demand_pattern(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Demand pattern optimization"""
|
||||
metadata = enriched.alert_metadata
|
||||
trend = enriched.trend_context
|
||||
|
||||
product_name = metadata.get('product_name', 'Producto')
|
||||
variation = round(metadata.get('variation_percent', 0), 0)
|
||||
|
||||
params = {
|
||||
'product_name': product_name,
|
||||
'variation_percent': variation
|
||||
}
|
||||
|
||||
if trend and trend.possible_causes:
|
||||
params['possible_causes'] = ', '.join(trend.possible_causes[:2])
|
||||
|
||||
return {
|
||||
'title_key': 'alerts.demand_pattern.title',
|
||||
'title_params': {'product_name': product_name},
|
||||
'message_key': 'alerts.demand_pattern.message',
|
||||
'message_params': params
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# RECOMMENDATIONS
|
||||
# ===================================================================
|
||||
|
||||
@staticmethod
|
||||
def _inventory_optimization(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Inventory optimization recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
|
||||
period = metadata.get('period', 7)
|
||||
suggested_increase = round(metadata.get('suggested_increase', 0), 1)
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.inventory_optimization.title',
|
||||
'title_params': {'ingredient_name': ingredient_name},
|
||||
'message_key': 'recommendations.inventory_optimization.message',
|
||||
'message_params': {
|
||||
'ingredient_name': ingredient_name,
|
||||
'period': period,
|
||||
'suggested_increase': suggested_increase
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _production_efficiency(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Production efficiency recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
suggested_time = metadata.get('suggested_time', '')
|
||||
savings_percent = round(metadata.get('savings_percent', 0), 1)
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.production_efficiency.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.production_efficiency.message',
|
||||
'message_params': {
|
||||
'suggested_time': suggested_time,
|
||||
'savings_percent': savings_percent
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sales_opportunity(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Sales opportunity recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
product_name = metadata.get('product_name', 'Producto')
|
||||
days = metadata.get('days', '')
|
||||
increase_percent = round(metadata.get('increase_percent', 0), 0)
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.sales_opportunity.title',
|
||||
'title_params': {'product_name': product_name},
|
||||
'message_key': 'recommendations.sales_opportunity.message',
|
||||
'message_params': {
|
||||
'product_name': product_name,
|
||||
'days': days,
|
||||
'increase_percent': increase_percent
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _seasonal_adjustment(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Seasonal adjustment recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
season = metadata.get('season', 'temporada')
|
||||
products = metadata.get('products', 'productos estacionales')
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.seasonal_adjustment.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.seasonal_adjustment.message',
|
||||
'message_params': {
|
||||
'season': season,
|
||||
'products': products
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _cost_reduction(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Cost reduction recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
supplier_name = metadata.get('supplier_name', 'Proveedor')
|
||||
ingredient = metadata.get('ingredient', 'ingrediente')
|
||||
savings_euros = round(metadata.get('savings_euros', 0), 0)
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.cost_reduction.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.cost_reduction.message',
|
||||
'message_params': {
|
||||
'supplier_name': supplier_name,
|
||||
'ingredient': ingredient,
|
||||
'savings_euros': savings_euros
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _waste_reduction(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Waste reduction recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
product = metadata.get('product', 'producto')
|
||||
waste_reduction_percent = round(metadata.get('waste_reduction_percent', 0), 0)
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.waste_reduction.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.waste_reduction.message',
|
||||
'message_params': {
|
||||
'product': product,
|
||||
'waste_reduction_percent': waste_reduction_percent
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _quality_improvement(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Quality improvement recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
product = metadata.get('product', 'producto')
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.quality_improvement.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.quality_improvement.message',
|
||||
'message_params': {
|
||||
'product': product
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _customer_satisfaction(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Customer satisfaction recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
product = metadata.get('product', 'producto')
|
||||
days = metadata.get('days', '')
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.customer_satisfaction.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.customer_satisfaction.message',
|
||||
'message_params': {
|
||||
'product': product,
|
||||
'days': days
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _energy_optimization(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Energy optimization recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
start_time = metadata.get('start_time', '')
|
||||
end_time = metadata.get('end_time', '')
|
||||
savings_euros = round(metadata.get('savings_euros', 0), 0)
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.energy_optimization.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.energy_optimization.message',
|
||||
'message_params': {
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'savings_euros': savings_euros
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _staff_optimization(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""Staff optimization recommendation"""
|
||||
metadata = enriched.alert_metadata
|
||||
|
||||
days = metadata.get('days', '')
|
||||
hours = metadata.get('hours', '')
|
||||
|
||||
return {
|
||||
'title_key': 'recommendations.staff_optimization.title',
|
||||
'title_params': {},
|
||||
'message_key': 'recommendations.staff_optimization.message',
|
||||
'message_params': {
|
||||
'days': days,
|
||||
'hours': hours
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_contextual_message(enriched: EnrichedAlert) -> Dict[str, Any]:
|
||||
"""
|
||||
Main entry point for contextual message generation with i18n support
|
||||
|
||||
Args:
|
||||
enriched: Fully enriched alert with all context
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- title_key: i18n translation key for title
|
||||
- title_params: parameters for title translation
|
||||
- message_key: i18n translation key for message
|
||||
- message_params: parameters for message translation
|
||||
- fallback_title: fallback if i18n not available
|
||||
- fallback_message: fallback if i18n not available
|
||||
"""
|
||||
return ContextualMessageGenerator.generate_message_data(enriched)
|
||||
@@ -1,276 +0,0 @@
|
||||
# shared/alerts/templates.py
|
||||
"""
|
||||
Alert and recommendation templates in Spanish for the bakery platform
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
|
||||
ITEM_TEMPLATES = {
|
||||
# ALERTS - Critical Issues Requiring Immediate Action
|
||||
'critical_stock_shortage': {
|
||||
'es': {
|
||||
'title': '🚨 Stock Crítico: {ingredient_name}',
|
||||
'message': 'Solo {current_stock}kg disponibles, necesarios {required_stock}kg para producción de mañana. Acción inmediata requerida.',
|
||||
'actions': ['Realizar pedido de emergencia', 'Contactar proveedor', 'Ajustar plan de producción']
|
||||
},
|
||||
'en': {
|
||||
'title': '🚨 Critical Stock: {ingredient_name}',
|
||||
'message': 'Only {current_stock}kg available, {required_stock}kg needed for tomorrow\'s production. Immediate action required.',
|
||||
'actions': ['Place emergency order', 'Contact supplier', 'Adjust production plan']
|
||||
}
|
||||
},
|
||||
'temperature_breach': {
|
||||
'es': {
|
||||
'title': '🌡️ ALERTA TEMPERATURA',
|
||||
'message': '{location}: {temperature}°C durante {duration} minutos. Revisar productos inmediatamente para evitar deterioro.',
|
||||
'actions': ['Verificar productos', 'Llamar técnico refrigeración', 'Documentar incidencia', 'Mover productos']
|
||||
},
|
||||
'en': {
|
||||
'title': '🌡️ TEMPERATURE ALERT',
|
||||
'message': '{location}: {temperature}°C for {duration} minutes. Check products immediately to prevent spoilage.',
|
||||
'actions': ['Check products', 'Call refrigeration technician', 'Document incident', 'Move products']
|
||||
}
|
||||
},
|
||||
'production_delay': {
|
||||
'es': {
|
||||
'title': '⏰ Retraso en Producción',
|
||||
'message': 'Lote {batch_name} con {delay_minutes} minutos de retraso. Impacto en entregas del día.',
|
||||
'actions': ['Acelerar producción', 'Notificar clientes', 'Reorganizar horarios', 'Buscar capacidad adicional']
|
||||
}
|
||||
},
|
||||
'expired_products': {
|
||||
'es': {
|
||||
'title': '📅 Productos Caducados',
|
||||
'message': '{product_count} productos han caducado hoy. Retirar inmediatamente por seguridad alimentaria.',
|
||||
'actions': ['Retirar productos', 'Revisar inventario', 'Ajustar pedidos', 'Documentar pérdidas']
|
||||
}
|
||||
},
|
||||
'equipment_failure': {
|
||||
'es': {
|
||||
'title': '⚙️ Fallo de Equipo',
|
||||
'message': '{equipment_name} no está funcionando correctamente. Producción afectada.',
|
||||
'actions': ['Parar producción', 'Llamar mantenimiento', 'Usar equipo alternativo', 'Documentar fallo']
|
||||
}
|
||||
},
|
||||
'maintenance_required': {
|
||||
'es': {
|
||||
'title': '🔧 Mantenimiento Requerido: {equipment_name}',
|
||||
'message': 'Equipo {equipment_name} requiere mantenimiento en {days_until_maintenance} días.',
|
||||
'actions': ['Programar mantenimiento', 'Revisar historial', 'Preparar repuestos', 'Planificar parada']
|
||||
}
|
||||
},
|
||||
'low_equipment_efficiency': {
|
||||
'es': {
|
||||
'title': '📉 Baja Eficiencia: {equipment_name}',
|
||||
'message': 'Eficiencia del {equipment_name} bajó a {efficiency_percent}%. Revisar funcionamiento.',
|
||||
'actions': ['Revisar configuración', 'Limpiar equipo', 'Calibrar sensores', 'Revisar mantenimiento']
|
||||
}
|
||||
},
|
||||
'order_overload': {
|
||||
'es': {
|
||||
'title': '📋 Sobrecarga de Pedidos',
|
||||
'message': 'Capacidad excedida en {percentage}%. Riesgo de no cumplir entregas.',
|
||||
'actions': ['Priorizar pedidos', 'Aumentar turnos', 'Rechazar nuevos pedidos', 'Buscar ayuda externa']
|
||||
}
|
||||
},
|
||||
'supplier_delay': {
|
||||
'es': {
|
||||
'title': '🚚 Retraso de Proveedor',
|
||||
'message': 'Entrega de {supplier_name} retrasada {hours} horas. Impacto en producción de {products}.',
|
||||
'actions': ['Contactar proveedor', 'Buscar alternativas', 'Ajustar producción', 'Usar stock reserva']
|
||||
}
|
||||
},
|
||||
|
||||
# RECOMMENDATIONS - Proactive Suggestions for Optimization
|
||||
'inventory_optimization': {
|
||||
'es': {
|
||||
'title': '📈 Optimización de Stock: {ingredient_name}',
|
||||
'message': 'Basado en tendencias de {period} días, sugerimos aumentar stock mínimo en {suggested_increase}kg para reducir costos.',
|
||||
'actions': ['Revisar niveles mínimos', 'Analizar proveedores', 'Actualizar configuración', 'Programar pedido mayor']
|
||||
},
|
||||
'en': {
|
||||
'title': '📈 Stock Optimization: {ingredient_name}',
|
||||
'message': 'Based on {period} day trends, suggest increasing minimum stock by {suggested_increase}kg to reduce costs.',
|
||||
'actions': ['Review minimum levels', 'Analyze suppliers', 'Update configuration', 'Schedule larger order']
|
||||
}
|
||||
},
|
||||
'production_efficiency': {
|
||||
'es': {
|
||||
'title': '⚙️ Mejora de Eficiencia',
|
||||
'message': 'Cambiar horarios de horneado a {suggested_time} puede reducir costos energéticos en {savings_percent}%.',
|
||||
'actions': ['Revisar horarios', 'Consultar personal', 'Probar nuevo horario', 'Medir resultados']
|
||||
}
|
||||
},
|
||||
'sales_opportunity': {
|
||||
'es': {
|
||||
'title': '💰 Oportunidad de Venta',
|
||||
'message': '{product_name} tiene alta demanda los {days}. Incrementar producción puede aumentar ventas {increase_percent}%.',
|
||||
'actions': ['Aumentar producción', 'Promocionar producto', 'Revisar precios', 'Planificar ingredientes']
|
||||
}
|
||||
},
|
||||
'seasonal_adjustment': {
|
||||
'es': {
|
||||
'title': '🍂 Ajuste Estacional',
|
||||
'message': 'Época de {season}: ajustar producción de {products} según patrones históricos.',
|
||||
'actions': ['Revisar recetas estacionales', 'Ajustar inventario', 'Planificar promociones', 'Entrenar personal']
|
||||
}
|
||||
},
|
||||
'cost_reduction': {
|
||||
'es': {
|
||||
'title': '💡 Reducción de Costos',
|
||||
'message': 'Cambiar a proveedor {supplier_name} para {ingredient} puede ahorrar {savings_euros}€/mes.',
|
||||
'actions': ['Evaluar calidad', 'Negociar precios', 'Probar muestras', 'Cambiar proveedor gradualmente']
|
||||
}
|
||||
},
|
||||
'waste_reduction': {
|
||||
'es': {
|
||||
'title': '♻️ Reducción de Desperdicio',
|
||||
'message': 'Ajustar tamaños de lote de {product} puede reducir desperdicio en {waste_reduction_percent}%.',
|
||||
'actions': ['Analizar ventas', 'Ajustar recetas', 'Cambiar lotes', 'Monitorear resultados']
|
||||
}
|
||||
},
|
||||
'quality_improvement': {
|
||||
'es': {
|
||||
'title': '⭐ Mejora de Calidad',
|
||||
'message': 'Temperatura de horneado de {product} puede optimizarse para mejor textura y sabor.',
|
||||
'actions': ['Probar temperaturas', 'Documentar cambios', 'Entrenar panaderos', 'Obtener feedback']
|
||||
}
|
||||
},
|
||||
'customer_satisfaction': {
|
||||
'es': {
|
||||
'title': '😊 Satisfacción del Cliente',
|
||||
'message': 'Clientes solicitan más {product} los {days}. Considerar aumentar disponibilidad.',
|
||||
'actions': ['Revisar comentarios', 'Aumentar producción', 'Crear promociones', 'Mejorar exhibición']
|
||||
}
|
||||
},
|
||||
'energy_optimization': {
|
||||
'es': {
|
||||
'title': '⚡ Optimización Energética',
|
||||
'message': 'Consolidar horneado entre {start_time} y {end_time} puede reducir costos energéticos {savings_euros}€/día.',
|
||||
'actions': ['Revisar horarios energía', 'Reorganizar producción', 'Optimizar hornos', 'Medir consumo']
|
||||
}
|
||||
},
|
||||
'staff_optimization': {
|
||||
'es': {
|
||||
'title': '👥 Optimización de Personal',
|
||||
'message': 'Picos de trabajo los {days} a las {hours}. Considerar ajustar turnos para mejor eficiencia.',
|
||||
'actions': ['Analizar cargas trabajo', 'Reorganizar turnos', 'Entrenar polivalencia', 'Contratar temporal']
|
||||
}
|
||||
},
|
||||
|
||||
# FORECASTING ALERTS - Demand prediction and planning alerts
|
||||
'demand_surge_weekend': {
|
||||
'es': {
|
||||
'title': '📈 Fin de semana con alta demanda: {product_name}',
|
||||
'message': '📈 Fin de semana con alta demanda: {product_name} +{percentage}%',
|
||||
'actions': ['Aumentar producción', 'Pedir ingredientes extra', 'Programar personal']
|
||||
}
|
||||
},
|
||||
'weather_impact_alert': {
|
||||
'es': {
|
||||
'title': '🌧️ Impacto climático previsto',
|
||||
'message': '🌧️ Lluvia prevista: -20% tráfico peatonal esperado',
|
||||
'actions': ['Reducir producción fresca', 'Enfoque productos comfort', 'Promoción delivery']
|
||||
}
|
||||
},
|
||||
'holiday_preparation': {
|
||||
'es': {
|
||||
'title': '🎉 {holiday_name} en {days} días',
|
||||
'message': '🎉 {holiday_name} en {days} días: pedidos especiales aumentan {percentage}%',
|
||||
'actions': ['Preparar menú especial', 'Stock decoraciones', 'Extender horarios']
|
||||
}
|
||||
},
|
||||
'demand_pattern_optimization': {
|
||||
'es': {
|
||||
'title': '📊 Optimización de Patrones: {product_name}',
|
||||
'message': 'Demanda de {product_name} varía {variation_percent}% durante la semana. Oportunidad de optimización.',
|
||||
'actions': ['Analizar patrones semanales', 'Ajustar producción diaria', 'Optimizar inventario', 'Planificar promociones']
|
||||
}
|
||||
},
|
||||
'severe_weather_impact': {
|
||||
'es': {
|
||||
'title': '⛈️ Impacto Climático Severo',
|
||||
'message': 'Tormenta severa prevista: reducir producción de productos frescos y activar delivery.',
|
||||
'actions': ['Reducir producción fresca', 'Activar delivery', 'Asegurar displays exteriores']
|
||||
}
|
||||
},
|
||||
'unexpected_demand_spike': {
|
||||
'es': {
|
||||
'title': '📈 Pico de Demanda Inesperado',
|
||||
'message': 'Ventas de {product_name} {spike_percentage}% sobre pronóstico.',
|
||||
'actions': ['Aumentar producción', 'Revisar inventario', 'Actualizar pronóstico']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def format_item_message(template_key: str, language: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Format item message using template with validation"""
|
||||
template = ITEM_TEMPLATES.get(template_key, {}).get(language, {})
|
||||
|
||||
if not template:
|
||||
# Fallback for missing templates
|
||||
return {
|
||||
'title': f'Notificación: {template_key}',
|
||||
'message': f'Información: {", ".join([f"{k}: {v}" for k, v in kwargs.items()])}',
|
||||
'actions': ['Revisar', 'Documentar']
|
||||
}
|
||||
|
||||
try:
|
||||
# Format with provided kwargs, handling missing values gracefully
|
||||
formatted_title = template['title'].format(**kwargs)
|
||||
formatted_message = template['message'].format(**kwargs)
|
||||
|
||||
return {
|
||||
'title': formatted_title,
|
||||
'message': formatted_message,
|
||||
'actions': template.get('actions', [])
|
||||
}
|
||||
except KeyError as e:
|
||||
# Handle missing format parameters
|
||||
return {
|
||||
'title': template.get('title', f'Notificación: {template_key}'),
|
||||
'message': f"Error en plantilla - parámetro faltante: {e}. Datos: {kwargs}",
|
||||
'actions': template.get('actions', ['Revisar configuración'])
|
||||
}
|
||||
|
||||
def get_severity_emoji(severity: str) -> str:
|
||||
"""Get emoji for severity level"""
|
||||
emoji_map = {
|
||||
'urgent': '🚨',
|
||||
'high': '⚠️',
|
||||
'medium': '💡',
|
||||
'low': 'ℹ️'
|
||||
}
|
||||
return emoji_map.get(severity, '📋')
|
||||
|
||||
def get_item_type_emoji(item_type: str) -> str:
|
||||
"""Get emoji for item type"""
|
||||
emoji_map = {
|
||||
'alert': '🚨',
|
||||
'recommendation': '💡'
|
||||
}
|
||||
return emoji_map.get(item_type, '📋')
|
||||
|
||||
def format_business_time(hour: int) -> str:
|
||||
"""Format hour in Spanish business context"""
|
||||
if hour == 0:
|
||||
return "medianoche"
|
||||
elif hour < 12:
|
||||
return f"{hour}:00 AM"
|
||||
elif hour == 12:
|
||||
return "12:00 PM (mediodía)"
|
||||
else:
|
||||
return f"{hour-12}:00 PM"
|
||||
|
||||
def get_spanish_day_name(day_number: int) -> str:
|
||||
"""Get Spanish day name (0=Monday)"""
|
||||
days = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
||||
return days[day_number] if 0 <= day_number <= 6 else "día desconocido"
|
||||
|
||||
def format_currency(amount: float) -> str:
|
||||
"""Format currency in Spanish Euro format"""
|
||||
return f"{amount:.2f}€"
|
||||
|
||||
def format_percentage(value: float) -> str:
|
||||
"""Format percentage in Spanish format"""
|
||||
return f"{value:.1f}%"
|
||||
@@ -94,6 +94,54 @@ class AlertsServiceClient(BaseServiceClient):
|
||||
logger.error("Error fetching critical alerts", error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_alerts(
|
||||
self,
|
||||
tenant_id: str,
|
||||
priority_level: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
resolved: Optional[bool] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get alerts with optional filters
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
priority_level: Filter by priority level (critical, important, standard, info)
|
||||
status: Filter by status (active, resolved, acknowledged, ignored)
|
||||
resolved: Filter by resolved status (None = all, True = resolved only, False = unresolved only)
|
||||
limit: Maximum number of alerts
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
{
|
||||
"alerts": [...],
|
||||
"total": int,
|
||||
"limit": int,
|
||||
"offset": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if priority_level:
|
||||
params["priority_level"] = priority_level
|
||||
if status:
|
||||
params["status"] = status
|
||||
if resolved is not None:
|
||||
params["resolved"] = resolved
|
||||
|
||||
return await self.get(
|
||||
"/alerts",
|
||||
tenant_id=tenant_id,
|
||||
params=params
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error fetching alerts",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_alerts_by_severity(
|
||||
self,
|
||||
tenant_id: str,
|
||||
@@ -153,6 +201,44 @@ class AlertsServiceClient(BaseServiceClient):
|
||||
alert_id=alert_id, tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_dashboard_analytics(
|
||||
self,
|
||||
tenant_id: str,
|
||||
days: int = 7
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get dashboard analytics including prevented issues and estimated savings
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
days: Number of days to analyze (default: 7)
|
||||
|
||||
Returns:
|
||||
Dict with analytics data:
|
||||
{
|
||||
"period_days": int,
|
||||
"total_alerts": int,
|
||||
"active_alerts": int,
|
||||
"ai_handling_rate": float,
|
||||
"prevented_issues_count": int,
|
||||
"estimated_savings_eur": float,
|
||||
"total_financial_impact_at_risk_eur": float,
|
||||
"priority_distribution": {...},
|
||||
"type_class_distribution": {...},
|
||||
"active_by_type_class": {...},
|
||||
"period_comparison": {...}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
return await self.get(
|
||||
"/alerts/analytics/dashboard",
|
||||
tenant_id=tenant_id,
|
||||
params={"days": days}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error fetching dashboard analytics", error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# UTILITY METHODS
|
||||
# ================================================================
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
# shared/config/rabbitmq_config.py
|
||||
"""
|
||||
RabbitMQ configuration for the alert and recommendation system
|
||||
Supports both alerts and recommendations through a unified topic exchange
|
||||
RabbitMQ configuration for the event system
|
||||
|
||||
Supports three event classes through a unified topic exchange:
|
||||
- ALERT: Actionable events requiring user decision
|
||||
- NOTIFICATION: Informational state changes
|
||||
- RECOMMENDATION: AI-generated suggestions
|
||||
|
||||
Routing key pattern: {event_class}.{event_domain}.{severity}
|
||||
Examples:
|
||||
- alert.inventory.urgent
|
||||
- notification.production.info
|
||||
- recommendation.demand.medium
|
||||
"""
|
||||
|
||||
RABBITMQ_CONFIG = {
|
||||
"exchanges": {
|
||||
"events": {
|
||||
"name": "events.exchange",
|
||||
"type": "topic",
|
||||
"durable": True,
|
||||
"auto_delete": False
|
||||
},
|
||||
# Legacy exchange for backward compatibility during migration
|
||||
"alerts": {
|
||||
"name": "alerts.exchange",
|
||||
"type": "topic",
|
||||
@@ -20,19 +37,31 @@ RABBITMQ_CONFIG = {
|
||||
}
|
||||
},
|
||||
"queues": {
|
||||
"alert_processing": {
|
||||
"name": "alert.processing.queue",
|
||||
"event_processing": {
|
||||
"name": "event.processing.queue",
|
||||
"durable": True,
|
||||
"arguments": {
|
||||
"x-message-ttl": 3600000, # 1 hour TTL
|
||||
"x-max-length": 10000, # Max 10k messages
|
||||
"x-overflow": "reject-publish",
|
||||
"x-overflow": "reject-publish",
|
||||
"x-dead-letter-exchange": "dlx.exchange",
|
||||
"x-dead-letter-routing-key": "failed.events"
|
||||
}
|
||||
},
|
||||
# Legacy queue for backward compatibility
|
||||
"alert_processing": {
|
||||
"name": "alert.processing.queue",
|
||||
"durable": True,
|
||||
"arguments": {
|
||||
"x-message-ttl": 3600000,
|
||||
"x-max-length": 10000,
|
||||
"x-overflow": "reject-publish",
|
||||
"x-dead-letter-exchange": "dlx.exchange",
|
||||
"x-dead-letter-routing-key": "failed.items"
|
||||
}
|
||||
},
|
||||
"dead_letter": {
|
||||
"name": "alert.dead_letter.queue",
|
||||
"name": "event.dead_letter.queue",
|
||||
"durable": True,
|
||||
"arguments": {
|
||||
"x-message-ttl": 86400000 # 24 hours for dead letters
|
||||
@@ -40,43 +69,148 @@ RABBITMQ_CONFIG = {
|
||||
}
|
||||
},
|
||||
"bindings": [
|
||||
# New event architecture bindings
|
||||
{
|
||||
"queue": "event.processing.queue",
|
||||
"exchange": "events.exchange",
|
||||
"routing_key": "*.*.*" # event_class.event_domain.severity
|
||||
},
|
||||
# Legacy bindings for backward compatibility
|
||||
{
|
||||
"queue": "alert.processing.queue",
|
||||
"exchange": "alerts.exchange",
|
||||
"routing_key": "*.*.*" # alert/recommendation.severity.service
|
||||
},
|
||||
{
|
||||
"queue": "alert.dead_letter.queue",
|
||||
"queue": "event.dead_letter.queue",
|
||||
"exchange": "dlx.exchange",
|
||||
"routing_key": "failed.items"
|
||||
"routing_key": "failed.events"
|
||||
},
|
||||
{
|
||||
"queue": "event.dead_letter.queue",
|
||||
"exchange": "dlx.exchange",
|
||||
"routing_key": "failed.items" # Legacy
|
||||
}
|
||||
],
|
||||
"routing_patterns": {
|
||||
# alert/recommendation.severity.service_name
|
||||
"alert": "alert.{severity}.{service}",
|
||||
"recommendation": "recommendation.{severity}.{service}",
|
||||
# New event architecture patterns
|
||||
# event_class.event_domain.severity
|
||||
"alert_inventory": "alert.inventory.*",
|
||||
"alert_production": "alert.production.*",
|
||||
"alert_supply_chain": "alert.supply_chain.*",
|
||||
"notification_inventory": "notification.inventory.*",
|
||||
"notification_production": "notification.production.*",
|
||||
"notification_operations": "notification.operations.*",
|
||||
"recommendation_all": "recommendation.*.*",
|
||||
|
||||
# By severity
|
||||
"all_urgent": "*.*.urgent",
|
||||
"all_high": "*.*.high",
|
||||
"all_medium": "*.*.medium",
|
||||
"all_low": "*.*.low",
|
||||
|
||||
# By event class
|
||||
"all_alerts": "alert.*.*",
|
||||
"all_notifications": "notification.*.*",
|
||||
"all_recommendations": "recommendation.*.*",
|
||||
"urgent_items": "*.urgent.*",
|
||||
"high_items": "*.high.*"
|
||||
|
||||
# By domain
|
||||
"inventory_all": "*.inventory.*",
|
||||
"production_all": "*.production.*",
|
||||
"supply_chain_all": "*.supply_chain.*",
|
||||
"demand_all": "*.demand.*",
|
||||
"operations_all": "*.operations.*",
|
||||
|
||||
# Legacy patterns (for backward compatibility)
|
||||
"legacy_alert": "alert.{severity}.{service}",
|
||||
"legacy_recommendation": "recommendation.{severity}.{service}",
|
||||
}
|
||||
}
|
||||
|
||||
def get_routing_key(item_type: str, severity: str, service: str) -> str:
|
||||
"""Generate routing key for item publishing"""
|
||||
|
||||
def get_routing_key(event_class: str, event_domain: str, severity: str) -> str:
|
||||
"""
|
||||
Generate routing key for event publishing.
|
||||
|
||||
New pattern: {event_class}.{event_domain}.{severity}
|
||||
|
||||
Args:
|
||||
event_class: 'alert', 'notification', or 'recommendation'
|
||||
event_domain: 'inventory', 'production', 'supply_chain', 'demand', 'operations'
|
||||
severity: 'urgent', 'high', 'medium', 'low'
|
||||
|
||||
Returns:
|
||||
Routing key string
|
||||
|
||||
Examples:
|
||||
>>> get_routing_key('alert', 'inventory', 'urgent')
|
||||
'alert.inventory.urgent'
|
||||
>>> get_routing_key('notification', 'production', 'info')
|
||||
'notification.production.info'
|
||||
"""
|
||||
return f"{event_class}.{event_domain}.{severity}"
|
||||
|
||||
|
||||
def get_legacy_routing_key(item_type: str, severity: str, service: str) -> str:
|
||||
"""
|
||||
Generate legacy routing key for backward compatibility.
|
||||
|
||||
Legacy pattern: {item_type}.{severity}.{service}
|
||||
|
||||
TODO: Remove after migration is complete.
|
||||
"""
|
||||
return f"{item_type}.{severity}.{service}"
|
||||
|
||||
def get_binding_patterns(item_types: list = None, severities: list = None, services: list = None) -> list:
|
||||
"""Generate binding patterns for selective consumption"""
|
||||
|
||||
def get_binding_patterns(
|
||||
event_classes: list = None,
|
||||
event_domains: list = None,
|
||||
severities: list = None
|
||||
) -> list:
|
||||
"""
|
||||
Generate binding patterns for selective consumption.
|
||||
|
||||
Args:
|
||||
event_classes: List of event classes to bind (default: all)
|
||||
event_domains: List of event domains to bind (default: all)
|
||||
severities: List of severities to bind (default: all)
|
||||
|
||||
Returns:
|
||||
List of routing key patterns
|
||||
|
||||
Examples:
|
||||
>>> get_binding_patterns(['alert'], ['inventory'], ['urgent', 'high'])
|
||||
['alert.inventory.urgent', 'alert.inventory.high']
|
||||
"""
|
||||
patterns = []
|
||||
|
||||
item_types = item_types or ["alert", "recommendation"]
|
||||
|
||||
event_classes = event_classes or ["alert", "notification", "recommendation"]
|
||||
event_domains = event_domains or ["inventory", "production", "supply_chain", "demand", "operations"]
|
||||
severities = severities or ["urgent", "high", "medium", "low"]
|
||||
services = services or ["*"]
|
||||
|
||||
for item_type in item_types:
|
||||
for severity in severities:
|
||||
for service in services:
|
||||
patterns.append(f"{item_type}.{severity}.{service}")
|
||||
|
||||
return patterns
|
||||
|
||||
for event_class in event_classes:
|
||||
for event_domain in event_domains:
|
||||
for severity in severities:
|
||||
patterns.append(f"{event_class}.{event_domain}.{severity}")
|
||||
|
||||
return patterns
|
||||
|
||||
|
||||
def priority_score_to_severity(priority_score: int) -> str:
|
||||
"""
|
||||
Convert priority score (0-100) to severity level.
|
||||
|
||||
Args:
|
||||
priority_score: Priority score (0-100)
|
||||
|
||||
Returns:
|
||||
Severity level: 'urgent', 'high', 'medium', or 'low'
|
||||
"""
|
||||
if priority_score >= 90:
|
||||
return "urgent"
|
||||
elif priority_score >= 70:
|
||||
return "high"
|
||||
elif priority_score >= 50:
|
||||
return "medium"
|
||||
else:
|
||||
return "low"
|
||||
276
shared/schemas/alert_types.py
Normal file
276
shared/schemas/alert_types.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Alert Types for Next-Generation Alert System
|
||||
|
||||
Defines enriched alert types that transform passive notifications into actionable guidance.
|
||||
This replaces simple severity-based alerts with context-rich, prioritized, intelligent alerts.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Alert Type Classifications
|
||||
# ============================================================
|
||||
|
||||
class AlertTypeClass(str, Enum):
|
||||
"""High-level alert type classifications"""
|
||||
ACTION_NEEDED = "action_needed" # Requires user decision
|
||||
PREVENTED_ISSUE = "prevented_issue" # AI already handled, FYI
|
||||
TREND_WARNING = "trend_warning" # Proactive insight
|
||||
ESCALATION = "escalation" # Time-sensitive with auto-action countdown
|
||||
INFORMATION = "information" # Pure informational
|
||||
|
||||
|
||||
class PriorityLevel(str, Enum):
|
||||
"""Priority levels based on multi-factor scoring"""
|
||||
CRITICAL = "critical" # 90-100: Needs decision in next 2 hours
|
||||
IMPORTANT = "important" # 70-89: Needs decision today
|
||||
STANDARD = "standard" # 50-69: Review when convenient
|
||||
INFO = "info" # 0-49: For awareness
|
||||
|
||||
|
||||
class PlacementHint(str, Enum):
|
||||
"""UI placement hints for where alert should appear"""
|
||||
TOAST = "toast" # Immediate popup notification
|
||||
ACTION_QUEUE = "action_queue" # Dashboard action queue section
|
||||
DASHBOARD_INLINE = "dashboard_inline" # Embedded in relevant dashboard section
|
||||
NOTIFICATION_PANEL = "notification_panel" # Bell icon notification panel
|
||||
EMAIL_DIGEST = "email_digest" # End-of-day email summary
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Smart Action Definitions
|
||||
# ============================================================
|
||||
|
||||
class SmartActionType(str, Enum):
|
||||
"""Types of smart actions users can take"""
|
||||
APPROVE_PO = "approve_po"
|
||||
REJECT_PO = "reject_po"
|
||||
MODIFY_PO = "modify_po"
|
||||
CALL_SUPPLIER = "call_supplier"
|
||||
NAVIGATE = "navigate"
|
||||
ADJUST_PRODUCTION = "adjust_production"
|
||||
START_PRODUCTION_BATCH = "start_production_batch"
|
||||
NOTIFY_CUSTOMER = "notify_customer"
|
||||
CANCEL_AUTO_ACTION = "cancel_auto_action"
|
||||
MARK_DELIVERY_RECEIVED = "mark_delivery_received"
|
||||
COMPLETE_STOCK_RECEIPT = "complete_stock_receipt"
|
||||
OPEN_REASONING = "open_reasoning"
|
||||
SNOOZE = "snooze"
|
||||
DISMISS = "dismiss"
|
||||
MARK_READ = "mark_read"
|
||||
|
||||
|
||||
class SmartAction(BaseModel):
|
||||
"""Smart action button definition"""
|
||||
label: str = Field(..., description="User-facing button label")
|
||||
type: SmartActionType = Field(..., description="Action type for handler routing")
|
||||
variant: str = Field(default="primary", description="UI variant: primary, secondary, tertiary, danger")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Action-specific data")
|
||||
disabled: bool = Field(default=False, description="Whether action is disabled")
|
||||
disabled_reason: Optional[str] = Field(None, description="Reason why action is disabled")
|
||||
estimated_time_minutes: Optional[int] = Field(None, description="Estimated time to complete action")
|
||||
consequence: Optional[str] = Field(None, description="What happens if this action is taken")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Context & Enrichment Models
|
||||
# ============================================================
|
||||
|
||||
class OrchestratorContext(BaseModel):
|
||||
"""Context from Daily Orchestrator about recent actions"""
|
||||
already_addressed: bool = Field(..., description="Has AI already addressed this issue?")
|
||||
action_type: Optional[str] = Field(None, description="Type of action taken: PO, batch, adjustment")
|
||||
action_id: Optional[str] = Field(None, description="ID of the PO/batch created")
|
||||
action_status: Optional[str] = Field(None, description="Status: created, pending_approval, completed")
|
||||
delivery_date: Optional[datetime] = Field(None, description="When will solution arrive")
|
||||
reasoning: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning data")
|
||||
estimated_resolution_time: Optional[datetime] = Field(None, description="When issue will be resolved")
|
||||
estimated_savings_eur: Optional[float] = Field(None, description="Estimated savings from preventing this issue")
|
||||
|
||||
|
||||
class BusinessImpact(BaseModel):
|
||||
"""Business impact assessment"""
|
||||
financial_impact_eur: Optional[float] = Field(None, description="Estimated € impact")
|
||||
affected_orders: Optional[int] = Field(None, description="Number of orders affected")
|
||||
affected_customers: Optional[List[str]] = Field(None, description="Customer names affected")
|
||||
production_batches_at_risk: Optional[List[str]] = Field(None, description="Batch IDs at risk")
|
||||
stockout_risk_hours: Optional[float] = Field(None, description="Hours until stockout")
|
||||
waste_risk_kg: Optional[float] = Field(None, description="Kg of waste risk")
|
||||
customer_satisfaction_impact: Optional[str] = Field(None, description="Impact level: high, medium, low")
|
||||
|
||||
|
||||
class UrgencyContext(BaseModel):
|
||||
"""Urgency and timing context"""
|
||||
deadline: Optional[datetime] = Field(None, description="Hard deadline for decision")
|
||||
time_until_consequence_hours: Optional[float] = Field(None, description="Hours until consequence occurs")
|
||||
can_wait_until_tomorrow: bool = Field(default=True, description="Can this wait until tomorrow?")
|
||||
peak_hour_relevant: bool = Field(default=False, description="Is this relevant during peak hours?")
|
||||
auto_action_countdown_seconds: Optional[int] = Field(None, description="Seconds until auto-action triggers")
|
||||
|
||||
|
||||
class UserAgency(BaseModel):
|
||||
"""User's ability to act on this alert"""
|
||||
can_user_fix: bool = Field(..., description="Can the user actually fix this?")
|
||||
requires_external_party: bool = Field(default=False, description="Requires supplier/customer action?")
|
||||
external_party_name: Optional[str] = Field(None, description="Name of external party")
|
||||
external_party_contact: Optional[str] = Field(None, description="Phone/email of external party")
|
||||
blockers: Optional[List[str]] = Field(None, description="Things blocking user from acting")
|
||||
suggested_workaround: Optional[str] = Field(None, description="Alternative solution if blocked")
|
||||
|
||||
|
||||
class TrendContext(BaseModel):
|
||||
"""Trend analysis context"""
|
||||
metric_name: str = Field(..., description="Name of metric trending")
|
||||
current_value: float = Field(..., description="Current value")
|
||||
baseline_value: float = Field(..., description="Baseline/expected value")
|
||||
change_percentage: float = Field(..., description="Percentage change")
|
||||
direction: str = Field(..., description="Direction: increasing, decreasing")
|
||||
significance: str = Field(..., description="Significance: high, medium, low")
|
||||
period_days: int = Field(..., description="Number of days in trend period")
|
||||
possible_causes: Optional[List[str]] = Field(None, description="Potential root causes")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Enriched Alert Model
|
||||
# ============================================================
|
||||
|
||||
class EnrichedAlert(BaseModel):
|
||||
"""
|
||||
Next-generation enriched alert with full context and guidance.
|
||||
This is what gets sent to the frontend after intelligence processing.
|
||||
"""
|
||||
|
||||
# Original Alert Data
|
||||
id: str = Field(..., description="Alert UUID")
|
||||
tenant_id: str = Field(..., description="Tenant UUID")
|
||||
service: str = Field(..., description="Originating service")
|
||||
alert_type: str = Field(..., description="Specific alert type code")
|
||||
title: str = Field(..., description="User-facing title")
|
||||
message: str = Field(..., description="Detailed message")
|
||||
|
||||
# Classification
|
||||
type_class: AlertTypeClass = Field(..., description="High-level classification")
|
||||
priority_level: PriorityLevel = Field(..., description="Priority level")
|
||||
priority_score: int = Field(..., description="Numeric priority score 0-100")
|
||||
|
||||
# Context Enrichment
|
||||
orchestrator_context: Optional[OrchestratorContext] = Field(None, description="AI system context")
|
||||
business_impact: Optional[BusinessImpact] = Field(None, description="Business impact assessment")
|
||||
urgency_context: Optional[UrgencyContext] = Field(None, description="Urgency and timing")
|
||||
user_agency: Optional[UserAgency] = Field(None, description="User's ability to act")
|
||||
trend_context: Optional[TrendContext] = Field(None, description="Trend analysis (if trend warning)")
|
||||
|
||||
# AI Reasoning
|
||||
ai_reasoning_summary: Optional[str] = Field(None, description="Plain language AI reasoning")
|
||||
reasoning_data: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning from orchestrator")
|
||||
confidence_score: Optional[float] = Field(None, description="AI confidence 0-1")
|
||||
|
||||
# Actions
|
||||
actions: List[SmartAction] = Field(default_factory=list, description="Smart action buttons")
|
||||
primary_action: Optional[SmartAction] = Field(None, description="Primary recommended action")
|
||||
|
||||
# UI Placement
|
||||
placement: List[PlacementHint] = Field(default_factory=list, description="Where to show this alert")
|
||||
|
||||
# Grouping
|
||||
group_id: Optional[str] = Field(None, description="Group ID if part of grouped alerts")
|
||||
is_group_summary: bool = Field(default=False, description="Is this a group summary?")
|
||||
grouped_alert_count: Optional[int] = Field(None, description="Number of alerts in group")
|
||||
grouped_alert_ids: Optional[List[str]] = Field(None, description="IDs of grouped alerts")
|
||||
|
||||
# Metadata
|
||||
created_at: datetime = Field(..., description="When alert was created")
|
||||
enriched_at: datetime = Field(..., description="When alert was enriched")
|
||||
alert_metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
||||
|
||||
# Status
|
||||
status: str = Field(default="active", description="Status: active, resolved, acknowledged, snoozed")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Raw Alert Input Model
|
||||
# ============================================================
|
||||
|
||||
class RawAlert(BaseModel):
|
||||
"""
|
||||
Raw alert from originating services (inventory, production, etc.)
|
||||
This is what services send before enrichment.
|
||||
"""
|
||||
tenant_id: str
|
||||
alert_type: str
|
||||
title: str
|
||||
message: str
|
||||
service: str
|
||||
actions: Optional[List[str]] = None # Simple action labels
|
||||
alert_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
item_type: str = Field(default="alert") # alert or recommendation
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Alert Group Model
|
||||
# ============================================================
|
||||
|
||||
class AlertGroup(BaseModel):
|
||||
"""Grouped alerts for better UX"""
|
||||
group_id: str = Field(..., description="Group UUID")
|
||||
tenant_id: str = Field(..., description="Tenant UUID")
|
||||
group_type: str = Field(..., description="Type of grouping: supplier, service, type")
|
||||
title: str = Field(..., description="Group title")
|
||||
summary: str = Field(..., description="Group summary message")
|
||||
alert_count: int = Field(..., description="Number of alerts in group")
|
||||
alert_ids: List[str] = Field(..., description="Alert UUIDs in group")
|
||||
highest_priority_score: int = Field(..., description="Highest priority in group")
|
||||
created_at: datetime = Field(..., description="When group was created")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Group metadata")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Priority Scoring Components
|
||||
# ============================================================
|
||||
|
||||
class PriorityScoreComponents(BaseModel):
|
||||
"""Breakdown of priority score calculation"""
|
||||
business_impact_score: float = Field(..., description="Business impact component 0-100")
|
||||
urgency_score: float = Field(..., description="Urgency component 0-100")
|
||||
user_agency_score: float = Field(..., description="User agency component 0-100")
|
||||
confidence_score: float = Field(..., description="Confidence component 0-100")
|
||||
final_score: int = Field(..., description="Final weighted score 0-100")
|
||||
weights: Dict[str, float] = Field(..., description="Weights used in calculation")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Standard Alert Type Constants
|
||||
# ============================================================
|
||||
|
||||
class AlertTypeConstants:
|
||||
"""Standard alert type string constants"""
|
||||
|
||||
# Inventory alerts
|
||||
LOW_STOCK_WARNING = "low_stock_warning"
|
||||
CRITICAL_STOCK_SHORTAGE = "critical_stock_shortage"
|
||||
EXPIRING_SOON = "expiring_soon"
|
||||
EXPIRED_STOCK = "expired_stock"
|
||||
|
||||
# Production alerts
|
||||
PRODUCTION_DELAY = "production_delay"
|
||||
PRODUCTION_STALLED = "production_stalled"
|
||||
BATCH_AT_RISK = "batch_at_risk"
|
||||
PRODUCTION_BATCH_START = "production_batch_start"
|
||||
|
||||
# Purchase Order alerts
|
||||
PO_APPROVAL_NEEDED = "po_approval_needed"
|
||||
PO_APPROVAL_ESCALATION = "po_approval_escalation"
|
||||
|
||||
# Delivery lifecycle alerts (NEW)
|
||||
DELIVERY_SCHEDULED = "delivery_scheduled"
|
||||
DELIVERY_ARRIVING_SOON = "delivery_arriving_soon"
|
||||
DELIVERY_OVERDUE = "delivery_overdue"
|
||||
STOCK_RECEIPT_INCOMPLETE = "stock_receipt_incomplete"
|
||||
|
||||
# Forecasting alerts
|
||||
DEMAND_SURGE_PREDICTED = "demand_surge_predicted"
|
||||
DEMAND_DROP_PREDICTED = "demand_drop_predicted"
|
||||
343
shared/schemas/event_classification.py
Normal file
343
shared/schemas/event_classification.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Event Classification Schema
|
||||
|
||||
This module defines the three-tier event model that separates:
|
||||
- ALERTS: Actionable events requiring user decision
|
||||
- NOTIFICATIONS: Informational state changes (FYI only)
|
||||
- RECOMMENDATIONS: Advisory suggestions from AI
|
||||
|
||||
This replaces the old conflated "alert" system with semantic clarity.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EventClass(str, Enum):
|
||||
"""
|
||||
Top-level event classification.
|
||||
|
||||
- ALERT: Actionable, requires user decision, has smart actions
|
||||
- NOTIFICATION: Informational state change, no action needed
|
||||
- RECOMMENDATION: Advisory suggestion, optional action
|
||||
"""
|
||||
ALERT = "alert"
|
||||
NOTIFICATION = "notification"
|
||||
RECOMMENDATION = "recommendation"
|
||||
|
||||
|
||||
class EventDomain(str, Enum):
|
||||
"""
|
||||
Business domain classification for events.
|
||||
Enables domain-specific dashboards and selective subscription.
|
||||
"""
|
||||
INVENTORY = "inventory"
|
||||
PRODUCTION = "production"
|
||||
SUPPLY_CHAIN = "supply_chain"
|
||||
DEMAND = "demand"
|
||||
OPERATIONS = "operations"
|
||||
|
||||
|
||||
class PriorityLevel(str, Enum):
|
||||
"""Priority levels for alerts and recommendations."""
|
||||
CRITICAL = "critical" # 90-100: Immediate action required
|
||||
IMPORTANT = "important" # 70-89: Action needed soon
|
||||
STANDARD = "standard" # 50-69: Normal priority
|
||||
INFO = "info" # 0-49: Low priority, informational
|
||||
|
||||
|
||||
class AlertTypeClass(str, Enum):
|
||||
"""
|
||||
Alert-specific classification (only applies to EventClass.ALERT).
|
||||
"""
|
||||
ACTION_NEEDED = "action_needed" # User must decide
|
||||
PREVENTED_ISSUE = "prevented_issue" # AI already handled, FYI
|
||||
TREND_WARNING = "trend_warning" # Pattern detected
|
||||
ESCALATION = "escalation" # Time-sensitive with auto-action countdown
|
||||
INFORMATION = "information" # Pure informational alert
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
"""
|
||||
Notification-specific types for state changes.
|
||||
"""
|
||||
STATE_CHANGE = "state_change" # Entity state transition
|
||||
COMPLETION = "completion" # Process/task completed
|
||||
ARRIVAL = "arrival" # Entity arrived/received
|
||||
DEPARTURE = "departure" # Entity left/shipped
|
||||
UPDATE = "update" # General update
|
||||
SYSTEM_EVENT = "system_event" # System operation
|
||||
|
||||
|
||||
class RecommendationType(str, Enum):
|
||||
"""
|
||||
Recommendation-specific types.
|
||||
"""
|
||||
OPTIMIZATION = "optimization" # Efficiency improvement
|
||||
COST_REDUCTION = "cost_reduction" # Save money
|
||||
RISK_MITIGATION = "risk_mitigation" # Prevent future issues
|
||||
TREND_INSIGHT = "trend_insight" # Pattern analysis
|
||||
BEST_PRACTICE = "best_practice" # Suggested approach
|
||||
|
||||
|
||||
class RawEvent(BaseModel):
|
||||
"""
|
||||
Base event emitted by domain services.
|
||||
|
||||
This is the unified schema replacing the old RawAlert.
|
||||
All domain services emit RawEvents which are then conditionally enriched.
|
||||
"""
|
||||
tenant_id: str = Field(..., description="Tenant identifier")
|
||||
|
||||
# Event classification
|
||||
event_class: EventClass = Field(..., description="Alert, Notification, or Recommendation")
|
||||
event_domain: EventDomain = Field(..., description="Business domain (inventory, production, etc.)")
|
||||
event_type: str = Field(..., description="Specific event type (e.g., 'critical_stock_shortage')")
|
||||
|
||||
# Core content
|
||||
title: str = Field(..., description="Event title")
|
||||
message: str = Field(..., description="Event message")
|
||||
|
||||
# Source
|
||||
service: str = Field(..., description="Originating service name")
|
||||
|
||||
# Actions (optional, mainly for alerts)
|
||||
actions: Optional[List[str]] = Field(default=None, description="Available action types")
|
||||
|
||||
# Metadata (domain-specific data)
|
||||
event_metadata: Dict[str, Any] = Field(default_factory=dict, description="Domain-specific metadata")
|
||||
|
||||
# Timestamp
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Event creation time")
|
||||
|
||||
# Deduplication (optional)
|
||||
deduplication_key: Optional[str] = Field(default=None, description="Key for deduplication")
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class EnrichedAlert(BaseModel):
|
||||
"""
|
||||
Fully enriched alert with priority scoring, smart actions, and context.
|
||||
Only used for EventClass.ALERT.
|
||||
"""
|
||||
# From RawEvent
|
||||
id: str
|
||||
tenant_id: str
|
||||
event_domain: EventDomain
|
||||
event_type: str
|
||||
title: str
|
||||
message: str
|
||||
service: str
|
||||
timestamp: datetime
|
||||
|
||||
# Alert-specific
|
||||
type_class: AlertTypeClass
|
||||
status: str # active, acknowledged, resolved, dismissed
|
||||
|
||||
# Priority
|
||||
priority_score: int = Field(..., ge=0, le=100, description="0-100 priority score")
|
||||
priority_level: PriorityLevel
|
||||
|
||||
# Enrichment context
|
||||
orchestrator_context: Optional[Dict[str, Any]] = Field(default=None)
|
||||
business_impact: Optional[Dict[str, Any]] = Field(default=None)
|
||||
urgency_context: Optional[Dict[str, Any]] = Field(default=None)
|
||||
user_agency: Optional[Dict[str, Any]] = Field(default=None)
|
||||
|
||||
# Smart actions
|
||||
smart_actions: Optional[List[Dict[str, Any]]] = Field(default=None)
|
||||
|
||||
# AI reasoning
|
||||
ai_reasoning_summary: Optional[str] = Field(default=None)
|
||||
confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||
|
||||
# Timing
|
||||
timing_decision: Optional[str] = Field(default=None)
|
||||
scheduled_send_time: Optional[datetime] = Field(default=None)
|
||||
placement: Optional[List[str]] = Field(default=None)
|
||||
|
||||
# Metadata
|
||||
alert_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
"""
|
||||
Lightweight notification for state changes.
|
||||
Only used for EventClass.NOTIFICATION.
|
||||
"""
|
||||
# From RawEvent
|
||||
id: str
|
||||
tenant_id: str
|
||||
event_domain: EventDomain
|
||||
event_type: str
|
||||
notification_type: NotificationType
|
||||
title: str
|
||||
message: str
|
||||
service: str
|
||||
timestamp: datetime
|
||||
|
||||
# Lightweight context
|
||||
entity_type: Optional[str] = Field(default=None, description="Type of entity (batch, delivery, etc.)")
|
||||
entity_id: Optional[str] = Field(default=None, description="ID of entity")
|
||||
old_state: Optional[str] = Field(default=None, description="Previous state")
|
||||
new_state: Optional[str] = Field(default=None, description="New state")
|
||||
|
||||
# Display metadata
|
||||
notification_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
# Placement (lightweight, typically just toast + panel)
|
||||
placement: List[str] = Field(default_factory=lambda: ["notification_panel"])
|
||||
|
||||
# TTL tracking
|
||||
expires_at: Optional[datetime] = Field(default=None, description="Auto-delete after this time")
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class Recommendation(BaseModel):
|
||||
"""
|
||||
AI-generated recommendation with moderate enrichment.
|
||||
Only used for EventClass.RECOMMENDATION.
|
||||
"""
|
||||
# From RawEvent
|
||||
id: str
|
||||
tenant_id: str
|
||||
event_domain: EventDomain
|
||||
event_type: str
|
||||
recommendation_type: RecommendationType
|
||||
title: str
|
||||
message: str
|
||||
service: str
|
||||
timestamp: datetime
|
||||
|
||||
# Recommendation-specific
|
||||
priority_level: PriorityLevel = Field(default=PriorityLevel.INFO)
|
||||
|
||||
# Context (lighter than alerts, no orchestrator queries)
|
||||
estimated_impact: Optional[Dict[str, Any]] = Field(default=None, description="Estimated benefit")
|
||||
suggested_actions: Optional[List[Dict[str, Any]]] = Field(default=None)
|
||||
|
||||
# AI reasoning
|
||||
ai_reasoning_summary: Optional[str] = Field(default=None)
|
||||
confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||
|
||||
# Dismissal tracking
|
||||
dismissed_at: Optional[datetime] = Field(default=None)
|
||||
dismissed_by: Optional[str] = Field(default=None)
|
||||
|
||||
# Metadata
|
||||
recommendation_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
# Event type mappings for easy classification
|
||||
EVENT_TYPE_TO_CLASS_MAP = {
|
||||
# Alerts (actionable)
|
||||
"critical_stock_shortage": (EventClass.ALERT, EventDomain.INVENTORY),
|
||||
"production_delay": (EventClass.ALERT, EventDomain.PRODUCTION),
|
||||
"equipment_failure": (EventClass.ALERT, EventDomain.PRODUCTION),
|
||||
"po_approval_needed": (EventClass.ALERT, EventDomain.SUPPLY_CHAIN),
|
||||
"delivery_overdue": (EventClass.ALERT, EventDomain.SUPPLY_CHAIN),
|
||||
"temperature_breach": (EventClass.ALERT, EventDomain.INVENTORY),
|
||||
"expired_products": (EventClass.ALERT, EventDomain.INVENTORY),
|
||||
"low_stock_warning": (EventClass.ALERT, EventDomain.INVENTORY),
|
||||
"production_ingredient_shortage": (EventClass.ALERT, EventDomain.INVENTORY),
|
||||
"order_overload": (EventClass.ALERT, EventDomain.PRODUCTION),
|
||||
|
||||
# Notifications (informational)
|
||||
"stock_received": (EventClass.NOTIFICATION, EventDomain.INVENTORY),
|
||||
"stock_movement": (EventClass.NOTIFICATION, EventDomain.INVENTORY),
|
||||
"batch_state_changed": (EventClass.NOTIFICATION, EventDomain.PRODUCTION),
|
||||
"batch_completed": (EventClass.NOTIFICATION, EventDomain.PRODUCTION),
|
||||
"orchestration_run_started": (EventClass.NOTIFICATION, EventDomain.OPERATIONS),
|
||||
"orchestration_run_completed": (EventClass.NOTIFICATION, EventDomain.OPERATIONS),
|
||||
"po_approved": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
|
||||
"po_sent_to_supplier": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
|
||||
"delivery_scheduled": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
|
||||
"delivery_arriving_soon": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
|
||||
"delivery_received": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
|
||||
|
||||
# Recommendations (advisory)
|
||||
"demand_surge_predicted": (EventClass.RECOMMENDATION, EventDomain.DEMAND),
|
||||
"weather_impact_forecast": (EventClass.RECOMMENDATION, EventDomain.DEMAND),
|
||||
"holiday_preparation": (EventClass.RECOMMENDATION, EventDomain.DEMAND),
|
||||
"inventory_optimization_opportunity": (EventClass.RECOMMENDATION, EventDomain.INVENTORY),
|
||||
"cost_reduction_suggestion": (EventClass.RECOMMENDATION, EventDomain.SUPPLY_CHAIN),
|
||||
"efficiency_improvement": (EventClass.RECOMMENDATION, EventDomain.PRODUCTION),
|
||||
}
|
||||
|
||||
|
||||
def get_event_classification(event_type: str) -> tuple[EventClass, EventDomain]:
|
||||
"""
|
||||
Get the event_class and event_domain for a given event_type.
|
||||
|
||||
Args:
|
||||
event_type: The specific event type string
|
||||
|
||||
Returns:
|
||||
Tuple of (EventClass, EventDomain)
|
||||
|
||||
Raises:
|
||||
ValueError: If event_type is not recognized
|
||||
"""
|
||||
if event_type in EVENT_TYPE_TO_CLASS_MAP:
|
||||
return EVENT_TYPE_TO_CLASS_MAP[event_type]
|
||||
|
||||
# Default: treat unknown types as notifications in operations domain
|
||||
return (EventClass.NOTIFICATION, EventDomain.OPERATIONS)
|
||||
|
||||
|
||||
def get_redis_channel(tenant_id: str, event_domain: EventDomain, event_class: EventClass) -> str:
|
||||
"""
|
||||
Get the Redis pub/sub channel name for an event.
|
||||
|
||||
Pattern: tenant:{tenant_id}:{domain}.{class}
|
||||
Examples:
|
||||
- tenant:uuid:inventory.alerts
|
||||
- tenant:uuid:production.notifications
|
||||
- tenant:uuid:recommendations (recommendations not domain-specific)
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
event_domain: Event domain
|
||||
event_class: Event class
|
||||
|
||||
Returns:
|
||||
Redis channel name
|
||||
"""
|
||||
if event_class == EventClass.RECOMMENDATION:
|
||||
# Recommendations go to a tenant-wide channel
|
||||
return f"tenant:{tenant_id}:recommendations"
|
||||
|
||||
return f"tenant:{tenant_id}:{event_domain.value}.{event_class.value}s"
|
||||
|
||||
|
||||
def get_rabbitmq_routing_key(event_class: EventClass, event_domain: EventDomain, severity: str) -> str:
|
||||
"""
|
||||
Get the RabbitMQ routing key for an event.
|
||||
|
||||
Pattern: {event_class}.{event_domain}.{severity}
|
||||
Examples:
|
||||
- alert.inventory.urgent
|
||||
- notification.production.info
|
||||
- recommendation.demand.medium
|
||||
|
||||
Args:
|
||||
event_class: Event class
|
||||
event_domain: Event domain
|
||||
severity: Severity level (urgent, high, medium, low)
|
||||
|
||||
Returns:
|
||||
RabbitMQ routing key
|
||||
"""
|
||||
return f"{event_class.value}.{event_domain.value}.{severity}"
|
||||
@@ -10,8 +10,8 @@ from typing import Optional
|
||||
|
||||
# Base reference date for all demo seed data
|
||||
# All seed scripts should use this as the "logical seed date"
|
||||
# Updated to November 2025 to show recent orchestration runs
|
||||
BASE_REFERENCE_DATE = datetime(2025, 11, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||
# IMPORTANT: Must match the actual dates in seed data (production batches start Jan 8, 2025)
|
||||
BASE_REFERENCE_DATE = datetime(2025, 1, 8, 6, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def adjust_date_for_demo(
|
||||
|
||||
Reference in New Issue
Block a user