New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -4,5 +4,6 @@ Alert Processor API Endpoints
from .analytics import router as analytics_router
from .alerts import router as alerts_router
from .internal_demo import router as internal_demo_router
__all__ = ['analytics_router', 'alerts_router']
__all__ = ['analytics_router', 'alerts_router', 'internal_demo_router']

View File

@@ -3,7 +3,7 @@
Alerts API endpoints for dashboard and alert management
"""
from fastapi import APIRouter, HTTPException, Query, Path
from fastapi import APIRouter, HTTPException, Query, Path, Depends
from typing import List, Optional
from pydantic import BaseModel, Field
from uuid import UUID
@@ -11,7 +11,8 @@ from datetime import datetime
import structlog
from app.repositories.alerts_repository import AlertsRepository
from app.models.alerts import AlertSeverity, AlertStatus
from app.models.events import AlertStatus
from app.dependencies import get_current_user
logger = structlog.get_logger()
@@ -28,12 +29,14 @@ class AlertResponse(BaseModel):
tenant_id: str
item_type: str
alert_type: str
severity: str
priority_level: str
priority_score: int
status: str
service: str
title: str
message: str
actions: Optional[dict] = None
type_class: str
actions: Optional[List[dict]] = None # smart_actions is a list of action objects
alert_metadata: Optional[dict] = None
created_at: datetime
updated_at: datetime
@@ -47,10 +50,10 @@ class AlertsSummaryResponse(BaseModel):
"""Alerts summary for dashboard"""
total_count: int = Field(..., description="Total number of alerts")
active_count: int = Field(..., description="Number of active (unresolved) alerts")
critical_count: int = Field(..., description="Number of critical/urgent alerts")
high_count: int = Field(..., description="Number of high severity alerts")
medium_count: int = Field(..., description="Number of medium severity alerts")
low_count: int = Field(..., description="Number of low severity alerts")
critical_count: int = Field(..., description="Number of critical priority alerts")
high_count: int = Field(..., description="Number of high priority alerts")
medium_count: int = Field(..., description="Number of medium priority alerts")
low_count: int = Field(..., description="Number of low priority alerts")
resolved_count: int = Field(..., description="Number of resolved alerts")
acknowledged_count: int = Field(..., description="Number of acknowledged alerts")
@@ -71,7 +74,7 @@ class AlertsListResponse(BaseModel):
"/api/v1/tenants/{tenant_id}/alerts/summary",
response_model=AlertsSummaryResponse,
summary="Get alerts summary",
description="Get summary of alerts by severity and status for dashboard health indicator"
description="Get summary of alerts by priority level and status for dashboard health indicator"
)
async def get_alerts_summary(
tenant_id: UUID = Path(..., description="Tenant ID")
@@ -79,8 +82,8 @@ async def get_alerts_summary(
"""
Get alerts summary for dashboard
Returns counts of alerts grouped by severity and status.
Critical count maps to URGENT severity for dashboard compatibility.
Returns counts of alerts grouped by priority level and status.
Critical count maps to URGENT priority level for dashboard compatibility.
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
@@ -107,7 +110,7 @@ async def get_alerts_summary(
)
async def get_alerts(
tenant_id: UUID = Path(..., description="Tenant ID"),
severity: Optional[str] = Query(None, description="Filter by severity: low, medium, high, urgent"),
priority_level: Optional[str] = Query(None, description="Filter by priority level: critical, important, standard, info"),
status: Optional[str] = Query(None, description="Filter by status: active, resolved, acknowledged, ignored"),
resolved: Optional[bool] = Query(None, description="Filter by resolved status: true=resolved only, false=unresolved only"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"),
@@ -117,7 +120,7 @@ async def get_alerts(
Get filtered list of alerts
Supports filtering by:
- severity: low, medium, high, urgent (maps to "critical" in dashboard)
- priority_level: critical, important, standard, info
- status: active, resolved, acknowledged, ignored
- resolved: boolean filter for resolved status
- pagination: limit and offset
@@ -126,18 +129,20 @@ async def get_alerts(
from shared.database.base import create_database_manager
try:
# Validate severity enum
if severity and severity not in [s.value for s in AlertSeverity]:
# Validate priority_level enum
valid_priority_levels = ['critical', 'important', 'standard', 'info']
if priority_level and priority_level not in valid_priority_levels:
raise HTTPException(
status_code=400,
detail=f"Invalid severity. Must be one of: {[s.value for s in AlertSeverity]}"
detail=f"Invalid priority level. Must be one of: {valid_priority_levels}"
)
# Validate status enum
if status and status not in [s.value for s in AlertStatus]:
valid_status_values = ['active', 'resolved', 'acknowledged', 'ignored']
if status and status not in valid_status_values:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Must be one of: {[s.value for s in AlertStatus]}"
detail=f"Invalid status. Must be one of: {valid_status_values}"
)
config = AlertProcessorConfig()
@@ -147,7 +152,7 @@ async def get_alerts(
repo = AlertsRepository(session)
alerts = await repo.get_alerts(
tenant_id=tenant_id,
severity=severity,
priority_level=priority_level,
status=status,
resolved=resolved,
limit=limit,
@@ -155,25 +160,42 @@ async def get_alerts(
)
# Convert to response models
alert_responses = [
AlertResponse(
alert_responses = []
for alert in alerts:
# Handle old format actions (strings) by converting to proper dict format
actions = alert.smart_actions
if actions and isinstance(actions, list) and len(actions) > 0:
# Check if actions are strings (old format)
if isinstance(actions[0], str):
# Convert old format to new format
actions = [
{
'action_type': action,
'label': action.replace('_', ' ').title(),
'variant': 'default',
'disabled': False
}
for action in actions
]
alert_responses.append(AlertResponse(
id=str(alert.id),
tenant_id=str(alert.tenant_id),
item_type=alert.item_type,
alert_type=alert.alert_type,
severity=alert.severity,
status=alert.status,
priority_level=alert.priority_level.value if hasattr(alert.priority_level, 'value') else alert.priority_level,
priority_score=alert.priority_score,
status=alert.status.value if hasattr(alert.status, 'value') else alert.status,
service=alert.service,
title=alert.title,
message=alert.message,
actions=alert.actions,
type_class=alert.type_class.value if hasattr(alert.type_class, 'value') else alert.type_class,
actions=actions, # Use converted actions
alert_metadata=alert.alert_metadata,
created_at=alert.created_at,
updated_at=alert.updated_at,
resolved_at=alert.resolved_at
)
for alert in alerts
]
))
return AlertsListResponse(
alerts=alert_responses,
@@ -214,17 +236,35 @@ async def get_alert(
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
# Handle old format actions (strings) by converting to proper dict format
actions = alert.smart_actions
if actions and isinstance(actions, list) and len(actions) > 0:
# Check if actions are strings (old format)
if isinstance(actions[0], str):
# Convert old format to new format
actions = [
{
'action_type': action,
'label': action.replace('_', ' ').title(),
'variant': 'default',
'disabled': False
}
for action in actions
]
return AlertResponse(
id=str(alert.id),
tenant_id=str(alert.tenant_id),
item_type=alert.item_type,
alert_type=alert.alert_type,
severity=alert.severity,
status=alert.status,
priority_level=alert.priority_level.value if hasattr(alert.priority_level, 'value') else alert.priority_level,
priority_score=alert.priority_score,
status=alert.status.value if hasattr(alert.status, 'value') else alert.status,
service=alert.service,
title=alert.title,
message=alert.message,
actions=alert.actions,
type_class=alert.type_class.value if hasattr(alert.type_class, 'value') else alert.type_class,
actions=actions, # Use converted actions
alert_metadata=alert.alert_metadata,
created_at=alert.created_at,
updated_at=alert.updated_at,
@@ -236,3 +276,242 @@ async def get_alert(
except Exception as e:
logger.error("Error getting alert", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/cancel-auto-action",
summary="Cancel auto-action for escalation alert",
description="Cancel the pending auto-action for an escalation-type alert"
)
async def cancel_auto_action(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID")
) -> dict:
"""
Cancel the auto-action scheduled for an escalation alert.
This prevents the system from automatically executing the action.
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import AlertStatus
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
alert = await repo.get_alert_by_id(tenant_id, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
# Verify this is an escalation alert
if alert.type_class != 'escalation':
raise HTTPException(
status_code=400,
detail="Alert is not an escalation type, no auto-action to cancel"
)
# Update alert metadata to mark auto-action as cancelled
alert.alert_metadata = alert.alert_metadata or {}
alert.alert_metadata['auto_action_cancelled'] = True
alert.alert_metadata['auto_action_cancelled_at'] = datetime.utcnow().isoformat()
# Update urgency context to remove countdown
if alert.urgency_context:
alert.urgency_context['auto_action_countdown_seconds'] = None
alert.urgency_context['auto_action_cancelled'] = True
# Change type class from escalation to action_needed
alert.type_class = 'action_needed'
await session.commit()
await session.refresh(alert)
logger.info("Auto-action cancelled", alert_id=str(alert_id), tenant_id=str(tenant_id))
return {
"success": True,
"alert_id": str(alert_id),
"message": "Auto-action cancelled successfully",
"updated_type_class": alert.type_class.value
}
except HTTPException:
raise
except Exception as e:
logger.error("Error cancelling auto-action", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/acknowledge",
summary="Acknowledge alert",
description="Mark alert as acknowledged"
)
async def acknowledge_alert(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID")
) -> dict:
"""Mark an alert as acknowledged"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import AlertStatus
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
alert = await repo.get_alert_by_id(tenant_id, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
alert.status = AlertStatus.ACKNOWLEDGED
await session.commit()
logger.info("Alert acknowledged", alert_id=str(alert_id), tenant_id=str(tenant_id))
return {
"success": True,
"alert_id": str(alert_id),
"status": alert.status.value
}
except HTTPException:
raise
except Exception as e:
logger.error("Error acknowledging alert", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/resolve",
summary="Resolve alert",
description="Mark alert as resolved"
)
async def resolve_alert(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID")
) -> dict:
"""Mark an alert as resolved"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import AlertStatus
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
alert = await repo.get_alert_by_id(tenant_id, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
alert.status = AlertStatus.RESOLVED
alert.resolved_at = datetime.utcnow()
await session.commit()
logger.info("Alert resolved", alert_id=str(alert_id), tenant_id=str(tenant_id))
return {
"success": True,
"alert_id": str(alert_id),
"status": alert.status.value,
"resolved_at": alert.resolved_at.isoformat()
}
except HTTPException:
raise
except Exception as e:
logger.error("Error resolving alert", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/digest/send",
summary="Send email digest for alerts"
)
async def send_alert_digest(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(1, ge=1, le=7, description="Number of days to include in digest"),
digest_type: str = Query("daily", description="Type of digest: daily or weekly"),
user_email: str = Query(..., description="Email address to send digest to"),
user_name: str = Query(None, description="User name for personalization"),
current_user: dict = Depends(get_current_user)
):
"""
Send email digest of alerts.
Digest includes:
- AI Impact Summary (prevented issues, savings)
- Prevented Issues List with AI reasoning
- Action Needed Alerts
- Trend Warnings
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import Alert
from app.services.enrichment.email_digest import EmailDigestService
from sqlalchemy import select, and_
from datetime import datetime, timedelta
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
cutoff_date = datetime.utcnow() - timedelta(days=days)
# Fetch alerts from the specified period
query = select(Alert).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).order_by(Alert.created_at.desc())
result = await session.execute(query)
alerts = result.scalars().all()
if not alerts:
return {
"success": False,
"message": "No alerts found for the specified period",
"alert_count": 0
}
# Send digest
digest_service = EmailDigestService(config)
if digest_type == "weekly":
success = await digest_service.send_weekly_digest(
tenant_id=tenant_id,
alerts=alerts,
user_email=user_email,
user_name=user_name
)
else:
success = await digest_service.send_daily_digest(
tenant_id=tenant_id,
alerts=alerts,
user_email=user_email,
user_name=user_name
)
return {
"success": success,
"message": f"{'Successfully sent' if success else 'Failed to send'} {digest_type} digest",
"alert_count": len(alerts),
"digest_type": digest_type,
"recipient": user_email
}
except Exception as e:
logger.error("Error sending email digest", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to send email digest: {str(e)}")

View File

@@ -239,6 +239,166 @@ async def get_trends(
raise HTTPException(status_code=500, detail=f"Failed to get trends: {str(e)}")
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/analytics/dashboard",
response_model=Dict[str, Any],
summary="Get enriched alert analytics for dashboard"
)
async def get_dashboard_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(30, ge=1, le=90, description="Number of days to analyze"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Get enriched alert analytics optimized for dashboard display.
Returns metrics based on the new enrichment system:
- AI handling rate (% of prevented_issue alerts)
- Priority distribution (critical, important, standard, info)
- Type class breakdown (action_needed, prevented_issue, trend_warning, etc.)
- Total financial impact at risk
- Average response time by priority level
- Prevented issues and estimated savings
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import Alert, AlertStatus, AlertTypeClass, PriorityLevel
from sqlalchemy import select, func, and_
from datetime import datetime, timedelta
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
cutoff_date = datetime.utcnow() - timedelta(days=days)
# Total alerts
total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
)
total_result = await session.execute(total_query)
total_alerts = total_result.scalar() or 0
# Priority distribution
priority_query = select(
Alert.priority_level,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).group_by(Alert.priority_level)
priority_result = await session.execute(priority_query)
priority_dist = {row.priority_level: row.count for row in priority_result}
# Type class distribution
type_class_query = select(
Alert.type_class,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).group_by(Alert.type_class)
type_class_result = await session.execute(type_class_query)
type_class_dist = {row.type_class: row.count for row in type_class_result}
# AI handling metrics
prevented_count = type_class_dist.get(AlertTypeClass.PREVENTED_ISSUE, 0)
ai_handling_percentage = (prevented_count / total_alerts * 100) if total_alerts > 0 else 0
# Financial impact - sum all business_impact.financial_impact_eur from active alerts
active_alerts_query = select(Alert.id, Alert.business_impact).where(
and_(
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE
)
)
active_alerts_result = await session.execute(active_alerts_query)
active_alerts = active_alerts_result.all()
total_financial_impact = sum(
(alert.business_impact or {}).get('financial_impact_eur', 0)
for alert in active_alerts
)
# Prevented issues savings
prevented_alerts_query = select(Alert.id, Alert.orchestrator_context).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= cutoff_date
)
)
prevented_alerts_result = await session.execute(prevented_alerts_query)
prevented_alerts = prevented_alerts_result.all()
estimated_savings = sum(
(alert.orchestrator_context or {}).get('estimated_savings_eur', 0)
for alert in prevented_alerts
)
# Active alerts by type class
active_by_type_query = select(
Alert.type_class,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE
)
).group_by(Alert.type_class)
active_by_type_result = await session.execute(active_by_type_query)
active_by_type = {row.type_class: row.count for row in active_by_type_result}
# Get period comparison for trends
from app.repositories.analytics_repository import AlertAnalyticsRepository
analytics_repo = AlertAnalyticsRepository(session)
period_comparison = await analytics_repo.get_period_comparison(
tenant_id=tenant_id,
current_days=days,
previous_days=days
)
return {
"period_days": days,
"total_alerts": total_alerts,
"active_alerts": len(active_alerts),
"ai_handling_rate": round(ai_handling_percentage, 1),
"prevented_issues_count": prevented_count,
"estimated_savings_eur": round(estimated_savings, 2),
"total_financial_impact_at_risk_eur": round(total_financial_impact, 2),
"priority_distribution": {
"critical": priority_dist.get(PriorityLevel.CRITICAL, 0),
"important": priority_dist.get(PriorityLevel.IMPORTANT, 0),
"standard": priority_dist.get(PriorityLevel.STANDARD, 0),
"info": priority_dist.get(PriorityLevel.INFO, 0)
},
"type_class_distribution": {
"action_needed": type_class_dist.get(AlertTypeClass.ACTION_NEEDED, 0),
"prevented_issue": type_class_dist.get(AlertTypeClass.PREVENTED_ISSUE, 0),
"trend_warning": type_class_dist.get(AlertTypeClass.TREND_WARNING, 0),
"escalation": type_class_dist.get(AlertTypeClass.ESCALATION, 0),
"information": type_class_dist.get(AlertTypeClass.INFORMATION, 0)
},
"active_by_type_class": active_by_type,
"period_comparison": period_comparison
}
except Exception as e:
logger.error("Failed to get dashboard analytics", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to get dashboard analytics: {str(e)}")
# ============================================================================
# Tenant Data Deletion Operations (Internal Service Only)
# ============================================================================

View File

@@ -0,0 +1,305 @@
"""
Internal Demo Cloning API for Alert Processor Service
Service-to-service endpoint for cloning alert data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
import os
from app.repositories.alerts_repository import AlertsRepository
from app.models.events import Alert, AlertStatus, AlertTypeClass
from app.config import AlertProcessorConfig
import sys
from pathlib import Path
# Add shared utilities to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
from shared.database.base import create_database_manager
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Internal API key for service-to-service auth
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
# Database manager for this module
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor-internal-demo")
# Dependency to get database session
async def get_db():
"""Get database session for internal demo operations"""
async with db_manager.get_session() as session:
yield session
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""
Clone alert service data for a virtual demo tenant
Clones:
- Action-needed alerts (PO approvals, delivery tracking, low stock warnings, production delays)
- Prevented-issue alerts (AI interventions with financial impact)
- Historical trend data over past 7 days
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
session_created_at: Session creation timestamp for date adjustment
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting alert data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"alerts": 0,
"action_needed": 0,
"prevented_issues": 0,
"historical_alerts": 0
}
# Clone Alerts
result = await db.execute(
select(Alert).where(Alert.tenant_id == base_uuid)
)
base_alerts = result.scalars().all()
logger.info(
"Found alerts to clone",
count=len(base_alerts),
base_tenant=str(base_uuid)
)
for alert in base_alerts:
# Adjust dates relative to session creation time
adjusted_created_at = adjust_date_for_demo(
alert.created_at, session_time, BASE_REFERENCE_DATE
) if alert.created_at else session_time
adjusted_updated_at = adjust_date_for_demo(
alert.updated_at, session_time, BASE_REFERENCE_DATE
) if alert.updated_at else session_time
adjusted_resolved_at = adjust_date_for_demo(
alert.resolved_at, session_time, BASE_REFERENCE_DATE
) if alert.resolved_at else None
adjusted_action_created_at = adjust_date_for_demo(
alert.action_created_at, session_time, BASE_REFERENCE_DATE
) if alert.action_created_at else None
adjusted_scheduled_send_time = adjust_date_for_demo(
alert.scheduled_send_time, session_time, BASE_REFERENCE_DATE
) if alert.scheduled_send_time else None
# Update urgency context with adjusted dates if present
urgency_context = alert.urgency_context.copy() if alert.urgency_context else {}
if urgency_context.get("expected_delivery"):
try:
original_delivery = datetime.fromisoformat(urgency_context["expected_delivery"].replace('Z', '+00:00'))
adjusted_delivery = adjust_date_for_demo(original_delivery, session_time, BASE_REFERENCE_DATE)
urgency_context["expected_delivery"] = adjusted_delivery.isoformat() if adjusted_delivery else None
except:
pass # Keep original if parsing fails
new_alert = Alert(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
item_type=alert.item_type,
alert_type=alert.alert_type,
service=alert.service,
title=alert.title,
message=alert.message,
status=alert.status,
priority_score=alert.priority_score,
priority_level=alert.priority_level,
type_class=alert.type_class,
orchestrator_context=alert.orchestrator_context,
business_impact=alert.business_impact,
urgency_context=urgency_context,
user_agency=alert.user_agency,
trend_context=alert.trend_context,
smart_actions=alert.smart_actions,
ai_reasoning_summary=alert.ai_reasoning_summary,
confidence_score=alert.confidence_score,
timing_decision=alert.timing_decision,
scheduled_send_time=adjusted_scheduled_send_time,
placement=alert.placement,
action_created_at=adjusted_action_created_at,
superseded_by_action_id=None, # Don't clone superseded relationships
hidden_from_ui=alert.hidden_from_ui,
alert_metadata=alert.alert_metadata,
created_at=adjusted_created_at,
updated_at=adjusted_updated_at,
resolved_at=adjusted_resolved_at
)
db.add(new_alert)
stats["alerts"] += 1
# Track by type_class
if alert.type_class == "action_needed":
stats["action_needed"] += 1
elif alert.type_class == "prevented_issue":
stats["prevented_issues"] += 1
# Track historical (older than 1 day)
if adjusted_created_at < session_time - timedelta(days=1):
stats["historical_alerts"] += 1
# Commit cloned data
await db.commit()
total_records = stats["alerts"]
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Alert data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "alert_processor",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone alert data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "alert_processor",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "alert_processor",
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""Delete all alert data for a virtual demo tenant"""
logger.info("Deleting alert data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records
alert_count = await db.scalar(
select(func.count(Alert.id)).where(Alert.tenant_id == virtual_uuid)
)
# Delete alerts
await db.execute(delete(Alert).where(Alert.tenant_id == virtual_uuid))
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Alert data deleted successfully",
virtual_tenant_id=virtual_tenant_id,
duration_ms=duration_ms
)
return {
"service": "alert_processor",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"alerts": alert_count,
"total": alert_count
},
"duration_ms": duration_ms
}
except Exception as e:
logger.error("Failed to delete alert data", error=str(e), exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
import structlog
from app.config import AlertProcessorConfig
from app.api import analytics_router, alerts_router
from app.api import analytics_router, alerts_router, internal_demo_router
from shared.database.base import create_database_manager
logger = structlog.get_logger()
@@ -32,6 +32,7 @@ app.add_middleware(
# Include routers
app.include_router(analytics_router, tags=["analytics"])
app.include_router(alerts_router, tags=["alerts"])
app.include_router(internal_demo_router, tags=["internal"])
# Initialize database
config = AlertProcessorConfig()
@@ -45,7 +46,7 @@ async def startup():
# Create tables
try:
from app.models.alerts import Base
from shared.database.base import Base
await db_manager.create_tables(Base.metadata)
logger.info("Database tables ensured")
except Exception as e:

View File

@@ -57,4 +57,61 @@ class AlertProcessorConfig(BaseServiceSettings):
@property
def low_channels(self) -> List[str]:
return ["dashboard"]
return ["dashboard"]
# ============================================================
# ENRICHMENT CONFIGURATION (NEW)
# ============================================================
# Priority scoring weights
BUSINESS_IMPACT_WEIGHT: float = float(os.getenv("BUSINESS_IMPACT_WEIGHT", "0.4"))
URGENCY_WEIGHT: float = float(os.getenv("URGENCY_WEIGHT", "0.3"))
USER_AGENCY_WEIGHT: float = float(os.getenv("USER_AGENCY_WEIGHT", "0.2"))
CONFIDENCE_WEIGHT: float = float(os.getenv("CONFIDENCE_WEIGHT", "0.1"))
# Priority thresholds
CRITICAL_THRESHOLD: int = int(os.getenv("CRITICAL_THRESHOLD", "90"))
IMPORTANT_THRESHOLD: int = int(os.getenv("IMPORTANT_THRESHOLD", "70"))
STANDARD_THRESHOLD: int = int(os.getenv("STANDARD_THRESHOLD", "50"))
# Timing intelligence
TIMING_INTELLIGENCE_ENABLED: bool = os.getenv("TIMING_INTELLIGENCE_ENABLED", "true").lower() == "true"
BATCH_LOW_PRIORITY_ALERTS: bool = os.getenv("BATCH_LOW_PRIORITY_ALERTS", "true").lower() == "true"
BUSINESS_HOURS_START: int = int(os.getenv("BUSINESS_HOURS_START", "6"))
BUSINESS_HOURS_END: int = int(os.getenv("BUSINESS_HOURS_END", "22"))
PEAK_HOURS_START: int = int(os.getenv("PEAK_HOURS_START", "7"))
PEAK_HOURS_END: int = int(os.getenv("PEAK_HOURS_END", "11"))
PEAK_HOURS_EVENING_START: int = int(os.getenv("PEAK_HOURS_EVENING_START", "17"))
PEAK_HOURS_EVENING_END: int = int(os.getenv("PEAK_HOURS_EVENING_END", "19"))
# Grouping
GROUPING_TIME_WINDOW_MINUTES: int = int(os.getenv("GROUPING_TIME_WINDOW_MINUTES", "15"))
MAX_ALERTS_PER_GROUP: int = int(os.getenv("MAX_ALERTS_PER_GROUP", "5"))
# Email digest
EMAIL_DIGEST_ENABLED: bool = os.getenv("EMAIL_DIGEST_ENABLED", "true").lower() == "true"
DIGEST_SEND_TIME: str = os.getenv("DIGEST_SEND_TIME", "18:00")
DIGEST_SEND_TIME_HOUR: int = int(os.getenv("DIGEST_SEND_TIME", "18:00").split(":")[0])
DIGEST_MIN_ALERTS: int = int(os.getenv("DIGEST_MIN_ALERTS", "5"))
# Alert grouping
ALERT_GROUPING_ENABLED: bool = os.getenv("ALERT_GROUPING_ENABLED", "true").lower() == "true"
MIN_ALERTS_FOR_GROUPING: int = int(os.getenv("MIN_ALERTS_FOR_GROUPING", "3"))
# Trend detection
TREND_DETECTION_ENABLED: bool = os.getenv("TREND_DETECTION_ENABLED", "true").lower() == "true"
TREND_LOOKBACK_DAYS: int = int(os.getenv("TREND_LOOKBACK_DAYS", "7"))
TREND_SIGNIFICANCE_THRESHOLD: float = float(os.getenv("TREND_SIGNIFICANCE_THRESHOLD", "0.15"))
# Context enrichment
ENRICHMENT_TIMEOUT_SECONDS: int = int(os.getenv("ENRICHMENT_TIMEOUT_SECONDS", "10"))
ORCHESTRATOR_CONTEXT_CACHE_TTL: int = int(os.getenv("ORCHESTRATOR_CONTEXT_CACHE_TTL", "300"))
# Peak hours (aliases for enrichment services)
EVENING_PEAK_START: int = int(os.getenv("PEAK_HOURS_EVENING_START", "17"))
EVENING_PEAK_END: int = int(os.getenv("PEAK_HOURS_EVENING_END", "19"))
# Service URLs for enrichment
ORCHESTRATOR_SERVICE_URL: str = os.getenv("ORCHESTRATOR_SERVICE_URL", "http://orchestrator-service:8000")
INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
PRODUCTION_SERVICE_URL: str = os.getenv("PRODUCTION_SERVICE_URL", "http://production-service:8000")

View File

@@ -0,0 +1,56 @@
"""
FastAPI dependencies for alert processor service
"""
from fastapi import Header, HTTPException, status
from typing import Optional
async def get_current_user(
authorization: Optional[str] = Header(None)
) -> dict:
"""
Extract and validate user from JWT token in Authorization header.
In production, this should verify the JWT token against auth service.
For now, we accept any Authorization header as valid.
Args:
authorization: Bearer token from Authorization header
Returns:
dict: User information extracted from token
Raises:
HTTPException: If no authorization header provided
"""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
# In production, verify JWT and extract user info
# For now, return a basic user dict
return {
"user_id": "system",
"tenant_id": None, # Will be extracted from path parameter
"authenticated": True
}
async def get_optional_user(
authorization: Optional[str] = Header(None)
) -> Optional[dict]:
"""
Optional authentication dependency.
Returns user if authenticated, None otherwise.
"""
if not authorization:
return None
try:
return await get_current_user(authorization)
except HTTPException:
return None

View File

@@ -0,0 +1,12 @@
"""
Scheduled Jobs Package
Contains background jobs for the alert processor service.
"""
from .priority_recalculation import PriorityRecalculationJob, run_priority_recalculation_job
__all__ = [
"PriorityRecalculationJob",
"run_priority_recalculation_job",
]

View File

@@ -0,0 +1,44 @@
"""
Main entry point for alert processor jobs when run as modules.
This file makes the jobs package executable as a module:
`python -m app.jobs.priority_recalculation`
"""
import asyncio
import sys
import os
from pathlib import Path
# Add the app directory to Python path
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "shared"))
from app.jobs.priority_recalculation import run_priority_recalculation_job
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.core.cache import get_redis_client
async def main():
"""Main entry point for the priority recalculation job."""
# Initialize services
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
redis_client = await get_redis_client()
try:
# Run the priority recalculation job
results = await run_priority_recalculation_job(
config=config,
db_manager=db_manager,
redis_client=redis_client
)
print(f"Priority recalculation completed: {results}")
except Exception as e:
print(f"Error running priority recalculation job: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,337 @@
"""
Priority Recalculation Job
Scheduled job that recalculates priority scores for active alerts,
applying time-based escalation boosts.
Runs hourly to ensure stale actions get escalated appropriately.
"""
import structlog
from datetime import datetime, timedelta, timezone
from typing import Dict, List
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.events import Alert, AlertStatus
from app.services.enrichment.priority_scoring import PriorityScoringService
from shared.schemas.alert_types import UrgencyContext
logger = structlog.get_logger()
class PriorityRecalculationJob:
"""Recalculates alert priorities with time-based escalation"""
def __init__(self, config, db_manager, redis_client):
self.config = config
self.db_manager = db_manager
self.redis = redis_client
self.priority_service = PriorityScoringService(config)
async def run(self, tenant_id: UUID = None) -> Dict[str, int]:
"""
Recalculate priorities for all active action-needed alerts.
Args:
tenant_id: Optional tenant filter. If None, runs for all tenants.
Returns:
Dict with counts: {
'processed': int,
'escalated': int,
'errors': int
}
"""
logger.info("Starting priority recalculation job", tenant_id=str(tenant_id) if tenant_id else "all")
counts = {
'processed': 0,
'escalated': 0,
'errors': 0
}
try:
# Process alerts in batches to avoid memory issues and timeouts
batch_size = 50 # Process 50 alerts at a time to prevent timeouts
# Get tenant IDs to process
tenant_ids = [tenant_id] if tenant_id else await self._get_tenant_ids()
for current_tenant_id in tenant_ids:
offset = 0
while True:
async with self.db_manager.get_session() as session:
# Get a batch of active alerts
alerts_batch = await self._get_active_alerts_batch(session, current_tenant_id, offset, batch_size)
if not alerts_batch:
break # No more alerts to process
logger.info(f"Processing batch of {len(alerts_batch)} alerts for tenant {current_tenant_id}, offset {offset}")
for alert in alerts_batch:
try:
result = await self._recalculate_alert_priority(session, alert)
counts['processed'] += 1
if result['escalated']:
counts['escalated'] += 1
except Exception as e:
logger.error(
"Error recalculating alert priority",
alert_id=str(alert.id),
error=str(e)
)
counts['errors'] += 1
# Commit this batch
await session.commit()
# Update offset for next batch
offset += batch_size
# Log progress periodically
if offset % (batch_size * 10) == 0: # Every 10 batches
logger.info(
"Priority recalculation progress update",
tenant_id=str(current_tenant_id),
processed=counts['processed'],
escalated=counts['escalated'],
errors=counts['errors']
)
logger.info(
"Tenant priority recalculation completed",
tenant_id=str(current_tenant_id),
processed=counts['processed'],
escalated=counts['escalated'],
errors=counts['errors']
)
logger.info(
"Priority recalculation completed for all tenants",
**counts
)
except Exception as e:
logger.error(
"Priority recalculation job failed",
error=str(e)
)
counts['errors'] += 1
return counts
async def _get_active_alerts(
self,
session: AsyncSession,
tenant_id: UUID = None
) -> List[Alert]:
"""
Get all active alerts that need priority recalculation.
Filters:
- Status: active
- Type class: action_needed (only these can escalate)
- Has action_created_at set
"""
stmt = select(Alert).where(
Alert.status == AlertStatus.ACTIVE,
Alert.type_class == 'action_needed',
Alert.action_created_at.isnot(None),
Alert.hidden_from_ui == False
)
if tenant_id:
stmt = stmt.where(Alert.tenant_id == tenant_id)
# Order by oldest first (most likely to need escalation)
stmt = stmt.order_by(Alert.action_created_at.asc())
result = await session.execute(stmt)
return result.scalars().all()
async def _get_tenant_ids(self) -> List[UUID]:
"""
Get all unique tenant IDs that have active alerts that need recalculation.
"""
async with self.db_manager.get_session() as session:
# Get unique tenant IDs with active alerts
stmt = select(Alert.tenant_id).distinct().where(
Alert.status == AlertStatus.ACTIVE,
Alert.type_class == 'action_needed',
Alert.action_created_at.isnot(None),
Alert.hidden_from_ui == False
)
result = await session.execute(stmt)
tenant_ids = result.scalars().all()
return tenant_ids
async def _get_active_alerts_batch(
self,
session: AsyncSession,
tenant_id: UUID,
offset: int,
limit: int
) -> List[Alert]:
"""
Get a batch of active alerts that need priority recalculation.
Filters:
- Status: active
- Type class: action_needed (only these can escalate)
- Has action_created_at set
"""
stmt = select(Alert).where(
Alert.status == AlertStatus.ACTIVE,
Alert.type_class == 'action_needed',
Alert.action_created_at.isnot(None),
Alert.hidden_from_ui == False
)
if tenant_id:
stmt = stmt.where(Alert.tenant_id == tenant_id)
# Order by oldest first (most likely to need escalation)
stmt = stmt.order_by(Alert.action_created_at.asc())
# Apply offset and limit for batching
stmt = stmt.offset(offset).limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
async def _recalculate_alert_priority(
self,
session: AsyncSession,
alert: Alert
) -> Dict[str, any]:
"""
Recalculate priority for a single alert with escalation boost.
Returns:
Dict with 'old_score', 'new_score', 'escalated' (bool)
"""
old_score = alert.priority_score
# Build urgency context from alert metadata
urgency_context = None
if alert.urgency_context:
urgency_context = UrgencyContext(**alert.urgency_context)
# Calculate escalation boost
boost = self.priority_service.calculate_escalation_boost(
action_created_at=alert.action_created_at,
urgency_context=urgency_context,
current_priority=old_score
)
# Apply boost
new_score = min(100, old_score + boost)
# Update if score changed
if new_score != old_score:
# Update priority score and level
new_level = self.priority_service.get_priority_level(new_score)
alert.priority_score = new_score
alert.priority_level = new_level
alert.updated_at = datetime.now(timezone.utc)
# Add escalation metadata
if not alert.alert_metadata:
alert.alert_metadata = {}
alert.alert_metadata['escalation'] = {
'original_score': old_score,
'boost_applied': boost,
'escalated_at': datetime.now(timezone.utc).isoformat(),
'reason': 'time_based_escalation'
}
# Invalidate cache
cache_key = f"alert:{alert.tenant_id}:{alert.id}"
await self.redis.delete(cache_key)
logger.info(
"Alert priority escalated",
alert_id=str(alert.id),
old_score=old_score,
new_score=new_score,
boost=boost,
old_level=alert.priority_level if old_score == new_score else self.priority_service.get_priority_level(old_score),
new_level=new_level
)
return {
'old_score': old_score,
'new_score': new_score,
'escalated': True
}
return {
'old_score': old_score,
'new_score': new_score,
'escalated': False
}
async def run_for_all_tenants(self) -> Dict[str, Dict[str, int]]:
"""
Run recalculation for all tenants.
Returns:
Dict mapping tenant_id to counts
"""
logger.info("Running priority recalculation for all tenants")
all_results = {}
try:
# Get unique tenant IDs with active alerts using the new efficient method
tenant_ids = await self._get_tenant_ids()
logger.info(f"Found {len(tenant_ids)} tenants with active alerts")
for tenant_id in tenant_ids:
try:
counts = await self.run(tenant_id)
all_results[str(tenant_id)] = counts
except Exception as e:
logger.error(
"Error processing tenant",
tenant_id=str(tenant_id),
error=str(e)
)
total_processed = sum(r['processed'] for r in all_results.values())
total_escalated = sum(r['escalated'] for r in all_results.values())
total_errors = sum(r['errors'] for r in all_results.values())
logger.info(
"All tenants processed",
tenants=len(all_results),
total_processed=total_processed,
total_escalated=total_escalated,
total_errors=total_errors
)
except Exception as e:
logger.error(
"Failed to run for all tenants",
error=str(e)
)
return all_results
async def run_priority_recalculation_job(config, db_manager, redis_client):
"""
Main entry point for scheduled job.
This is called by the scheduler (cron/celery/etc).
"""
job = PriorityRecalculationJob(config, db_manager, redis_client)
return await job.run_for_all_tenants()

View File

@@ -19,14 +19,33 @@ from shared.database.base import create_database_manager
from shared.clients.base_service_client import BaseServiceClient
from shared.config.rabbitmq_config import RABBITMQ_CONFIG
# Import enrichment services
from app.services.enrichment import (
PriorityScoringService,
ContextEnrichmentService,
TimingIntelligenceService,
OrchestratorClient
)
from shared.schemas.alert_types import RawAlert
# Setup logging
import logging
# Configure Python's standard logging first (required for structlog.stdlib.LoggerFactory)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=logging.INFO,
)
# Configure structlog to use the standard logging backend
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="ISO"),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer()
@@ -81,12 +100,19 @@ class AlertProcessorService:
self.connection = None
self.channel = None
self.running = False
# Initialize enrichment services (context_enrichment initialized after Redis connection)
self.orchestrator_client = OrchestratorClient(config.ORCHESTRATOR_SERVICE_URL)
self.context_enrichment = None # Initialized in start() after Redis connection
self.priority_scoring = PriorityScoringService(config)
self.timing_intelligence = TimingIntelligenceService(config)
# Metrics
self.items_processed = 0
self.items_stored = 0
self.notifications_sent = 0
self.errors_count = 0
self.enrichments_count = 0
async def start(self):
"""Start the alert processor service"""
@@ -97,16 +123,20 @@ class AlertProcessorService:
await initialize_redis(self.config.REDIS_URL, db=0, max_connections=20)
self.redis = await get_redis_client()
logger.info("Connected to Redis")
# Initialize context enrichment service now that Redis is available
self.context_enrichment = ContextEnrichmentService(self.config, self.db_manager, self.redis)
logger.info("Initialized context enrichment service")
# Connect to RabbitMQ
await self._setup_rabbitmq()
# Start consuming messages
await self._start_consuming()
self.running = True
logger.info("Alert Processor Service started successfully")
except Exception as e:
logger.error("Failed to start Alert Processor Service", error=str(e))
raise
@@ -152,102 +182,202 @@ class AlertProcessorService:
try:
# Parse message
item = json.loads(message.body.decode())
logger.info("Processing item",
logger.info("Processing item",
item_type=item.get('item_type'),
alert_type=item.get('type'),
severity=item.get('severity'),
priority_level=item.get('priority_level', 'standard'),
tenant_id=item.get('tenant_id'))
# Store in database
stored_item = await self.store_item(item)
# ENRICH ALERT BEFORE STORAGE
enriched_item = await self.enrich_alert(item)
self.enrichments_count += 1
# Store enriched alert in database
stored_item = await self.store_enriched_item(enriched_item)
self.items_stored += 1
# Determine delivery channels based on severity and type
channels = self.get_channels_by_severity_and_type(
item['severity'],
item['item_type']
)
# Determine delivery channels based on priority score (not severity)
channels = self.get_channels_by_priority(enriched_item['priority_score'])
# Send via notification service if channels are specified
if channels:
notification_result = await self.notification_client.send_notification(
tenant_id=item['tenant_id'],
tenant_id=enriched_item['tenant_id'],
notification={
'type': item['item_type'], # 'alert' or 'recommendation'
'id': item['id'],
'title': item['title'],
'message': item['message'],
'severity': item['severity'],
'metadata': item.get('metadata', {}),
'actions': item.get('actions', []),
'email': item.get('email'),
'phone': item.get('phone'),
'user_id': item.get('user_id')
'type': enriched_item['item_type'],
'id': enriched_item['id'],
'title': enriched_item['title'],
'message': enriched_item['message'],
'priority_score': enriched_item['priority_score'],
'priority_level': enriched_item['priority_level'],
'type_class': enriched_item['type_class'],
'metadata': enriched_item.get('metadata', {}),
'actions': enriched_item.get('smart_actions', []),
'ai_reasoning_summary': enriched_item.get('ai_reasoning_summary'),
'email': enriched_item.get('email'),
'phone': enriched_item.get('phone'),
'user_id': enriched_item.get('user_id')
},
channels=channels
)
if notification_result and notification_result.get('status') == 'success':
self.notifications_sent += 1
# Stream to SSE for real-time dashboard (always)
await self.stream_to_sse(item['tenant_id'], stored_item)
# Stream enriched alert to SSE for real-time dashboard (always)
await self.stream_to_sse(enriched_item['tenant_id'], stored_item)
self.items_processed += 1
logger.info("Item processed successfully",
item_id=item['id'],
logger.info("Item processed successfully",
item_id=enriched_item['id'],
priority_score=enriched_item['priority_score'],
priority_level=enriched_item['priority_level'],
channels=len(channels))
except Exception as e:
self.errors_count += 1
logger.error("Item processing failed", error=str(e))
raise
async def store_item(self, item: dict) -> dict:
"""Store alert or recommendation in database and cache in Redis"""
from app.models.alerts import Alert, AlertSeverity, AlertStatus
async def enrich_alert(self, item: dict) -> dict:
"""
Enrich alert with priority scoring, context, and smart actions.
All alerts MUST be enriched - no legacy support.
"""
try:
# Convert dict to RawAlert model
# Map 'type' to 'alert_type' and 'metadata' to 'alert_metadata'
raw_alert = RawAlert(
tenant_id=item['tenant_id'],
alert_type=item.get('type', item.get('alert_type', 'unknown')),
title=item['title'],
message=item['message'],
service=item['service'],
actions=item.get('actions', []),
alert_metadata=item.get('metadata', item.get('alert_metadata', {})),
item_type=item.get('item_type', 'alert')
)
# Enrich with orchestrator context (AI actions, business impact)
enriched = await self.context_enrichment.enrich_alert(raw_alert)
# Convert EnrichedAlert back to dict and merge with original item
# Use mode='json' to properly serialize datetime objects to ISO strings
enriched_dict = enriched.model_dump(mode='json') if hasattr(enriched, 'model_dump') else dict(enriched)
enriched_dict['id'] = item['id'] # Preserve original ID
enriched_dict['item_type'] = item.get('item_type', 'alert') # Preserve item_type
enriched_dict['type'] = enriched_dict.get('alert_type', item.get('type', 'unknown')) # Preserve type field
enriched_dict['timestamp'] = item.get('timestamp', datetime.utcnow().isoformat())
enriched_dict['timing_decision'] = enriched_dict.get('timing_decision', 'send_now') # Default timing decision
# Map 'actions' to 'smart_actions' for database storage
if 'actions' in enriched_dict and 'smart_actions' not in enriched_dict:
enriched_dict['smart_actions'] = enriched_dict['actions']
logger.info("Alert enriched successfully",
alert_id=enriched_dict['id'],
alert_type=enriched_dict.get('alert_type'),
priority_score=enriched_dict['priority_score'],
priority_level=enriched_dict['priority_level'],
type_class=enriched_dict['type_class'],
actions_count=len(enriched_dict.get('actions', [])),
smart_actions_count=len(enriched_dict.get('smart_actions', [])))
return enriched_dict
except Exception as e:
logger.error("Alert enrichment failed, using fallback", error=str(e), alert_id=item.get('id'))
# Fallback: basic enrichment with defaults
return self._create_fallback_enrichment(item)
def _create_fallback_enrichment(self, item: dict) -> dict:
"""
Create fallback enrichment when enrichment services fail.
Ensures all alerts have required enrichment fields.
"""
return {
**item,
'item_type': item.get('item_type', 'alert'), # Ensure item_type is preserved
'type': item.get('type', 'unknown'), # Ensure type field is preserved
'alert_type': item.get('type', item.get('alert_type', 'unknown')), # Ensure alert_type exists
'priority_score': 50,
'priority_level': 'standard',
'type_class': 'action_needed',
'orchestrator_context': None,
'business_impact': None,
'urgency_context': None,
'user_agency': None,
'trend_context': None,
'smart_actions': item.get('actions', []),
'ai_reasoning_summary': None,
'confidence_score': 0.5,
'timing_decision': 'send_now',
'scheduled_send_time': None,
'placement': ['dashboard']
}
async def store_enriched_item(self, enriched_item: dict) -> dict:
"""Store enriched alert in database with all enrichment fields"""
from app.models.events import Alert, AlertStatus
from sqlalchemy import select
async with self.db_manager.get_session() as session:
# Create alert instance
# Create enriched alert instance
alert = Alert(
id=item['id'],
tenant_id=item['tenant_id'],
item_type=item['item_type'], # 'alert' or 'recommendation'
alert_type=item['type'],
severity=AlertSeverity(item['severity'].lower()),
status=AlertStatus.ACTIVE,
service=item['service'],
title=item['title'],
message=item['message'],
actions=item.get('actions', []),
alert_metadata=item.get('metadata', {}),
created_at=datetime.fromisoformat(item['timestamp']) if isinstance(item['timestamp'], str) else item['timestamp']
id=enriched_item['id'],
tenant_id=enriched_item['tenant_id'],
item_type=enriched_item['item_type'],
alert_type=enriched_item['type'],
status='active',
service=enriched_item['service'],
title=enriched_item['title'],
message=enriched_item['message'],
# Enrichment fields (REQUIRED)
priority_score=enriched_item['priority_score'],
priority_level=enriched_item['priority_level'],
type_class=enriched_item['type_class'],
# Context enrichment (JSONB)
orchestrator_context=enriched_item.get('orchestrator_context'),
business_impact=enriched_item.get('business_impact'),
urgency_context=enriched_item.get('urgency_context'),
user_agency=enriched_item.get('user_agency'),
trend_context=enriched_item.get('trend_context'),
# Smart actions
smart_actions=enriched_item.get('smart_actions', []),
# AI reasoning
ai_reasoning_summary=enriched_item.get('ai_reasoning_summary'),
confidence_score=enriched_item.get('confidence_score', 0.8),
# Timing intelligence
timing_decision=enriched_item.get('timing_decision', 'send_now'),
scheduled_send_time=enriched_item.get('scheduled_send_time'),
# Placement
placement=enriched_item.get('placement', ['dashboard']),
# Metadata (legacy)
alert_metadata=enriched_item.get('metadata', {}),
# Timestamp
created_at=datetime.fromisoformat(enriched_item['timestamp']) if isinstance(enriched_item['timestamp'], str) else enriched_item['timestamp']
)
session.add(alert)
await session.commit()
await session.refresh(alert)
logger.debug("Item stored in database", item_id=item['id'])
logger.debug("Enriched item stored in database",
item_id=enriched_item['id'],
priority_score=alert.priority_score,
type_class=alert.type_class)
# Convert to dict for return
alert_dict = {
'id': str(alert.id),
'tenant_id': str(alert.tenant_id),
'item_type': alert.item_type,
'alert_type': alert.alert_type,
'severity': alert.severity.value,
'status': alert.status.value,
'service': alert.service,
'title': alert.title,
'message': alert.message,
'actions': alert.actions,
'metadata': alert.alert_metadata,
'created_at': alert.created_at
}
# Convert to enriched dict for return
alert_dict = alert.to_dict()
# Cache active alerts in Redis for SSE initial_items
await self._cache_active_alerts(str(alert.tenant_id))
@@ -263,7 +393,7 @@ class AlertProcessorService:
Analytics endpoints should query the database directly for historical data.
"""
try:
from app.models.alerts import Alert, AlertStatus
from app.models.events import Alert, AlertStatus
from sqlalchemy import select
async with self.db_manager.get_session() as session:
@@ -281,21 +411,10 @@ class AlertProcessorService:
result = await session.execute(query)
alerts = result.scalars().all()
# Convert to JSON-serializable format
# Convert to enriched JSON-serializable format
active_items = []
for alert in alerts:
active_items.append({
'id': str(alert.id),
'item_type': alert.item_type,
'type': alert.alert_type,
'severity': alert.severity.value,
'title': alert.title,
'message': alert.message,
'actions': alert.actions or [],
'metadata': alert.alert_metadata or {},
'timestamp': alert.created_at.isoformat() if alert.created_at else datetime.utcnow().isoformat(),
'status': alert.status.value
})
active_items.append(alert.to_dict())
# Cache in Redis with 1 hour TTL
cache_key = f"active_alerts:{tenant_id}"
@@ -316,57 +435,51 @@ class AlertProcessorService:
error=str(e))
async def stream_to_sse(self, tenant_id: str, item: dict):
"""Publish item to Redis for SSE streaming"""
"""Publish enriched item to Redis for SSE streaming"""
channel = f"alerts:{tenant_id}"
# Prepare message for SSE
# Item is already enriched dict from store_enriched_item
# Just ensure timestamp is serializable
sse_message = {
'id': item['id'],
'item_type': item['item_type'],
'type': item['alert_type'],
'severity': item['severity'],
'title': item['title'],
'message': item['message'],
'actions': json.loads(item['actions']) if isinstance(item['actions'], str) else item['actions'],
'metadata': json.loads(item['metadata']) if isinstance(item['metadata'], str) else item['metadata'],
'timestamp': item['created_at'].isoformat() if hasattr(item['created_at'], 'isoformat') else item['created_at'],
'status': item['status']
**item,
'timestamp': item['created_at'].isoformat() if hasattr(item['created_at'], 'isoformat') else item['created_at']
}
# Publish to Redis channel for SSE
await self.redis.publish(channel, json.dumps(sse_message))
logger.debug("Item published to SSE", tenant_id=tenant_id, item_id=item['id'])
logger.debug("Enriched item published to SSE",
tenant_id=tenant_id,
item_id=item['id'],
priority_score=item.get('priority_score'))
def get_channels_by_severity_and_type(self, severity: str, item_type: str) -> list:
"""Determine notification channels based on severity, type, and time"""
def get_channels_by_priority(self, priority_score: int) -> list:
"""
Determine notification channels based on priority score and timing.
Uses multi-factor priority score (0-100) instead of legacy severity.
"""
current_hour = datetime.now().hour
channels = ['dashboard'] # Always include dashboard (SSE)
if item_type == 'alert':
if severity == 'urgent':
# Urgent alerts: All channels immediately
channels.extend(['whatsapp', 'email', 'push'])
elif severity == 'high':
# High alerts: WhatsApp and email during extended hours
if 6 <= current_hour <= 22:
channels.extend(['whatsapp', 'email'])
else:
channels.append('email') # Email only during night
elif severity == 'medium':
# Medium alerts: Email during business hours
if 7 <= current_hour <= 20:
channels.append('email')
# Low severity: Dashboard only
elif item_type == 'recommendation':
# Recommendations: Less urgent, limit channels and respect business hours
if severity in ['medium', 'high']:
if 8 <= current_hour <= 19: # Business hours for recommendations
channels.append('email')
# Low/urgent (rare for recs): Dashboard only
# Critical priority (90-100): All channels immediately
if priority_score >= self.config.CRITICAL_THRESHOLD:
channels.extend(['whatsapp', 'email', 'push'])
# Important priority (70-89): WhatsApp and email during extended hours
elif priority_score >= self.config.IMPORTANT_THRESHOLD:
if 6 <= current_hour <= 22:
channels.extend(['whatsapp', 'email'])
else:
channels.append('email') # Email only during night
# Standard priority (50-69): Email during business hours
elif priority_score >= self.config.STANDARD_THRESHOLD:
if 7 <= current_hour <= 20:
channels.append('email')
# Info priority (0-49): Dashboard only
return channels
async def stop(self):
@@ -392,6 +505,7 @@ class AlertProcessorService:
return {
"items_processed": self.items_processed,
"items_stored": self.items_stored,
"enrichments_count": self.enrichments_count,
"notifications_sent": self.notifications_sent,
"errors_count": self.errors_count,
"running": self.running
@@ -399,27 +513,32 @@ class AlertProcessorService:
async def main():
"""Main entry point"""
print("STARTUP: Inside main() function", file=sys.stderr, flush=True)
config = AlertProcessorConfig()
print("STARTUP: Config created", file=sys.stderr, flush=True)
service = AlertProcessorService(config)
print("STARTUP: Service created", file=sys.stderr, flush=True)
# Setup signal handlers for graceful shutdown
async def shutdown():
logger.info("Received shutdown signal")
await service.stop()
sys.exit(0)
# Register signal handlers
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda s, f: asyncio.create_task(shutdown()))
try:
# Start the service
print("STARTUP: About to start service", file=sys.stderr, flush=True)
await service.start()
print("STARTUP: Service started successfully", file=sys.stderr, flush=True)
# Keep running
while service.running:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Received keyboard interrupt")
except Exception as e:
@@ -428,4 +547,13 @@ async def main():
await service.stop()
if __name__ == "__main__":
asyncio.run(main())
print("STARTUP: Entering main block", file=sys.stderr, flush=True)
try:
print("STARTUP: About to run main()", file=sys.stderr, flush=True)
asyncio.run(main())
print("STARTUP: main() completed", file=sys.stderr, flush=True)
except Exception as e:
print(f"STARTUP: FATAL ERROR: {e}", file=sys.stderr, flush=True)
import traceback
traceback.print_exc(file=sys.stderr)
raise

View File

@@ -12,12 +12,31 @@ from shared.database.base import Base
AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .alerts import Alert, AlertStatus, AlertSeverity
from .events import (
Alert,
Notification,
Recommendation,
EventInteraction,
AlertStatus,
PriorityLevel,
AlertTypeClass,
NotificationType,
RecommendationType,
)
# List all models for easier access
__all__ = [
# New event models
"Alert",
"Notification",
"Recommendation",
"EventInteraction",
# Enums
"AlertStatus",
"AlertSeverity",
"PriorityLevel",
"AlertTypeClass",
"NotificationType",
"RecommendationType",
# System
"AuditLog",
]

View File

@@ -1,90 +0,0 @@
# services/alert_processor/app/models/alerts.py
"""
Alert models for the alert processor service
"""
from sqlalchemy import Column, String, Text, DateTime, JSON, Enum, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime, timezone
import uuid
import enum
from shared.database.base import Base
def utc_now():
"""Return current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
class AlertStatus(enum.Enum):
"""Alert status values"""
ACTIVE = "active"
RESOLVED = "resolved"
ACKNOWLEDGED = "acknowledged"
IGNORED = "ignored"
class AlertSeverity(enum.Enum):
"""Alert severity levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class InteractionType(enum.Enum):
"""Alert interaction types"""
ACKNOWLEDGED = "acknowledged"
RESOLVED = "resolved"
SNOOZED = "snoozed"
DISMISSED = "dismissed"
class Alert(Base):
"""Alert records for the alert processor service"""
__tablename__ = "alerts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Alert classification
item_type = Column(String(50), nullable=False) # 'alert' or 'recommendation'
alert_type = Column(String(100), nullable=False) # e.g., 'overstock_warning'
severity = Column(Enum(AlertSeverity, values_callable=lambda obj: [e.value for e in obj]), nullable=False, index=True)
status = Column(Enum(AlertStatus, values_callable=lambda obj: [e.value for e in obj]), default=AlertStatus.ACTIVE, index=True)
# Source and content
service = Column(String(100), nullable=False) # originating service
title = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
# Actions and metadata
actions = Column(JSON, nullable=True) # List of available actions
alert_metadata = Column(JSON, nullable=True) # Additional alert-specific data
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
resolved_at = Column(DateTime(timezone=True), nullable=True)
class AlertInteraction(Base):
"""Alert interaction tracking for analytics"""
__tablename__ = "alert_interactions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
alert_id = Column(UUID(as_uuid=True), ForeignKey('alerts.id', ondelete='CASCADE'), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Interaction details
interaction_type = Column(String(50), nullable=False, index=True)
interacted_at = Column(DateTime(timezone=True), nullable=False, default=utc_now, index=True)
response_time_seconds = Column(Integer, nullable=True)
# Context
interaction_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), nullable=False, default=utc_now)

View File

@@ -0,0 +1,402 @@
"""
Unified Event Storage Models
This module defines separate storage models for:
- Alerts: Full enrichment, lifecycle tracking
- Notifications: Lightweight, ephemeral (7-day TTL)
- Recommendations: Medium weight, dismissible
Replaces the old single Alert model with semantic clarity.
"""
from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Float, CheckConstraint, Index, Boolean, Enum
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional
import uuid
import enum
from shared.database.base import Base
def utc_now():
"""Return current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
# ============================================================
# ENUMS
# ============================================================
class AlertStatus(enum.Enum):
"""Alert lifecycle status"""
ACTIVE = "active"
RESOLVED = "resolved"
ACKNOWLEDGED = "acknowledged"
IN_PROGRESS = "in_progress"
DISMISSED = "dismissed"
IGNORED = "ignored"
class PriorityLevel(enum.Enum):
"""Priority levels based on multi-factor scoring"""
CRITICAL = "critical" # 90-100
IMPORTANT = "important" # 70-89
STANDARD = "standard" # 50-69
INFO = "info" # 0-49
class AlertTypeClass(enum.Enum):
"""Alert type classification (for alerts only)"""
ACTION_NEEDED = "action_needed" # Requires user action
PREVENTED_ISSUE = "prevented_issue" # AI already handled
TREND_WARNING = "trend_warning" # Pattern detected
ESCALATION = "escalation" # Time-sensitive with countdown
INFORMATION = "information" # FYI only
class NotificationType(enum.Enum):
"""Notification type classification"""
STATE_CHANGE = "state_change"
COMPLETION = "completion"
ARRIVAL = "arrival"
DEPARTURE = "departure"
UPDATE = "update"
SYSTEM_EVENT = "system_event"
class RecommendationType(enum.Enum):
"""Recommendation type classification"""
OPTIMIZATION = "optimization"
COST_REDUCTION = "cost_reduction"
RISK_MITIGATION = "risk_mitigation"
TREND_INSIGHT = "trend_insight"
BEST_PRACTICE = "best_practice"
# ============================================================
# ALERT MODEL (Full Enrichment)
# ============================================================
class Alert(Base):
"""
Alert model with full enrichment capabilities.
Used for EventClass.ALERT only.
Full priority scoring, context enrichment, smart actions, lifecycle tracking.
"""
__tablename__ = "alerts"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
item_type = Column(String(50), nullable=False) # 'alert' or 'recommendation' - from old schema
event_domain = Column(String(50), nullable=True, index=True) # inventory, production, etc. - new field, make nullable for now
alert_type = Column(String(100), nullable=False) # specific type of alert (e.g., 'low_stock', 'supplier_delay') - from old schema
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Alert-specific classification
type_class = Column(
Enum(AlertTypeClass, name='alerttypeclass', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=True
)
# Status
status = Column(
Enum(AlertStatus, name='alertstatus', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
default=AlertStatus.ACTIVE,
nullable=False,
index=True
)
# Priority (multi-factor scored)
priority_score = Column(Integer, nullable=False) # 0-100
priority_level = Column(
Enum(PriorityLevel, name='prioritylevel', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=True
)
# Enrichment context (JSONB)
orchestrator_context = Column(JSONB, nullable=True)
business_impact = Column(JSONB, nullable=True)
urgency_context = Column(JSONB, nullable=True)
user_agency = Column(JSONB, nullable=True)
trend_context = Column(JSONB, nullable=True)
# Smart actions
smart_actions = Column(JSONB, nullable=False)
# AI reasoning
ai_reasoning_summary = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=False, default=0.8)
# Timing intelligence
timing_decision = Column(String(50), nullable=False, default='send_now')
scheduled_send_time = Column(DateTime(timezone=True), nullable=True)
# Placement hints
placement = Column(JSONB, nullable=False)
# Escalation & chaining
action_created_at = Column(DateTime(timezone=True), nullable=True, index=True)
superseded_by_action_id = Column(UUID(as_uuid=True), nullable=True, index=True)
hidden_from_ui = Column(Boolean, default=False, nullable=False, index=True)
# Metadata
alert_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
resolved_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_alerts_tenant_status', 'tenant_id', 'status'),
Index('idx_alerts_priority_score', 'tenant_id', 'priority_score', 'created_at'),
Index('idx_alerts_type_class', 'tenant_id', 'type_class', 'status'),
Index('idx_alerts_domain', 'tenant_id', 'event_domain', 'status'),
Index('idx_alerts_timing', 'timing_decision', 'scheduled_send_time'),
CheckConstraint('priority_score >= 0 AND priority_score <= 100', name='chk_alert_priority_range'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'alert',
'event_domain': self.event_domain,
'event_type': self.alert_type,
'alert_type': self.alert_type, # Frontend expects this field name
'service': self.service,
'title': self.title,
'message': self.message,
'type_class': self.type_class.value if isinstance(self.type_class, AlertTypeClass) else self.type_class,
'status': self.status.value if isinstance(self.status, AlertStatus) else self.status,
'priority_level': self.priority_level.value if isinstance(self.priority_level, PriorityLevel) else self.priority_level,
'priority_score': self.priority_score,
'orchestrator_context': self.orchestrator_context,
'business_impact': self.business_impact,
'urgency_context': self.urgency_context,
'user_agency': self.user_agency,
'trend_context': self.trend_context,
'actions': self.smart_actions,
'ai_reasoning_summary': self.ai_reasoning_summary,
'confidence_score': self.confidence_score,
'timing_decision': self.timing_decision,
'scheduled_send_time': self.scheduled_send_time.isoformat() if self.scheduled_send_time else None,
'placement': self.placement,
'action_created_at': self.action_created_at.isoformat() if self.action_created_at else None,
'superseded_by_action_id': str(self.superseded_by_action_id) if self.superseded_by_action_id else None,
'hidden_from_ui': self.hidden_from_ui,
'alert_metadata': self.alert_metadata, # Frontend expects alert_metadata
'metadata': self.alert_metadata, # Keep legacy field for backwards compat
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None,
}
# ============================================================
# NOTIFICATION MODEL (Lightweight, Ephemeral)
# ============================================================
class Notification(Base):
"""
Notification model for informational state changes.
Used for EventClass.NOTIFICATION only.
Lightweight schema, no priority scoring, no lifecycle, 7-day TTL.
"""
__tablename__ = "notifications"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
event_domain = Column(String(50), nullable=False, index=True)
event_type = Column(String(100), nullable=False)
notification_type = Column(String(50), nullable=False) # NotificationType
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Entity context (optional)
entity_type = Column(String(100), nullable=True) # 'batch', 'delivery', 'po', etc.
entity_id = Column(String(100), nullable=True, index=True)
old_state = Column(String(100), nullable=True)
new_state = Column(String(100), nullable=True)
# Display metadata
notification_metadata = Column(JSONB, nullable=True)
# Placement hints (lightweight)
placement = Column(JSONB, nullable=False, default=['notification_panel'])
# TTL tracking
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
__table_args__ = (
Index('idx_notifications_tenant_domain', 'tenant_id', 'event_domain', 'created_at'),
Index('idx_notifications_entity', 'tenant_id', 'entity_type', 'entity_id'),
Index('idx_notifications_expiry', 'expires_at'),
)
def __init__(self, **kwargs):
"""Set default expiry to 7 days from now"""
if 'expires_at' not in kwargs:
kwargs['expires_at'] = utc_now() + timedelta(days=7)
super().__init__(**kwargs)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'notification',
'event_domain': self.event_domain,
'event_type': self.event_type,
'notification_type': self.notification_type,
'service': self.service,
'title': self.title,
'message': self.message,
'entity_type': self.entity_type,
'entity_id': self.entity_id,
'old_state': self.old_state,
'new_state': self.new_state,
'metadata': self.notification_metadata,
'placement': self.placement,
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
}
# ============================================================
# RECOMMENDATION MODEL (Medium Weight, Dismissible)
# ============================================================
class Recommendation(Base):
"""
Recommendation model for AI-generated suggestions.
Used for EventClass.RECOMMENDATION only.
Medium weight schema, light priority, no orchestrator queries, dismissible.
"""
__tablename__ = "recommendations"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
event_domain = Column(String(50), nullable=False, index=True)
event_type = Column(String(100), nullable=False)
recommendation_type = Column(String(50), nullable=False) # RecommendationType
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Light priority (info by default)
priority_level = Column(String(50), nullable=False, default='info')
# Context (lighter than alerts)
estimated_impact = Column(JSONB, nullable=True)
suggested_actions = Column(JSONB, nullable=True)
# AI reasoning
ai_reasoning_summary = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=True)
# Dismissal tracking
dismissed_at = Column(DateTime(timezone=True), nullable=True, index=True)
dismissed_by = Column(UUID(as_uuid=True), nullable=True)
# Metadata
recommendation_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
__table_args__ = (
Index('idx_recommendations_tenant_domain', 'tenant_id', 'event_domain', 'created_at'),
Index('idx_recommendations_dismissed', 'tenant_id', 'dismissed_at'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'recommendation',
'event_domain': self.event_domain,
'event_type': self.event_type,
'recommendation_type': self.recommendation_type,
'service': self.service,
'title': self.title,
'message': self.message,
'priority_level': self.priority_level,
'estimated_impact': self.estimated_impact,
'suggested_actions': self.suggested_actions,
'ai_reasoning_summary': self.ai_reasoning_summary,
'confidence_score': self.confidence_score,
'dismissed_at': self.dismissed_at.isoformat() if self.dismissed_at else None,
'dismissed_by': str(self.dismissed_by) if self.dismissed_by else None,
'metadata': self.recommendation_metadata,
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
# ============================================================
# INTERACTION TRACKING (Shared across all event types)
# ============================================================
class EventInteraction(Base):
"""Event interaction tracking for analytics"""
__tablename__ = "event_interactions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event reference (polymorphic)
event_id = Column(UUID(as_uuid=True), nullable=False, index=True)
event_class = Column(String(50), nullable=False, index=True) # 'alert', 'notification', 'recommendation'
# User
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Interaction details
interaction_type = Column(String(50), nullable=False, index=True) # acknowledged, resolved, dismissed, clicked, etc.
interacted_at = Column(DateTime(timezone=True), nullable=False, default=utc_now, index=True)
response_time_seconds = Column(Integer, nullable=True)
# Context
interaction_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), nullable=False, default=utc_now)
__table_args__ = (
Index('idx_event_interactions_event', 'event_id', 'event_class'),
Index('idx_event_interactions_user', 'tenant_id', 'user_id', 'interacted_at'),
)

View File

@@ -9,7 +9,7 @@ from typing import List, Dict, Any, Optional
from uuid import UUID
import structlog
from app.models.alerts import Alert, AlertStatus, AlertSeverity
from app.models.events import Alert, AlertStatus
logger = structlog.get_logger()
@@ -23,7 +23,7 @@ class AlertsRepository:
async def get_alerts(
self,
tenant_id: UUID,
severity: Optional[str] = None,
priority_level: Optional[str] = None,
status: Optional[str] = None,
resolved: Optional[bool] = None,
limit: int = 100,
@@ -34,7 +34,7 @@ class AlertsRepository:
Args:
tenant_id: Tenant UUID
severity: Filter by severity (low, medium, high, urgent)
priority_level: Filter by priority level (critical, important, standard, info)
status: Filter by status (active, resolved, acknowledged, ignored)
resolved: Filter by resolved status (True = resolved, False = not resolved, None = all)
limit: Maximum number of results
@@ -47,17 +47,24 @@ class AlertsRepository:
query = select(Alert).where(Alert.tenant_id == tenant_id)
# Apply filters
if severity:
query = query.where(Alert.severity == severity)
if priority_level:
query = query.where(Alert.priority_level == priority_level)
if status:
query = query.where(Alert.status == status)
# Convert string status to enum value
try:
status_enum = AlertStatus(status.lower())
query = query.where(Alert.status == status_enum)
except ValueError:
# Invalid status value, log and continue without filtering
logger.warning("Invalid status value provided", status=status)
pass
if resolved is not None:
if resolved:
query = query.where(Alert.status == AlertStatus.RESOLVED.value)
query = query.where(Alert.status == AlertStatus.RESOLVED)
else:
query = query.where(Alert.status != AlertStatus.RESOLVED.value)
query = query.where(Alert.status != AlertStatus.RESOLVED)
# Order by created_at descending (newest first)
query = query.order_by(Alert.created_at.desc())
@@ -72,7 +79,7 @@ class AlertsRepository:
"Retrieved alerts",
tenant_id=str(tenant_id),
count=len(alerts),
filters={"severity": severity, "status": status, "resolved": resolved}
filters={"priority_level": priority_level, "status": status, "resolved": resolved}
)
return list(alerts)
@@ -83,32 +90,32 @@ class AlertsRepository:
async def get_alerts_summary(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get summary of alerts by severity and status
Get summary of alerts by priority level and status
Args:
tenant_id: Tenant UUID
Returns:
Dict with counts by severity and status
Dict with counts by priority level and status
"""
try:
# Count by severity
severity_query = (
# Count by priority level
priority_query = (
select(
Alert.severity,
Alert.priority_level,
func.count(Alert.id).label("count")
)
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.status != AlertStatus.RESOLVED.value
Alert.status != AlertStatus.RESOLVED
)
)
.group_by(Alert.severity)
.group_by(Alert.priority_level)
)
severity_result = await self.db.execute(severity_query)
severity_counts = {row[0]: row[1] for row in severity_result.all()}
priority_result = await self.db.execute(priority_query)
priority_counts = {row[0]: row[1] for row in priority_result.all()}
# Count by status
status_query = (
@@ -126,19 +133,23 @@ class AlertsRepository:
# Count active alerts (not resolved)
active_count = sum(
count for status, count in status_counts.items()
if status != AlertStatus.RESOLVED.value
if status != AlertStatus.RESOLVED
)
# Convert enum values to strings for dictionary lookups
status_counts_str = {status.value if hasattr(status, 'value') else status: count
for status, count in status_counts.items()}
# Map to expected field names (dashboard expects "critical")
summary = {
"total_count": sum(status_counts.values()),
"active_count": active_count,
"critical_count": severity_counts.get(AlertSeverity.URGENT.value, 0), # Map URGENT to critical
"high_count": severity_counts.get(AlertSeverity.HIGH.value, 0),
"medium_count": severity_counts.get(AlertSeverity.MEDIUM.value, 0),
"low_count": severity_counts.get(AlertSeverity.LOW.value, 0),
"resolved_count": status_counts.get(AlertStatus.RESOLVED.value, 0),
"acknowledged_count": status_counts.get(AlertStatus.ACKNOWLEDGED.value, 0),
"critical_count": priority_counts.get('critical', 0),
"high_count": priority_counts.get('important', 0),
"medium_count": priority_counts.get('standard', 0),
"low_count": priority_counts.get('info', 0),
"resolved_count": status_counts_str.get('resolved', 0),
"acknowledged_count": status_counts_str.get('acknowledged', 0),
}
logger.info(

View File

@@ -10,7 +10,7 @@ from sqlalchemy import select, func, and_, extract, case
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.models.alerts import Alert, AlertInteraction, AlertSeverity, AlertStatus
from app.models.events import Alert, EventInteraction, AlertStatus
logger = structlog.get_logger()
@@ -28,7 +28,7 @@ class AlertAnalyticsRepository:
user_id: UUID,
interaction_type: str,
metadata: Optional[Dict[str, Any]] = None
) -> AlertInteraction:
) -> EventInteraction:
"""Create a new alert interaction"""
# Get alert to calculate response time
@@ -44,7 +44,7 @@ class AlertAnalyticsRepository:
response_time_seconds = int((now - alert.created_at).total_seconds())
# Create interaction
interaction = AlertInteraction(
interaction = EventInteraction(
tenant_id=tenant_id,
alert_id=alert_id,
user_id=user_id,
@@ -81,7 +81,7 @@ class AlertAnalyticsRepository:
self,
tenant_id: UUID,
interactions: List[Dict[str, Any]]
) -> List[AlertInteraction]:
) -> List[EventInteraction]:
"""Create multiple interactions in batch"""
created_interactions = []
@@ -113,22 +113,26 @@ class AlertAnalyticsRepository:
"""Get alert trends for the last N days"""
start_date = datetime.utcnow() - timedelta(days=days)
# Query alerts grouped by date and severity
# Query alerts grouped by date and priority_level (mapping to severity equivalents)
# Critical priority_level maps to urgent severity
# Important priority_level maps to high severity
# Standard priority_level maps to medium severity
# Info priority_level maps to low severity
query = (
select(
func.date(Alert.created_at).label('date'),
func.count(Alert.id).label('total_count'),
func.sum(
case((Alert.severity == AlertSeverity.URGENT, 1), else_=0)
case((Alert.priority_level == 'critical', 1), else_=0)
).label('urgent_count'),
func.sum(
case((Alert.severity == AlertSeverity.HIGH, 1), else_=0)
case((Alert.priority_level == 'important', 1), else_=0)
).label('high_count'),
func.sum(
case((Alert.severity == AlertSeverity.MEDIUM, 1), else_=0)
case((Alert.priority_level == 'standard', 1), else_=0)
).label('medium_count'),
func.sum(
case((Alert.severity == AlertSeverity.LOW, 1), else_=0)
case((Alert.priority_level == 'info', 1), else_=0)
).label('low_count')
)
.where(
@@ -178,13 +182,13 @@ class AlertAnalyticsRepository:
start_date = datetime.utcnow() - timedelta(days=days)
query = (
select(func.avg(AlertInteraction.response_time_seconds))
select(func.avg(EventInteraction.response_time_seconds))
.where(
and_(
AlertInteraction.tenant_id == tenant_id,
AlertInteraction.interaction_type == 'acknowledged',
AlertInteraction.interacted_at >= start_date,
AlertInteraction.response_time_seconds < 86400 # Less than 24 hours
EventInteraction.tenant_id == tenant_id,
EventInteraction.interaction_type == 'acknowledged',
EventInteraction.interacted_at >= start_date,
EventInteraction.response_time_seconds < 86400 # Less than 24 hours
)
)
)
@@ -380,3 +384,125 @@ class AlertAnalyticsRepository:
'predictedDailyAverage': predicted_avg,
'busiestDay': busiest_day
}
async def get_period_comparison(
self,
tenant_id: UUID,
current_days: int = 7,
previous_days: int = 7
) -> Dict[str, Any]:
"""
Compare current period metrics with previous period.
Used for week-over-week trend analysis in dashboard cards.
Args:
tenant_id: Tenant ID
current_days: Number of days in current period (default 7)
previous_days: Number of days in previous period (default 7)
Returns:
Dictionary with current/previous metrics and percentage changes
"""
from datetime import datetime, timedelta
now = datetime.utcnow()
current_start = now - timedelta(days=current_days)
previous_start = current_start - timedelta(days=previous_days)
previous_end = current_start
# Current period: AI handling rate (prevented issues / total)
current_total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= current_start,
Alert.created_at <= now
)
)
current_total_result = await self.session.execute(current_total_query)
current_total = current_total_result.scalar() or 0
current_prevented_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= current_start,
Alert.created_at <= now
)
)
current_prevented_result = await self.session.execute(current_prevented_query)
current_prevented = current_prevented_result.scalar() or 0
current_handling_rate = (
(current_prevented / current_total * 100)
if current_total > 0 else 0
)
# Previous period: AI handling rate
previous_total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= previous_start,
Alert.created_at < previous_end
)
)
previous_total_result = await self.session.execute(previous_total_query)
previous_total = previous_total_result.scalar() or 0
previous_prevented_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= previous_start,
Alert.created_at < previous_end
)
)
previous_prevented_result = await self.session.execute(previous_prevented_query)
previous_prevented = previous_prevented_result.scalar() or 0
previous_handling_rate = (
(previous_prevented / previous_total * 100)
if previous_total > 0 else 0
)
# Calculate percentage change
if previous_handling_rate > 0:
handling_rate_change = round(
((current_handling_rate - previous_handling_rate) / previous_handling_rate) * 100,
1
)
elif current_handling_rate > 0:
handling_rate_change = 100.0 # Went from 0% to something
else:
handling_rate_change = 0.0
# Alert count change
if previous_total > 0:
alert_count_change = round(
((current_total - previous_total) / previous_total) * 100,
1
)
elif current_total > 0:
alert_count_change = 100.0
else:
alert_count_change = 0.0
return {
'current_period': {
'days': current_days,
'total_alerts': current_total,
'prevented_issues': current_prevented,
'handling_rate_percentage': round(current_handling_rate, 1)
},
'previous_period': {
'days': previous_days,
'total_alerts': previous_total,
'prevented_issues': previous_prevented,
'handling_rate_percentage': round(previous_handling_rate, 1)
},
'changes': {
'handling_rate_change_percentage': handling_rate_change,
'alert_count_change_percentage': alert_count_change,
'trend_direction': 'up' if handling_rate_change > 0 else ('down' if handling_rate_change < 0 else 'stable')
}
}

View File

@@ -0,0 +1,21 @@
"""
Alert Enrichment Services
Provides intelligent enrichment for all alerts:
- Priority scoring (multi-factor)
- Context enrichment (orchestrator queries)
- Timing intelligence (peak hours)
- Smart action generation
"""
from .priority_scoring import PriorityScoringService
from .context_enrichment import ContextEnrichmentService
from .timing_intelligence import TimingIntelligenceService
from .orchestrator_client import OrchestratorClient
__all__ = [
'PriorityScoringService',
'ContextEnrichmentService',
'TimingIntelligenceService',
'OrchestratorClient',
]

View File

@@ -0,0 +1,163 @@
"""
Alert Grouping Service
Groups related alerts for better UX:
- Multiple low stock items from same supplier → "3 ingredients low from Supplier X"
- Multiple production delays → "Production delays affecting 5 batches"
- Same alert type in time window → Grouped notification
"""
import structlog
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from uuid import uuid4
from collections import defaultdict
from shared.schemas.alert_types import EnrichedAlert, AlertGroup
logger = structlog.get_logger()
class AlertGroupingService:
"""Groups related alerts intelligently"""
def __init__(self, config):
self.config = config
self.grouping_enabled = config.ALERT_GROUPING_ENABLED
self.time_window_minutes = config.GROUPING_TIME_WINDOW_MINUTES
self.min_for_grouping = config.MIN_ALERTS_FOR_GROUPING
async def group_alerts(
self,
alerts: List[EnrichedAlert],
tenant_id: str
) -> List[EnrichedAlert]:
"""
Group related alerts and return list with group summaries
Returns: Modified alert list with group summaries replacing individual alerts
"""
if not self.grouping_enabled or len(alerts) < self.min_for_grouping:
return alerts
# Group by different strategies
groups = []
ungrouped = []
# Strategy 1: Group by supplier
supplier_groups = self._group_by_supplier(alerts)
for group_alerts in supplier_groups.values():
if len(group_alerts) >= self.min_for_grouping:
groups.append(self._create_supplier_group(group_alerts, tenant_id))
else:
ungrouped.extend(group_alerts)
# Strategy 2: Group by alert type (same type, same time window)
type_groups = self._group_by_type(alerts)
for group_alerts in type_groups.values():
if len(group_alerts) >= self.min_for_grouping:
groups.append(self._create_type_group(group_alerts, tenant_id))
else:
ungrouped.extend(group_alerts)
# Combine grouped summaries with ungrouped alerts
result = groups + ungrouped
result.sort(key=lambda a: a.priority_score, reverse=True)
logger.info(
"Alerts grouped",
original_count=len(alerts),
grouped_count=len(groups),
final_count=len(result)
)
return result
def _group_by_supplier(self, alerts: List[EnrichedAlert]) -> Dict[str, List[EnrichedAlert]]:
"""Group alerts by supplier"""
groups = defaultdict(list)
for alert in alerts:
if alert.user_agency and alert.user_agency.external_party_name:
supplier = alert.user_agency.external_party_name
if alert.alert_type in ["critical_stock_shortage", "low_stock_warning"]:
groups[supplier].append(alert)
return groups
def _group_by_type(self, alerts: List[EnrichedAlert]) -> Dict[str, List[EnrichedAlert]]:
"""Group alerts by type within time window"""
groups = defaultdict(list)
cutoff_time = datetime.utcnow() - timedelta(minutes=self.time_window_minutes)
for alert in alerts:
if alert.created_at >= cutoff_time:
groups[alert.alert_type].append(alert)
# Filter out groups that don't meet minimum
return {k: v for k, v in groups.items() if len(v) >= self.min_for_grouping}
def _create_supplier_group(
self,
alerts: List[EnrichedAlert],
tenant_id: str
) -> EnrichedAlert:
"""Create a grouped alert for supplier-related alerts"""
supplier_name = alerts[0].user_agency.external_party_name
count = len(alerts)
# Calculate highest priority
max_priority = max(a.priority_score for a in alerts)
# Aggregate financial impact
total_impact = sum(
a.business_impact.financial_impact_eur or 0
for a in alerts
if a.business_impact
)
# Create group summary alert
group_id = str(uuid4())
summary_alert = alerts[0].copy(deep=True)
summary_alert.id = group_id
summary_alert.group_id = group_id
summary_alert.is_group_summary = True
summary_alert.grouped_alert_count = count
summary_alert.grouped_alert_ids = [a.id for a in alerts]
summary_alert.priority_score = max_priority
summary_alert.title = f"{count} ingredients low from {supplier_name}"
summary_alert.message = f"Review consolidated order for {supplier_name} — €{total_impact:.0f} total"
# Update actions - check if using old actions structure
if hasattr(summary_alert, 'actions') and summary_alert.actions:
matching_actions = [a for a in summary_alert.actions if hasattr(a, 'type') and getattr(a, 'type', None) and getattr(a.type, 'value', None) == "open_reasoning"][:1]
if len(summary_alert.actions) > 0:
summary_alert.actions = [summary_alert.actions[0]] + matching_actions
return summary_alert
def _create_type_group(
self,
alerts: List[EnrichedAlert],
tenant_id: str
) -> EnrichedAlert:
"""Create a grouped alert for same-type alerts"""
alert_type = alerts[0].alert_type
count = len(alerts)
max_priority = max(a.priority_score for a in alerts)
group_id = str(uuid4())
summary_alert = alerts[0].copy(deep=True)
summary_alert.id = group_id
summary_alert.group_id = group_id
summary_alert.is_group_summary = True
summary_alert.grouped_alert_count = count
summary_alert.grouped_alert_ids = [a.id for a in alerts]
summary_alert.priority_score = max_priority
summary_alert.title = f"{count} {alert_type.replace('_', ' ')} alerts"
summary_alert.message = f"Review {count} related alerts"
return summary_alert

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,239 @@
"""
Email Digest Service - Enriched Alert System
Sends daily/weekly summaries highlighting AI wins and prevented issues
"""
import structlog
from datetime import datetime, timedelta
from typing import List, Optional
from uuid import UUID
import httpx
from shared.schemas.alert_types import EnrichedAlert
logger = structlog.get_logger()
class EmailDigestService:
"""
Manages email digests for enriched alerts.
Philosophy: Celebrate AI wins, build trust, show prevented issues prominently.
"""
def __init__(self, config):
self.config = config
self.enabled = getattr(config, 'EMAIL_DIGEST_ENABLED', False)
self.send_hour = getattr(config, 'DIGEST_SEND_TIME_HOUR', 18) # 6 PM default
self.min_alerts = getattr(config, 'DIGEST_MIN_ALERTS', 1)
self.notification_service_url = "http://notification-service:8000"
async def send_daily_digest(
self,
tenant_id: UUID,
alerts: List[EnrichedAlert],
user_email: str,
user_name: Optional[str] = None
) -> bool:
"""
Send daily email digest highlighting AI impact and prevented issues.
Email structure:
1. AI Impact Summary (prevented issues count, savings)
2. Prevented Issues List (top 5 with AI reasoning)
3. Action Needed Alerts (critical/important requiring attention)
4. Trend Warnings (optional)
"""
if not self.enabled or len(alerts) == 0:
return False
# Categorize alerts by type_class
prevented_issues = [a for a in alerts if a.type_class == 'prevented_issue']
action_needed = [a for a in alerts if a.type_class == 'action_needed']
trend_warnings = [a for a in alerts if a.type_class == 'trend_warning']
escalations = [a for a in alerts if a.type_class == 'escalation']
# Calculate AI impact metrics
total_savings = sum(
(a.orchestrator_context or {}).get('estimated_savings_eur', 0)
for a in prevented_issues
)
ai_handling_rate = (len(prevented_issues) / len(alerts) * 100) if alerts else 0
# Build email content
email_data = {
"to": user_email,
"subject": self._build_subject_line(len(prevented_issues), len(action_needed)),
"template": "enriched_alert_digest",
"context": {
"tenant_id": str(tenant_id),
"user_name": user_name or "there",
"date": datetime.utcnow().strftime("%B %d, %Y"),
"total_alerts": len(alerts),
# AI Impact Section
"prevented_issues_count": len(prevented_issues),
"total_savings_eur": round(total_savings, 2),
"ai_handling_rate": round(ai_handling_rate, 1),
"prevented_issues": [self._serialize_prevented_issue(a) for a in prevented_issues[:5]],
# Action Needed Section
"action_needed_count": len(action_needed),
"critical_actions": [
self._serialize_action_alert(a)
for a in action_needed
if a.priority_level == 'critical'
][:3],
"important_actions": [
self._serialize_action_alert(a)
for a in action_needed
if a.priority_level == 'important'
][:5],
# Trend Warnings Section
"trend_warnings_count": len(trend_warnings),
"trend_warnings": [self._serialize_trend_warning(a) for a in trend_warnings[:3]],
# Escalations Section
"escalations_count": len(escalations),
"escalations": [self._serialize_escalation(a) for a in escalations[:3]],
}
}
# Send via notification service
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{self.notification_service_url}/api/email/send",
json=email_data,
timeout=10.0
)
success = response.status_code == 200
logger.info(
"Enriched email digest sent",
tenant_id=str(tenant_id),
alert_count=len(alerts),
prevented_count=len(prevented_issues),
savings_eur=total_savings,
success=success
)
return success
except Exception as e:
logger.error("Failed to send email digest", error=str(e), tenant_id=str(tenant_id))
return False
async def send_weekly_digest(
self,
tenant_id: UUID,
alerts: List[EnrichedAlert],
user_email: str,
user_name: Optional[str] = None
) -> bool:
"""
Send weekly email digest with aggregated AI impact metrics.
Focus: Week-over-week trends, total savings, top prevented issues.
"""
if not self.enabled or len(alerts) == 0:
return False
prevented_issues = [a for a in alerts if a.type_class == 'prevented_issue']
total_savings = sum(
(a.orchestrator_context or {}).get('estimated_savings_eur', 0)
for a in prevented_issues
)
email_data = {
"to": user_email,
"subject": f"Weekly AI Impact Summary - {len(prevented_issues)} Issues Prevented",
"template": "weekly_alert_digest",
"context": {
"tenant_id": str(tenant_id),
"user_name": user_name or "there",
"week_start": (datetime.utcnow() - timedelta(days=7)).strftime("%B %d"),
"week_end": datetime.utcnow().strftime("%B %d, %Y"),
"prevented_issues_count": len(prevented_issues),
"total_savings_eur": round(total_savings, 2),
"top_prevented_issues": [
self._serialize_prevented_issue(a)
for a in sorted(
prevented_issues,
key=lambda x: (x.orchestrator_context or {}).get('estimated_savings_eur', 0),
reverse=True
)[:10]
],
}
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{self.notification_service_url}/api/email/send",
json=email_data,
timeout=10.0
)
return response.status_code == 200
except Exception as e:
logger.error("Failed to send weekly digest", error=str(e))
return False
def _build_subject_line(self, prevented_count: int, action_count: int) -> str:
"""Build dynamic subject line based on alert counts"""
if prevented_count > 0 and action_count == 0:
return f"🎉 Great News! AI Prevented {prevented_count} Issue{'s' if prevented_count > 1 else ''} Today"
elif prevented_count > 0 and action_count > 0:
return f"Daily Summary: {prevented_count} Prevented, {action_count} Need{'s' if action_count == 1 else ''} Attention"
elif action_count > 0:
return f"⚠️ {action_count} Alert{'s' if action_count > 1 else ''} Require{'s' if action_count == 1 else ''} Your Attention"
else:
return "Daily Alert Summary"
def _serialize_prevented_issue(self, alert: EnrichedAlert) -> dict:
"""Serialize prevented issue for email with celebration tone"""
return {
"title": alert.title,
"message": alert.message,
"ai_reasoning": alert.ai_reasoning_summary,
"savings_eur": (alert.orchestrator_context or {}).get('estimated_savings_eur', 0),
"action_taken": (alert.orchestrator_context or {}).get('action_taken', 'AI intervention'),
"created_at": alert.created_at.strftime("%I:%M %p"),
"priority_score": alert.priority_score,
}
def _serialize_action_alert(self, alert: EnrichedAlert) -> dict:
"""Serialize action-needed alert with urgency context"""
return {
"title": alert.title,
"message": alert.message,
"priority_level": alert.priority_level.value,
"priority_score": alert.priority_score,
"financial_impact_eur": (alert.business_impact or {}).get('financial_impact_eur'),
"time_sensitive": (alert.urgency_context or {}).get('time_sensitive', False),
"deadline": (alert.urgency_context or {}).get('deadline'),
"actions": [a.get('label', '') for a in (alert.smart_actions or [])[:3] if isinstance(a, dict)],
"created_at": alert.created_at.strftime("%I:%M %p"),
}
def _serialize_trend_warning(self, alert: EnrichedAlert) -> dict:
"""Serialize trend warning with trend data"""
return {
"title": alert.title,
"message": alert.message,
"trend_direction": (alert.trend_context or {}).get('direction', 'stable'),
"historical_comparison": (alert.trend_context or {}).get('historical_comparison'),
"ai_reasoning": alert.ai_reasoning_summary,
"created_at": alert.created_at.strftime("%I:%M %p"),
}
def _serialize_escalation(self, alert: EnrichedAlert) -> dict:
"""Serialize escalation alert with auto-action context"""
return {
"title": alert.title,
"message": alert.message,
"action_countdown": (alert.orchestrator_context or {}).get('action_in_seconds'),
"action_description": (alert.orchestrator_context or {}).get('pending_action'),
"can_cancel": not (alert.alert_metadata or {}).get('auto_action_cancelled', False),
"financial_impact_eur": (alert.business_impact or {}).get('financial_impact_eur'),
"created_at": alert.created_at.strftime("%I:%M %p"),
}

View File

@@ -0,0 +1,391 @@
"""
Enrichment Router
Routes events to appropriate enrichment pipelines based on event_class:
- ALERT: Full enrichment (orchestrator, priority, smart actions, timing)
- NOTIFICATION: Lightweight enrichment (basic formatting only)
- RECOMMENDATION: Moderate enrichment (no orchestrator queries)
This enables 80% reduction in processing time for non-alert events.
"""
import logging
from typing import Dict, Any, Optional
from datetime import datetime, timezone, timedelta
import uuid
from shared.schemas.event_classification import (
RawEvent,
EventClass,
EventDomain,
NotificationType,
RecommendationType,
)
from services.alert_processor.app.models.events import (
Alert,
Notification,
Recommendation,
)
from services.alert_processor.app.services.enrichment.context_enrichment import ContextEnrichmentService
from services.alert_processor.app.services.enrichment.priority_scoring import PriorityScoringService
from services.alert_processor.app.services.enrichment.timing_intelligence import TimingIntelligenceService
from services.alert_processor.app.services.enrichment.orchestrator_client import OrchestratorClient
logger = logging.getLogger(__name__)
class EnrichmentRouter:
"""
Routes events to appropriate enrichment pipeline based on event_class.
"""
def __init__(
self,
context_enrichment_service: Optional[ContextEnrichmentService] = None,
priority_scoring_service: Optional[PriorityScoringService] = None,
timing_intelligence_service: Optional[TimingIntelligenceService] = None,
orchestrator_client: Optional[OrchestratorClient] = None,
):
"""Initialize enrichment router with services"""
self.context_enrichment = context_enrichment_service or ContextEnrichmentService()
self.priority_scoring = priority_scoring_service or PriorityScoringService()
self.timing_intelligence = timing_intelligence_service or TimingIntelligenceService()
self.orchestrator_client = orchestrator_client or OrchestratorClient()
async def enrich_event(self, raw_event: RawEvent) -> Alert | Notification | Recommendation:
"""
Route event to appropriate enrichment pipeline.
Args:
raw_event: Raw event from domain service
Returns:
Enriched Alert, Notification, or Recommendation model
Raises:
ValueError: If event_class is not recognized
"""
logger.info(
f"Enriching event: class={raw_event.event_class}, "
f"domain={raw_event.event_domain}, type={raw_event.event_type}"
)
if raw_event.event_class == EventClass.ALERT:
return await self._enrich_alert(raw_event)
elif raw_event.event_class == EventClass.NOTIFICATION:
return await self._enrich_notification(raw_event)
elif raw_event.event_class == EventClass.RECOMMENDATION:
return await self._enrich_recommendation(raw_event)
else:
raise ValueError(f"Unknown event_class: {raw_event.event_class}")
# ============================================================
# ALERT ENRICHMENT (Full Pipeline)
# ============================================================
async def _enrich_alert(self, raw_event: RawEvent) -> Alert:
"""
Full enrichment pipeline for alerts.
Steps:
1. Query orchestrator for context
2. Calculate business impact
3. Assess urgency
4. Determine user agency
5. Generate smart actions
6. Calculate priority score
7. Determine timing
8. Classify type_class
"""
logger.debug(f"Full enrichment for alert: {raw_event.event_type}")
# Step 1: Orchestrator context
orchestrator_context = await self._get_orchestrator_context(raw_event)
# Step 2-5: Context enrichment (business impact, urgency, user agency, smart actions)
enriched_context = await self.context_enrichment.enrich(
raw_event=raw_event,
orchestrator_context=orchestrator_context,
)
# Step 6: Priority scoring (multi-factor)
priority_data = await self.priority_scoring.calculate_priority(
raw_event=raw_event,
business_impact=enriched_context.get('business_impact'),
urgency_context=enriched_context.get('urgency_context'),
user_agency=enriched_context.get('user_agency'),
confidence_score=enriched_context.get('confidence_score', 0.8),
)
# Step 7: Timing intelligence
timing_data = await self.timing_intelligence.determine_timing(
priority_score=priority_data['priority_score'],
priority_level=priority_data['priority_level'],
type_class=enriched_context.get('type_class', 'action_needed'),
)
# Create Alert model
alert = Alert(
id=uuid.uuid4(),
tenant_id=uuid.UUID(raw_event.tenant_id),
event_domain=raw_event.event_domain.value,
event_type=raw_event.event_type,
service=raw_event.service,
title=raw_event.title,
message=raw_event.message,
type_class=enriched_context.get('type_class', 'action_needed'),
status='active',
priority_score=priority_data['priority_score'],
priority_level=priority_data['priority_level'],
orchestrator_context=orchestrator_context,
business_impact=enriched_context.get('business_impact'),
urgency_context=enriched_context.get('urgency_context'),
user_agency=enriched_context.get('user_agency'),
trend_context=enriched_context.get('trend_context'),
smart_actions=enriched_context.get('smart_actions', []),
ai_reasoning_summary=enriched_context.get('ai_reasoning_summary'),
confidence_score=enriched_context.get('confidence_score', 0.8),
timing_decision=timing_data['timing_decision'],
scheduled_send_time=timing_data.get('scheduled_send_time'),
placement=timing_data.get('placement', ['toast', 'action_queue', 'notification_panel']),
action_created_at=enriched_context.get('action_created_at'),
superseded_by_action_id=enriched_context.get('superseded_by_action_id'),
hidden_from_ui=enriched_context.get('hidden_from_ui', False),
alert_metadata=raw_event.event_metadata,
created_at=raw_event.timestamp or datetime.now(timezone.utc),
)
logger.info(
f"Alert enriched: {alert.event_type}, priority={alert.priority_score}, "
f"type_class={alert.type_class}"
)
return alert
async def _get_orchestrator_context(self, raw_event: RawEvent) -> Optional[Dict[str, Any]]:
"""Query orchestrator for recent actions related to this event"""
try:
# Extract relevant IDs from metadata
ingredient_id = raw_event.event_metadata.get('ingredient_id')
product_id = raw_event.event_metadata.get('product_id')
if not ingredient_id and not product_id:
return None
# Query orchestrator
recent_actions = await self.orchestrator_client.get_recent_actions(
tenant_id=raw_event.tenant_id,
ingredient_id=ingredient_id,
product_id=product_id,
)
if not recent_actions:
return None
# Return most recent action
action = recent_actions[0]
return {
'already_addressed': True,
'action_type': action.get('action_type'),
'action_id': action.get('action_id'),
'action_status': action.get('status'),
'delivery_date': action.get('delivery_date'),
'reasoning': action.get('reasoning'),
}
except Exception as e:
logger.warning(f"Failed to fetch orchestrator context: {e}")
return None
# ============================================================
# NOTIFICATION ENRICHMENT (Lightweight)
# ============================================================
async def _enrich_notification(self, raw_event: RawEvent) -> Notification:
"""
Lightweight enrichment for notifications.
No orchestrator queries, no priority scoring, no smart actions.
Just basic formatting and entity extraction.
"""
logger.debug(f"Lightweight enrichment for notification: {raw_event.event_type}")
# Infer notification_type from event_type
notification_type = self._infer_notification_type(raw_event.event_type)
# Extract entity context from metadata
entity_type, entity_id, old_state, new_state = self._extract_entity_context(raw_event)
# Create Notification model
notification = Notification(
id=uuid.uuid4(),
tenant_id=uuid.UUID(raw_event.tenant_id),
event_domain=raw_event.event_domain.value,
event_type=raw_event.event_type,
notification_type=notification_type.value,
service=raw_event.service,
title=raw_event.title,
message=raw_event.message,
entity_type=entity_type,
entity_id=entity_id,
old_state=old_state,
new_state=new_state,
notification_metadata=raw_event.event_metadata,
placement=['notification_panel'], # Lightweight: panel only, no toast
# expires_at set automatically in __init__ (7 days)
created_at=raw_event.timestamp or datetime.now(timezone.utc),
)
logger.info(f"Notification enriched: {notification.event_type}, entity={entity_type}:{entity_id}")
return notification
def _infer_notification_type(self, event_type: str) -> NotificationType:
"""Infer notification_type from event_type string"""
event_type_lower = event_type.lower()
if 'state_change' in event_type_lower or 'status_change' in event_type_lower:
return NotificationType.STATE_CHANGE
elif 'completed' in event_type_lower or 'finished' in event_type_lower:
return NotificationType.COMPLETION
elif 'received' in event_type_lower or 'arrived' in event_type_lower or 'arrival' in event_type_lower:
return NotificationType.ARRIVAL
elif 'shipped' in event_type_lower or 'sent' in event_type_lower or 'departure' in event_type_lower:
return NotificationType.DEPARTURE
elif 'started' in event_type_lower or 'created' in event_type_lower:
return NotificationType.SYSTEM_EVENT
else:
return NotificationType.UPDATE
def _extract_entity_context(self, raw_event: RawEvent) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Extract entity context from metadata"""
metadata = raw_event.event_metadata
# Try to infer entity_type from metadata keys
entity_type = None
entity_id = None
old_state = None
new_state = None
# Check for common entity types
if 'batch_id' in metadata:
entity_type = 'batch'
entity_id = metadata.get('batch_id')
old_state = metadata.get('old_status') or metadata.get('previous_status')
new_state = metadata.get('new_status') or metadata.get('status')
elif 'delivery_id' in metadata:
entity_type = 'delivery'
entity_id = metadata.get('delivery_id')
old_state = metadata.get('old_status')
new_state = metadata.get('new_status') or metadata.get('status')
elif 'po_id' in metadata or 'purchase_order_id' in metadata:
entity_type = 'purchase_order'
entity_id = metadata.get('po_id') or metadata.get('purchase_order_id')
old_state = metadata.get('old_status')
new_state = metadata.get('new_status') or metadata.get('status')
elif 'orchestration_run_id' in metadata or 'run_id' in metadata:
entity_type = 'orchestration_run'
entity_id = metadata.get('orchestration_run_id') or metadata.get('run_id')
old_state = metadata.get('old_status')
new_state = metadata.get('new_status') or metadata.get('status')
return entity_type, entity_id, old_state, new_state
# ============================================================
# RECOMMENDATION ENRICHMENT (Moderate)
# ============================================================
async def _enrich_recommendation(self, raw_event: RawEvent) -> Recommendation:
"""
Moderate enrichment for recommendations.
No orchestrator queries, light priority, basic suggested actions.
"""
logger.debug(f"Moderate enrichment for recommendation: {raw_event.event_type}")
# Infer recommendation_type from event_type
recommendation_type = self._infer_recommendation_type(raw_event.event_type)
# Calculate light priority (defaults to info, can be elevated based on metadata)
priority_level = self._calculate_light_priority(raw_event)
# Extract estimated impact from metadata
estimated_impact = self._extract_estimated_impact(raw_event)
# Generate basic suggested actions (lightweight, no smart action generation)
suggested_actions = self._generate_suggested_actions(raw_event)
# Create Recommendation model
recommendation = Recommendation(
id=uuid.uuid4(),
tenant_id=uuid.UUID(raw_event.tenant_id),
event_domain=raw_event.event_domain.value,
event_type=raw_event.event_type,
recommendation_type=recommendation_type.value,
service=raw_event.service,
title=raw_event.title,
message=raw_event.message,
priority_level=priority_level,
estimated_impact=estimated_impact,
suggested_actions=suggested_actions,
ai_reasoning_summary=raw_event.event_metadata.get('reasoning'),
confidence_score=raw_event.event_metadata.get('confidence_score', 0.7),
recommendation_metadata=raw_event.event_metadata,
created_at=raw_event.timestamp or datetime.now(timezone.utc),
)
logger.info(f"Recommendation enriched: {recommendation.event_type}, priority={priority_level}")
return recommendation
def _infer_recommendation_type(self, event_type: str) -> RecommendationType:
"""Infer recommendation_type from event_type string"""
event_type_lower = event_type.lower()
if 'optimization' in event_type_lower or 'efficiency' in event_type_lower:
return RecommendationType.OPTIMIZATION
elif 'cost' in event_type_lower or 'saving' in event_type_lower:
return RecommendationType.COST_REDUCTION
elif 'risk' in event_type_lower or 'prevent' in event_type_lower:
return RecommendationType.RISK_MITIGATION
elif 'trend' in event_type_lower or 'pattern' in event_type_lower:
return RecommendationType.TREND_INSIGHT
else:
return RecommendationType.BEST_PRACTICE
def _calculate_light_priority(self, raw_event: RawEvent) -> str:
"""Calculate light priority for recommendations (info by default)"""
metadata = raw_event.event_metadata
# Check for urgency hints in metadata
if metadata.get('urgent') or metadata.get('is_urgent'):
return 'important'
elif metadata.get('high_impact'):
return 'standard'
else:
return 'info'
def _extract_estimated_impact(self, raw_event: RawEvent) -> Optional[Dict[str, Any]]:
"""Extract estimated impact from metadata"""
metadata = raw_event.event_metadata
impact = {}
if 'estimated_savings_eur' in metadata:
impact['financial_savings_eur'] = metadata['estimated_savings_eur']
if 'estimated_time_saved_hours' in metadata:
impact['time_saved_hours'] = metadata['estimated_time_saved_hours']
if 'efficiency_gain_percent' in metadata:
impact['efficiency_gain_percent'] = metadata['efficiency_gain_percent']
return impact if impact else None
def _generate_suggested_actions(self, raw_event: RawEvent) -> Optional[list[Dict[str, Any]]]:
"""Generate basic suggested actions (lightweight, no smart action logic)"""
# If actions provided in raw_event, use them
if raw_event.actions:
return [{'type': action, 'label': action.replace('_', ' ').title()} for action in raw_event.actions]
# Otherwise, return None (optional actions)
return None

View File

@@ -0,0 +1,102 @@
"""
Orchestrator Client for Alert Enrichment
Queries Daily Orchestrator for recent AI actions to provide context enrichment
"""
import httpx
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
import structlog
logger = structlog.get_logger()
class OrchestratorClient:
"""
Client for querying orchestrator service
Used to determine if AI already handled an alert
"""
def __init__(self, base_url: str, timeout: float = 10.0):
self.base_url = base_url.rstrip('/')
self.timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=self.timeout)
return self._client
async def get_recent_actions(
self,
tenant_id: str,
ingredient_id: Optional[str] = None,
hours_ago: int = 24
) -> List[Dict[str, Any]]:
"""
Query orchestrator for recent actions
Args:
tenant_id: Tenant ID
ingredient_id: Optional ingredient filter
hours_ago: How far back to look (default 24h)
Returns:
List of recent orchestrator actions
"""
try:
client = await self._get_client()
url = f"{self.base_url}/api/internal/recent-actions"
params = {
'tenant_id': tenant_id,
'hours_ago': hours_ago
}
if ingredient_id:
params['ingredient_id'] = ingredient_id
response = await client.get(
url,
params=params,
headers={'X-Internal-Service': 'alert-processor'}
)
if response.status_code == 200:
data = response.json()
actions = data.get('actions', [])
logger.debug(
"Orchestrator actions retrieved",
tenant_id=tenant_id,
count=len(actions)
)
return actions
else:
logger.warning(
"Orchestrator query failed",
status=response.status_code,
tenant_id=tenant_id
)
return []
except httpx.TimeoutException:
logger.warning(
"Orchestrator query timeout",
tenant_id=tenant_id,
timeout=self.timeout
)
return []
except Exception as e:
logger.error(
"Failed to query orchestrator",
error=str(e),
tenant_id=tenant_id
)
return []
async def close(self):
"""Close HTTP client"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None

View File

@@ -0,0 +1,415 @@
"""
Priority Scoring Service
Calculates multi-factor priority scores for alerts based on:
- Business Impact (40%): Financial, operational, customer satisfaction
- Urgency (30%): Time until consequence, deadline proximity
- User Agency (20%): Can the user actually fix this?
- Confidence (10%): How certain is the assessment?
PLUS time-based escalation for action-needed alerts
"""
import structlog
from datetime import datetime, time as dt_time, timedelta, timezone
from typing import Dict, Any, Optional
from uuid import UUID
from shared.schemas.alert_types import (
PriorityScoreComponents,
BusinessImpact, UrgencyContext, UserAgency
)
logger = structlog.get_logger()
class PriorityScoringService:
"""Calculates intelligent priority scores for alerts"""
def __init__(self, config):
self.config = config
self.business_impact_weight = config.BUSINESS_IMPACT_WEIGHT
self.urgency_weight = config.URGENCY_WEIGHT
self.user_agency_weight = config.USER_AGENCY_WEIGHT
self.confidence_weight = config.CONFIDENCE_WEIGHT
def calculate_priority_score(
self,
business_impact: Optional[BusinessImpact],
urgency_context: Optional[UrgencyContext],
user_agency: Optional[UserAgency],
confidence_score: Optional[float]
) -> PriorityScoreComponents:
"""
Calculate multi-factor priority score
Args:
business_impact: Business impact assessment
urgency_context: Urgency and timing context
user_agency: User's ability to act
confidence_score: AI confidence (0-1)
Returns:
PriorityScoreComponents with breakdown
"""
# Calculate component scores
business_score = self._calculate_business_impact_score(business_impact)
urgency_score = self._calculate_urgency_score(urgency_context)
agency_score = self._calculate_user_agency_score(user_agency)
confidence = (confidence_score or 0.8) * 100 # Default 80% confidence
# Apply weights
weighted_business = business_score * self.business_impact_weight
weighted_urgency = urgency_score * self.urgency_weight
weighted_agency = agency_score * self.user_agency_weight
weighted_confidence = confidence * self.confidence_weight
# Calculate final score
final_score = int(
weighted_business +
weighted_urgency +
weighted_agency +
weighted_confidence
)
# Clamp to 0-100
final_score = max(0, min(100, final_score))
logger.debug(
"Priority score calculated",
final_score=final_score,
business=business_score,
urgency=urgency_score,
agency=agency_score,
confidence=confidence
)
return PriorityScoreComponents(
business_impact_score=business_score,
urgency_score=urgency_score,
user_agency_score=agency_score,
confidence_score=confidence,
final_score=final_score,
weights={
"business_impact": self.business_impact_weight,
"urgency": self.urgency_weight,
"user_agency": self.user_agency_weight,
"confidence": self.confidence_weight
}
)
def calculate_escalation_boost(
self,
action_created_at: Optional[datetime],
urgency_context: Optional[UrgencyContext],
current_priority: int
) -> int:
"""
Calculate priority boost based on how long action has been pending
and proximity to deadline.
Escalation rules:
- Pending >48h: +10 priority points
- Pending >72h: +20 priority points
- Within 24h of deadline: +15 points
- Within 6h of deadline: +30 points
- Max total boost: +30 points
Args:
action_created_at: When the action was created
urgency_context: Deadline and timing context
current_priority: Current priority score (to avoid over-escalating)
Returns:
Escalation boost (0-30 points)
"""
if not action_created_at:
return 0
now = datetime.now(timezone.utc)
boost = 0
# Make action_created_at timezone-aware if it isn't
if action_created_at.tzinfo is None:
action_created_at = action_created_at.replace(tzinfo=timezone.utc)
time_pending = now - action_created_at
# Time pending escalation
if time_pending > timedelta(hours=72):
boost += 20
logger.info(
"Alert escalated: pending >72h",
action_created_at=action_created_at.isoformat(),
hours_pending=time_pending.total_seconds() / 3600,
boost=20
)
elif time_pending > timedelta(hours=48):
boost += 10
logger.info(
"Alert escalated: pending >48h",
action_created_at=action_created_at.isoformat(),
hours_pending=time_pending.total_seconds() / 3600,
boost=10
)
# Deadline proximity escalation
if urgency_context and urgency_context.deadline:
deadline = urgency_context.deadline
# Make deadline timezone-aware if it isn't
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
time_until_deadline = deadline - now
if time_until_deadline < timedelta(hours=6):
deadline_boost = 30
boost = max(boost, deadline_boost) # Take the higher boost
logger.info(
"Alert escalated: deadline <6h",
deadline=deadline.isoformat(),
hours_until=time_until_deadline.total_seconds() / 3600,
boost=deadline_boost
)
elif time_until_deadline < timedelta(hours=24):
deadline_boost = 15
boost = max(boost, 15) # Take the higher boost
logger.info(
"Alert escalated: deadline <24h",
deadline=deadline.isoformat(),
hours_until=time_until_deadline.total_seconds() / 3600,
boost=deadline_boost
)
# Cap total boost at 30 points
boost = min(30, boost)
# Don't escalate if already critical (>= 90)
if current_priority >= 90 and boost > 0:
logger.debug(
"Escalation skipped: already critical",
current_priority=current_priority,
would_boost=boost
)
return 0
return boost
def get_priority_level(self, score: int) -> str:
"""Convert numeric score to priority level"""
if score >= self.config.CRITICAL_THRESHOLD:
return "critical"
elif score >= self.config.IMPORTANT_THRESHOLD:
return "important"
elif score >= self.config.STANDARD_THRESHOLD:
return "standard"
else:
return "info"
def _calculate_business_impact_score(
self,
impact: Optional[BusinessImpact]
) -> float:
"""
Calculate business impact score (0-100)
Factors:
- Financial impact (€)
- Affected orders/customers
- Production disruption
- Stockout/waste risk
"""
if not impact:
return 50.0 # Default mid-range
score = 0.0
# Financial impact (0-40 points)
if impact.financial_impact_eur:
if impact.financial_impact_eur >= 500:
score += 40
elif impact.financial_impact_eur >= 200:
score += 30
elif impact.financial_impact_eur >= 100:
score += 20
elif impact.financial_impact_eur >= 50:
score += 10
else:
score += 5
# Affected orders/customers (0-30 points)
affected_count = (impact.affected_orders or 0) + len(impact.affected_customers or [])
if affected_count >= 10:
score += 30
elif affected_count >= 5:
score += 20
elif affected_count >= 2:
score += 10
elif affected_count >= 1:
score += 5
# Production disruption (0-20 points)
batches_at_risk = len(impact.production_batches_at_risk or [])
if batches_at_risk >= 5:
score += 20
elif batches_at_risk >= 3:
score += 15
elif batches_at_risk >= 1:
score += 10
# Stockout/waste risk (0-10 points)
if impact.stockout_risk_hours and impact.stockout_risk_hours <= 24:
score += 10
elif impact.waste_risk_kg and impact.waste_risk_kg >= 50:
score += 10
elif impact.waste_risk_kg and impact.waste_risk_kg >= 20:
score += 5
return min(100.0, score)
def _calculate_urgency_score(
self,
urgency: Optional[UrgencyContext]
) -> float:
"""
Calculate urgency score (0-100)
Factors:
- Time until consequence
- Hard deadline proximity
- Peak hour relevance
- Auto-action countdown
"""
if not urgency:
return 50.0 # Default mid-range
score = 0.0
# Time until consequence (0-50 points)
if urgency.time_until_consequence_hours is not None:
hours = urgency.time_until_consequence_hours
if hours <= 2:
score += 50
elif hours <= 6:
score += 40
elif hours <= 12:
score += 30
elif hours <= 24:
score += 20
elif hours <= 48:
score += 10
else:
score += 5
# Hard deadline (0-30 points)
if urgency.deadline:
now = datetime.now(timezone.utc)
hours_until_deadline = (urgency.deadline - now).total_seconds() / 3600
if hours_until_deadline <= 2:
score += 30
elif hours_until_deadline <= 6:
score += 20
elif hours_until_deadline <= 24:
score += 10
# Peak hour relevance (0-10 points)
if urgency.peak_hour_relevant:
score += 10
# Auto-action countdown (0-10 points)
if urgency.auto_action_countdown_seconds:
if urgency.auto_action_countdown_seconds <= 300: # 5 minutes
score += 10
elif urgency.auto_action_countdown_seconds <= 900: # 15 minutes
score += 5
return min(100.0, score)
def _calculate_user_agency_score(
self,
agency: Optional[UserAgency]
) -> float:
"""
Calculate user agency score (0-100)
Higher score = user CAN act effectively
Lower score = user is blocked or needs external party
Factors:
- Can user fix this?
- Requires external party?
- Number of blockers
- Workaround available?
"""
if not agency:
return 50.0 # Default mid-range
score = 100.0 # Start high, deduct for blockers
# Can't fix = major deduction
if not agency.can_user_fix:
score -= 40
# Requires external party = moderate deduction
if agency.requires_external_party:
score -= 20
# But if we have contact info, it's easier
if agency.external_party_contact:
score += 10
# Blockers reduce score
if agency.blockers:
blocker_count = len(agency.blockers)
score -= min(30, blocker_count * 10)
# Workaround available = boost
if agency.suggested_workaround:
score += 15
return max(0.0, min(100.0, score))
def is_peak_hours(self) -> bool:
"""Check if current time is during peak hours"""
now = datetime.now()
current_hour = now.hour
morning_peak = (
self.config.PEAK_HOURS_START <= current_hour < self.config.PEAK_HOURS_END
)
evening_peak = (
self.config.EVENING_PEAK_START <= current_hour < self.config.EVENING_PEAK_END
)
return morning_peak or evening_peak
def is_business_hours(self) -> bool:
"""Check if current time is during business hours"""
now = datetime.now()
current_hour = now.hour
return (
self.config.BUSINESS_HOURS_START <= current_hour < self.config.BUSINESS_HOURS_END
)
def should_send_now(self, priority_score: int) -> bool:
"""
Determine if alert should be sent immediately or batched
Rules:
- Critical (90+): Always send immediately
- Important (70-89): Send immediately during business hours
- Standard (50-69): Send if business hours, batch otherwise
- Info (<50): Always batch for digest
"""
if priority_score >= self.config.CRITICAL_THRESHOLD:
return True
if priority_score >= self.config.IMPORTANT_THRESHOLD:
return self.is_business_hours()
if priority_score >= self.config.STANDARD_THRESHOLD:
return self.is_business_hours() and not self.is_peak_hours()
# Low priority - batch for digest
return False

View File

@@ -0,0 +1,140 @@
"""
Timing Intelligence Service
Implements smart timing logic:
- Avoid non-critical alerts during peak hours
- Batch low-priority alerts for digest
- Respect quiet hours
- Schedule alerts for optimal user attention
"""
import structlog
from datetime import datetime, time as dt_time, timedelta
from typing import List, Optional
from enum import Enum
from shared.schemas.alert_types import EnrichedAlert, PlacementHint
logger = structlog.get_logger()
class TimingDecision(Enum):
"""Decision about when to send alert"""
SEND_NOW = "send_now"
BATCH_FOR_DIGEST = "batch_for_digest"
SCHEDULE_LATER = "schedule_later"
HOLD_UNTIL_QUIET = "hold_until_quiet"
class TimingIntelligenceService:
"""Intelligent alert timing decisions"""
def __init__(self, config):
self.config = config
self.timing_enabled = config.TIMING_INTELLIGENCE_ENABLED
self.batch_low_priority = config.BATCH_LOW_PRIORITY_ALERTS
def should_send_now(self, alert: EnrichedAlert) -> TimingDecision:
"""Determine if alert should be sent now or delayed"""
if not self.timing_enabled:
return TimingDecision.SEND_NOW
priority = alert.priority_score
now = datetime.now()
current_hour = now.hour
# Critical always sends immediately
if priority >= 90:
return TimingDecision.SEND_NOW
# During peak hours (7-11am, 5-7pm), only send important+
if self._is_peak_hours(now):
if priority >= 70:
return TimingDecision.SEND_NOW
else:
return TimingDecision.SCHEDULE_LATER
# Outside business hours, batch non-important alerts
if not self._is_business_hours(now):
if priority >= 70:
return TimingDecision.SEND_NOW
else:
return TimingDecision.BATCH_FOR_DIGEST
# During quiet hours, send important+ immediately
if priority >= 70:
return TimingDecision.SEND_NOW
# Standard priority during quiet hours
if priority >= 50:
return TimingDecision.SEND_NOW
# Low priority always batched
return TimingDecision.BATCH_FOR_DIGEST
def get_next_quiet_time(self) -> datetime:
"""Get next quiet period start time"""
now = datetime.now()
current_hour = now.hour
# After evening peak (after 7pm)
if current_hour < 19:
return now.replace(hour=19, minute=0, second=0, microsecond=0)
# After lunch (1pm)
elif current_hour < 13:
return now.replace(hour=13, minute=0, second=0, microsecond=0)
# Before morning peak (6am next day)
else:
tomorrow = now + timedelta(days=1)
return tomorrow.replace(hour=6, minute=0, second=0, microsecond=0)
def get_digest_send_time(self) -> datetime:
"""Get time for end-of-day digest"""
now = datetime.now()
digest_time = now.replace(
hour=self.config.DIGEST_SEND_TIME_HOUR,
minute=0,
second=0,
microsecond=0
)
# If already passed today, schedule for tomorrow
if digest_time <= now:
digest_time += timedelta(days=1)
return digest_time
def _is_peak_hours(self, dt: datetime) -> bool:
"""Check if time is during peak hours"""
hour = dt.hour
return (
(self.config.PEAK_HOURS_START <= hour < self.config.PEAK_HOURS_END) or
(self.config.EVENING_PEAK_START <= hour < self.config.EVENING_PEAK_END)
)
def _is_business_hours(self, dt: datetime) -> bool:
"""Check if time is during business hours"""
hour = dt.hour
return self.config.BUSINESS_HOURS_START <= hour < self.config.BUSINESS_HOURS_END
def adjust_placement_for_timing(
self,
alert: EnrichedAlert,
decision: TimingDecision
) -> List[PlacementHint]:
"""Adjust UI placement based on timing decision"""
if decision == TimingDecision.SEND_NOW:
return alert.placement
if decision == TimingDecision.BATCH_FOR_DIGEST:
return [PlacementHint.EMAIL_DIGEST]
if decision in [TimingDecision.SCHEDULE_LATER, TimingDecision.HOLD_UNTIL_QUIET]:
# Remove toast, keep other placements
return [p for p in alert.placement if p != PlacementHint.TOAST]
return alert.placement

View File

@@ -0,0 +1,104 @@
"""
Trend Detection Service
Identifies meaningful trends in operational metrics and generates proactive warnings
"""
import structlog
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from shared.schemas.alert_types import TrendContext, EnrichedAlert
from scipy import stats
import numpy as np
logger = structlog.get_logger()
class TrendDetectionService:
"""Detects significant trends in metrics"""
def __init__(self, config, db_manager):
self.config = config
self.db_manager = db_manager
self.enabled = config.TREND_DETECTION_ENABLED
self.lookback_days = config.TREND_LOOKBACK_DAYS
self.significance_threshold = config.TREND_SIGNIFICANCE_THRESHOLD
async def detect_waste_trends(self, tenant_id: str) -> Optional[TrendContext]:
"""Detect increasing waste trends"""
if not self.enabled:
return None
query = """
SELECT date, SUM(waste_kg) as daily_waste
FROM waste_tracking
WHERE tenant_id = $1 AND date >= $2
GROUP BY date
ORDER BY date
"""
cutoff = datetime.utcnow().date() - timedelta(days=self.lookback_days)
async with self.db_manager.get_session() as session:
result = await session.execute(query, [tenant_id, cutoff])
data = [(row[0], row[1]) for row in result.fetchall()]
if len(data) < 3:
return None
values = [d[1] for d in data]
baseline = np.mean(values[:3])
current = np.mean(values[-3:])
change_pct = ((current - baseline) / baseline) * 100 if baseline > 0 else 0
if abs(change_pct) >= self.significance_threshold * 100:
return TrendContext(
metric_name="Waste percentage",
current_value=current,
baseline_value=baseline,
change_percentage=change_pct,
direction="increasing" if change_pct > 0 else "decreasing",
significance="high" if abs(change_pct) > 20 else "medium",
period_days=self.lookback_days,
possible_causes=["Recipe yield issues", "Over-production", "Quality control"]
)
return None
async def detect_efficiency_trends(self, tenant_id: str) -> Optional[TrendContext]:
"""Detect declining production efficiency"""
if not self.enabled:
return None
query = """
SELECT date, AVG(efficiency_percent) as daily_efficiency
FROM production_metrics
WHERE tenant_id = $1 AND date >= $2
GROUP BY date
ORDER BY date
"""
cutoff = datetime.utcnow().date() - timedelta(days=self.lookback_days)
async with self.db_manager.get_session() as session:
result = await session.execute(query, [tenant_id, cutoff])
data = [(row[0], row[1]) for row in result.fetchall()]
if len(data) < 3:
return None
values = [d[1] for d in data]
baseline = np.mean(values[:3])
current = np.mean(values[-3:])
change_pct = ((current - baseline) / baseline) * 100 if baseline > 0 else 0
if change_pct < -self.significance_threshold * 100:
return TrendContext(
metric_name="Production efficiency",
current_value=current,
baseline_value=baseline,
change_percentage=change_pct,
direction="decreasing",
significance="high" if abs(change_pct) > 15 else "medium",
period_days=self.lookback_days,
possible_causes=["Equipment wear", "Process changes", "Staff training"]
)
return None

View File

@@ -0,0 +1,228 @@
"""
Redis Publisher Service
Publishes events to domain-based Redis pub/sub channels for SSE streaming.
Channel pattern:
- tenant:{tenant_id}:inventory.alerts
- tenant:{tenant_id}:production.notifications
- tenant:{tenant_id}:recommendations (tenant-wide)
This enables selective subscription and reduces SSE traffic by ~70% per page.
"""
import json
import logging
from typing import Dict, Any
from datetime import datetime
from shared.schemas.event_classification import EventClass, EventDomain, get_redis_channel
from services.alert_processor.app.models.events import Alert, Notification, Recommendation
logger = logging.getLogger(__name__)
class RedisPublisher:
"""
Publishes events to domain-based Redis pub/sub channels.
"""
def __init__(self, redis_client):
"""Initialize with Redis client"""
self.redis = redis_client
async def publish_event(
self,
event: Alert | Notification | Recommendation,
tenant_id: str,
) -> None:
"""
Publish event to appropriate domain-based Redis channel.
Args:
event: Enriched event (Alert, Notification, or Recommendation)
tenant_id: Tenant identifier
The channel is determined by event_domain and event_class.
"""
try:
# Convert event to dict
event_dict = event.to_dict()
# Determine channel based on event_class and event_domain
event_class = event_dict['event_class']
event_domain = event_dict['event_domain']
# Get domain-based channel
if event_class == 'recommendation':
# Recommendations go to tenant-wide channel (not domain-specific)
channel = f"tenant:{tenant_id}:recommendations"
else:
# Alerts and notifications use domain-specific channels
channel = f"tenant:{tenant_id}:{event_domain}.{event_class}s"
# Ensure timestamp is serializable
if 'timestamp' not in event_dict or not event_dict['timestamp']:
event_dict['timestamp'] = event_dict.get('created_at')
# Publish to domain-based channel
await self.redis.publish(channel, json.dumps(event_dict))
logger.info(
f"Event published to Redis channel: {channel}",
extra={
'event_id': event_dict['id'],
'event_class': event_class,
'event_domain': event_domain,
'event_type': event_dict['event_type'],
}
)
except Exception as e:
logger.error(
f"Failed to publish event to Redis: {e}",
extra={
'event_id': str(event.id),
'tenant_id': tenant_id,
},
exc_info=True,
)
raise
async def cache_active_events(
self,
tenant_id: str,
event_domain: EventDomain,
event_class: EventClass,
events: list[Dict[str, Any]],
ttl_seconds: int = 3600,
) -> None:
"""
Cache active events for initial state loading.
Args:
tenant_id: Tenant identifier
event_domain: Event domain (inventory, production, etc.)
event_class: Event class (alert, notification, recommendation)
events: List of event dicts
ttl_seconds: Cache TTL in seconds (default 1 hour)
"""
try:
if event_class == EventClass.RECOMMENDATION:
# Recommendations: tenant-wide cache
cache_key = f"active_events:{tenant_id}:recommendations"
else:
# Domain-specific cache for alerts and notifications
cache_key = f"active_events:{tenant_id}:{event_domain.value}.{event_class.value}s"
# Store as JSON
await self.redis.setex(
cache_key,
ttl_seconds,
json.dumps(events)
)
logger.debug(
f"Cached active events: {cache_key}",
extra={
'count': len(events),
'ttl_seconds': ttl_seconds,
}
)
except Exception as e:
logger.error(
f"Failed to cache active events: {e}",
extra={
'tenant_id': tenant_id,
'event_domain': event_domain.value,
'event_class': event_class.value,
},
exc_info=True,
)
async def get_cached_events(
self,
tenant_id: str,
event_domain: EventDomain,
event_class: EventClass,
) -> list[Dict[str, Any]]:
"""
Get cached active events for initial state loading.
Args:
tenant_id: Tenant identifier
event_domain: Event domain
event_class: Event class
Returns:
List of cached event dicts
"""
try:
if event_class == EventClass.RECOMMENDATION:
cache_key = f"active_events:{tenant_id}:recommendations"
else:
cache_key = f"active_events:{tenant_id}:{event_domain.value}.{event_class.value}s"
cached_data = await self.redis.get(cache_key)
if not cached_data:
return []
return json.loads(cached_data)
except Exception as e:
logger.error(
f"Failed to get cached events: {e}",
extra={
'tenant_id': tenant_id,
'event_domain': event_domain.value,
'event_class': event_class.value,
},
exc_info=True,
)
return []
async def invalidate_cache(
self,
tenant_id: str,
event_domain: EventDomain = None,
event_class: EventClass = None,
) -> None:
"""
Invalidate cached events.
Args:
tenant_id: Tenant identifier
event_domain: If provided, invalidate specific domain cache
event_class: If provided, invalidate specific class cache
"""
try:
if event_domain and event_class:
# Invalidate specific cache
if event_class == EventClass.RECOMMENDATION:
cache_key = f"active_events:{tenant_id}:recommendations"
else:
cache_key = f"active_events:{tenant_id}:{event_domain.value}.{event_class.value}s"
await self.redis.delete(cache_key)
logger.debug(f"Invalidated cache: {cache_key}")
else:
# Invalidate all tenant caches
pattern = f"active_events:{tenant_id}:*"
keys = []
async for key in self.redis.scan_iter(match=pattern):
keys.append(key)
if keys:
await self.redis.delete(*keys)
logger.debug(f"Invalidated {len(keys)} cache keys for tenant {tenant_id}")
except Exception as e:
logger.error(
f"Failed to invalidate cache: {e}",
extra={'tenant_id': tenant_id},
exc_info=True,
)

View File

@@ -48,12 +48,12 @@ class AlertProcessorTenantDeletionService(BaseTenantDataDeletionService):
)
preview["alerts"] = alert_count or 0
# Note: AlertInteraction has CASCADE delete, so counting manually
# Note: EventInteraction has CASCADE delete, so counting manually
# Count alert interactions for informational purposes
from app.models.alerts import AlertInteraction
from app.models.events import EventInteraction
interaction_count = await self.db.scalar(
select(func.count(AlertInteraction.id)).where(
AlertInteraction.tenant_id == tenant_id
select(func.count(EventInteraction.id)).where(
EventInteraction.tenant_id == tenant_id
)
)
preview["alert_interactions"] = interaction_count or 0
@@ -88,11 +88,11 @@ class AlertProcessorTenantDeletionService(BaseTenantDataDeletionService):
Permanently delete all alert data for a tenant
Deletion order (respecting foreign key constraints):
1. AlertInteraction (child of Alert with CASCADE, but deleted explicitly for tracking)
1. EventInteraction (child of Alert with CASCADE, but deleted explicitly for tracking)
2. Alert (parent table)
3. AuditLog (independent)
Note: AlertInteraction has CASCADE delete from Alert, so it will be
Note: EventInteraction has CASCADE delete from Alert, so it will be
automatically deleted when Alert is deleted. We delete it explicitly
first for proper counting and logging.
@@ -106,14 +106,14 @@ class AlertProcessorTenantDeletionService(BaseTenantDataDeletionService):
result = TenantDataDeletionResult(tenant_id=tenant_id, service_name=self.service_name)
try:
# Import AlertInteraction here to avoid circular imports
from app.models.alerts import AlertInteraction
# Import EventInteraction here to avoid circular imports
from app.models.events import EventInteraction
# Step 1: Delete alert interactions (child of alerts)
logger.info("alert_processor.tenant_deletion.deleting_interactions", tenant_id=tenant_id)
interactions_result = await self.db.execute(
delete(AlertInteraction).where(
AlertInteraction.tenant_id == tenant_id
delete(EventInteraction).where(
EventInteraction.tenant_id == tenant_id
)
)
result.deleted_counts["alert_interactions"] = interactions_result.rowcount

View File

@@ -1,100 +0,0 @@
"""initial_schema_20251015_1230
Revision ID: 5ad7a76c1b10
Revises:
Create Date: 2025-10-15 12:30:29.410300+02:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '5ad7a76c1b10'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('alerts',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('item_type', sa.String(length=50), nullable=False),
sa.Column('alert_type', sa.String(length=100), nullable=False),
sa.Column('severity', sa.Enum('low', 'medium', 'high', 'urgent', name='alertseverity'), nullable=False),
sa.Column('status', sa.Enum('active', 'resolved', 'acknowledged', 'ignored', name='alertstatus'), nullable=True),
sa.Column('service', sa.String(length=100), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('actions', sa.JSON(), nullable=True),
sa.Column('alert_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_alerts_created_at'), 'alerts', ['created_at'], unique=False)
op.create_index(op.f('ix_alerts_severity'), 'alerts', ['severity'], unique=False)
op.create_index(op.f('ix_alerts_status'), 'alerts', ['status'], unique=False)
op.create_index(op.f('ix_alerts_tenant_id'), 'alerts', ['tenant_id'], unique=False)
op.create_table('audit_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('resource_type', sa.String(length=100), nullable=False),
sa.Column('resource_id', sa.String(length=255), nullable=True),
sa.Column('severity', sa.String(length=20), nullable=False),
sa.Column('service_name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('changes', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('audit_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('endpoint', sa.String(length=255), nullable=True),
sa.Column('method', sa.String(length=10), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_audit_resource_type_action', 'audit_logs', ['resource_type', 'action'], unique=False)
op.create_index('idx_audit_service_created', 'audit_logs', ['service_name', 'created_at'], unique=False)
op.create_index('idx_audit_severity_created', 'audit_logs', ['severity', 'created_at'], unique=False)
op.create_index('idx_audit_tenant_created', 'audit_logs', ['tenant_id', 'created_at'], unique=False)
op.create_index('idx_audit_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_service_name'), 'audit_logs', ['service_name'], unique=False)
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_service_name'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
op.drop_index('idx_audit_user_created', table_name='audit_logs')
op.drop_index('idx_audit_tenant_created', table_name='audit_logs')
op.drop_index('idx_audit_severity_created', table_name='audit_logs')
op.drop_index('idx_audit_service_created', table_name='audit_logs')
op.drop_index('idx_audit_resource_type_action', table_name='audit_logs')
op.drop_table('audit_logs')
op.drop_index(op.f('ix_alerts_tenant_id'), table_name='alerts')
op.drop_index(op.f('ix_alerts_status'), table_name='alerts')
op.drop_index(op.f('ix_alerts_severity'), table_name='alerts')
op.drop_index(op.f('ix_alerts_created_at'), table_name='alerts')
op.drop_table('alerts')
# ### end Alembic commands ###

View File

@@ -1,51 +0,0 @@
"""add_alert_interactions
Revision ID: a1b2c3d4e5f6
Revises: 5ad7a76c1b10
Create Date: 2025-10-19 14:30:00.000000+02:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '5ad7a76c1b10'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create alert_interactions table
op.create_table('alert_interactions',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('alert_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('interaction_type', sa.String(length=50), nullable=False),
sa.Column('interacted_at', sa.DateTime(), nullable=False),
sa.Column('response_time_seconds', sa.Integer(), nullable=True),
sa.Column('interaction_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['alert_id'], ['alerts.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for efficient queries
op.create_index('idx_alert_interactions_tenant_alert', 'alert_interactions', ['tenant_id', 'alert_id'], unique=False)
op.create_index('idx_alert_interactions_user', 'alert_interactions', ['user_id'], unique=False)
op.create_index('idx_alert_interactions_time', 'alert_interactions', ['interacted_at'], unique=False)
op.create_index('idx_alert_interactions_type', 'alert_interactions', ['interaction_type'], unique=False)
op.create_index('idx_alert_interactions_tenant_time', 'alert_interactions', ['tenant_id', 'interacted_at'], unique=False)
def downgrade() -> None:
op.drop_index('idx_alert_interactions_tenant_time', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_type', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_time', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_user', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_tenant_alert', table_name='alert_interactions')
op.drop_table('alert_interactions')

View File

@@ -0,0 +1,275 @@
"""Unified initial schema for alert-processor service
Revision ID: 20251125_unified_initial_schema
Revises:
Create Date: 2025-11-25
This is a unified migration that includes:
- All enum types (alertstatus, prioritylevel, alerttypeclass)
- Alerts table with full enrichment capabilities
- Alert interactions table for user engagement tracking
- Audit logs table for compliance and debugging
- All enhancements from incremental migrations:
- event_domain column
- action_created_at, superseded_by_action_id, hidden_from_ui columns
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '20251125_unified_initial_schema'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ============================================================
# Create Enum Types
# ============================================================
op.execute("""
CREATE TYPE alertstatus AS ENUM (
'active',
'resolved',
'acknowledged',
'ignored',
'in_progress',
'dismissed'
);
""")
op.execute("""
CREATE TYPE prioritylevel AS ENUM (
'critical',
'important',
'standard',
'info'
);
""")
op.execute("""
CREATE TYPE alerttypeclass AS ENUM (
'action_needed',
'prevented_issue',
'trend_warning',
'escalation',
'information'
);
""")
# ============================================================
# Create Alerts Table
# ============================================================
op.create_table('alerts',
# Core alert fields
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('item_type', sa.String(length=50), nullable=False),
sa.Column('event_domain', sa.String(50), nullable=True), # Added from 20251125_add_event_domain_column
sa.Column('alert_type', sa.String(length=100), nullable=False),
sa.Column('status', postgresql.ENUM('active', 'resolved', 'acknowledged', 'ignored', 'in_progress', 'dismissed', name='alertstatus', create_type=False), nullable=False),
sa.Column('service', sa.String(length=100), nullable=False),
sa.Column('title', sa.String(length=500), nullable=False), # Increased from 255 to match model
sa.Column('message', sa.Text(), nullable=False),
sa.Column('alert_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
# Priority scoring fields
sa.Column('priority_score', sa.Integer(), nullable=False),
sa.Column('priority_level', postgresql.ENUM('critical', 'important', 'standard', 'info', name='prioritylevel', create_type=False), nullable=False),
# Alert classification
sa.Column('type_class', postgresql.ENUM('action_needed', 'prevented_issue', 'trend_warning', 'escalation', 'information', name='alerttypeclass', create_type=False), nullable=False),
# Context enrichment (JSONB)
sa.Column('orchestrator_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('business_impact', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('urgency_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('user_agency', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('trend_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
# Smart actions
sa.Column('smart_actions', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
# AI reasoning
sa.Column('ai_reasoning_summary', sa.Text(), nullable=True),
sa.Column('confidence_score', sa.Float(), nullable=False, server_default='0.8'),
# Timing intelligence
sa.Column('timing_decision', sa.String(50), nullable=False, server_default='send_now'),
sa.Column('scheduled_send_time', sa.DateTime(timezone=True), nullable=True),
# Placement hints for frontend
sa.Column('placement', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
# Escalation & chaining (Added from 20251123_add_alert_enhancements)
sa.Column('action_created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('superseded_by_action_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('hidden_from_ui', sa.Boolean(), nullable=False, server_default='false'),
# Timestamps
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
# Constraints
sa.CheckConstraint('priority_score >= 0 AND priority_score <= 100', name='chk_priority_score_range')
)
# ============================================================
# Create Indexes for Alerts Table
# ============================================================
op.create_index(op.f('ix_alerts_created_at'), 'alerts', ['created_at'], unique=False)
op.create_index(op.f('ix_alerts_status'), 'alerts', ['status'], unique=False)
op.create_index(op.f('ix_alerts_tenant_id'), 'alerts', ['tenant_id'], unique=False)
# Enrichment indexes
op.create_index(
'idx_alerts_priority_score',
'alerts',
['tenant_id', 'priority_score', 'created_at'],
postgresql_using='btree'
)
op.create_index(
'idx_alerts_type_class',
'alerts',
['tenant_id', 'type_class', 'status'],
postgresql_using='btree'
)
op.create_index(
'idx_alerts_priority_level',
'alerts',
['priority_level', 'status'],
postgresql_using='btree'
)
op.create_index(
'idx_alerts_timing',
'alerts',
['timing_decision', 'scheduled_send_time'],
postgresql_using='btree',
postgresql_where=sa.text("timing_decision != 'send_now'")
)
# Domain index (from 20251125_add_event_domain_column)
op.create_index('idx_alerts_domain', 'alerts', ['tenant_id', 'event_domain', 'status'], unique=False)
# Escalation indexes (from 20251123_add_alert_enhancements)
op.create_index('idx_alerts_action_created', 'alerts', ['tenant_id', 'action_created_at'], unique=False)
op.create_index('idx_alerts_superseded', 'alerts', ['superseded_by_action_id'], unique=False)
op.create_index('idx_alerts_hidden', 'alerts', ['tenant_id', 'hidden_from_ui', 'status'], unique=False)
# ============================================================
# Create Alert Interactions Table
# ============================================================
op.create_table('alert_interactions',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('alert_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('interaction_type', sa.String(length=50), nullable=False),
sa.Column('interacted_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('response_time_seconds', sa.Integer(), nullable=True),
sa.Column('interaction_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['alert_id'], ['alerts.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for alert_interactions
op.create_index('idx_alert_interactions_tenant_alert', 'alert_interactions', ['tenant_id', 'alert_id'], unique=False)
op.create_index('idx_alert_interactions_user', 'alert_interactions', ['user_id'], unique=False)
op.create_index('idx_alert_interactions_time', 'alert_interactions', ['interacted_at'], unique=False)
op.create_index('idx_alert_interactions_type', 'alert_interactions', ['interaction_type'], unique=False)
op.create_index('idx_alert_interactions_tenant_time', 'alert_interactions', ['tenant_id', 'interacted_at'], unique=False)
# ============================================================
# Create Audit Logs Table
# ============================================================
op.create_table('audit_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('resource_type', sa.String(length=100), nullable=False),
sa.Column('resource_id', sa.String(length=255), nullable=True),
sa.Column('severity', sa.String(length=20), nullable=False),
sa.Column('service_name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('changes', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('audit_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('endpoint', sa.String(length=255), nullable=True),
sa.Column('method', sa.String(length=10), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for audit_logs
op.create_index('idx_audit_resource_type_action', 'audit_logs', ['resource_type', 'action'], unique=False)
op.create_index('idx_audit_service_created', 'audit_logs', ['service_name', 'created_at'], unique=False)
op.create_index('idx_audit_severity_created', 'audit_logs', ['severity', 'created_at'], unique=False)
op.create_index('idx_audit_tenant_created', 'audit_logs', ['tenant_id', 'created_at'], unique=False)
op.create_index('idx_audit_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_service_name'), 'audit_logs', ['service_name'], unique=False)
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
# Remove server defaults after table creation (for new inserts)
op.alter_column('alerts', 'confidence_score', server_default=None)
op.alter_column('alerts', 'timing_decision', server_default=None)
op.alter_column('alerts', 'hidden_from_ui', server_default=None)
def downgrade() -> None:
# Drop audit_logs table and indexes
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_service_name'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
op.drop_index('idx_audit_user_created', table_name='audit_logs')
op.drop_index('idx_audit_tenant_created', table_name='audit_logs')
op.drop_index('idx_audit_severity_created', table_name='audit_logs')
op.drop_index('idx_audit_service_created', table_name='audit_logs')
op.drop_index('idx_audit_resource_type_action', table_name='audit_logs')
op.drop_table('audit_logs')
# Drop alert_interactions table and indexes
op.drop_index('idx_alert_interactions_tenant_time', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_type', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_time', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_user', table_name='alert_interactions')
op.drop_index('idx_alert_interactions_tenant_alert', table_name='alert_interactions')
op.drop_table('alert_interactions')
# Drop alerts table and indexes
op.drop_index('idx_alerts_hidden', table_name='alerts')
op.drop_index('idx_alerts_superseded', table_name='alerts')
op.drop_index('idx_alerts_action_created', table_name='alerts')
op.drop_index('idx_alerts_domain', table_name='alerts')
op.drop_index('idx_alerts_timing', table_name='alerts')
op.drop_index('idx_alerts_priority_level', table_name='alerts')
op.drop_index('idx_alerts_type_class', table_name='alerts')
op.drop_index('idx_alerts_priority_score', table_name='alerts')
op.drop_index(op.f('ix_alerts_tenant_id'), table_name='alerts')
op.drop_index(op.f('ix_alerts_status'), table_name='alerts')
op.drop_index(op.f('ix_alerts_created_at'), table_name='alerts')
op.drop_table('alerts')
# Drop enum types
op.execute('DROP TYPE IF EXISTS alerttypeclass;')
op.execute('DROP TYPE IF EXISTS prioritylevel;')
op.execute('DROP TYPE IF EXISTS alertstatus;')

View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Alert Seeding Script for Alert Processor Service
ONLY seeds prevented-issue alerts (AI interventions with financial impact).
Action-needed alerts are system-generated and should not be seeded.
All alerts reference real seed data:
- Real ingredient IDs from inventory seed
- Real supplier IDs from supplier seed
- Real product names from recipes seed
- Historical data over past 7 days for trend analysis
"""
import asyncio
import uuid
import sys
import os
import random
from datetime import datetime, timezone, timedelta
from pathlib import Path
from decimal import Decimal
from typing import List, Dict, Any
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, delete
import structlog
from app.models.events import Alert, AlertStatus, PriorityLevel, AlertTypeClass
from app.config import AlertProcessorConfig
# Add shared utilities to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from shared.utils.demo_dates import BASE_REFERENCE_DATE, adjust_date_for_demo
# Configure logging
logger = structlog.get_logger()
# Demo tenant IDs (match those from other services)
DEMO_TENANT_IDS = [
uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"), # San Pablo
uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # La Espiga
]
# System user ID for AI actions
SYSTEM_USER_ID = uuid.UUID("50000000-0000-0000-0000-000000000004")
# ============================================================================
# REAL SEED DATA IDs (from demo seed scripts)
# ============================================================================
# Real ingredient IDs from inventory seed
HARINA_T55_ID = "10000000-0000-0000-0000-000000000001"
MANTEQUILLA_ID = "10000000-0000-0000-0000-000000000007"
HUEVOS_ID = "10000000-0000-0000-0000-000000000008"
AZUCAR_BLANCO_ID = "10000000-0000-0000-0000-000000000005"
NUECES_ID = "10000000-0000-0000-0000-000000000018"
PASAS_ID = "10000000-0000-0000-0000-000000000019"
# Real supplier IDs from supplier seed
MOLINOS_SAN_JOSE_ID = "40000000-0000-0000-0000-000000000001"
LACTEOS_DEL_VALLE_ID = "40000000-0000-0000-0000-000000000002"
# Real product names from recipes seed
PAN_DE_PUEBLO = "Pan de Pueblo"
BAGUETTE_FRANCESA = "Baguette Francesa Tradicional"
PAN_RUSTICO_CEREALES = "Pan Rústico de Cereales"
def create_prevented_issue_alerts(tenant_id: uuid.UUID, reference_time: datetime) -> List[Alert]:
"""Create prevented issue alerts showing AI interventions with financial impact"""
alerts = []
# Historical prevented issues over past 7 days
prevented_scenarios = [
{
"days_ago": 1,
"title": "Problema Evitado: Exceso de Stock de Harina",
"message": "Detecté que la orden automática iba a crear sobrestockexceso. Reduje la cantidad de 150kg a 100kg.",
"alert_type": "prevented_overstock",
"service": "inventory",
"priority_score": 55,
"priority_level": "standard",
"financial_impact": 87.50,
"ai_reasoning": "El análisis de demanda mostró una disminución del 15% en productos con harina. Ajusté la orden para evitar desperdicio.",
"confidence": 0.88,
"metadata": {
"ingredient_id": HARINA_T55_ID,
"ingredient": "Harina de Trigo T55",
"original_quantity_kg": 150,
"adjusted_quantity_kg": 100,
"savings_eur": 87.50,
"waste_prevented_kg": 50,
"supplier_id": MOLINOS_SAN_JOSE_ID,
"supplier": "Molinos San José S.L."
}
},
{
"days_ago": 2,
"title": "Problema Evitado: Conflicto de Equipamiento",
"message": "Evité un conflicto en el horno principal reprogramando el lote de baguettes 30 minutos antes.",
"alert_type": "prevented_equipment_conflict",
"service": "production",
"priority_score": 70,
"priority_level": "important",
"financial_impact": 0,
"ai_reasoning": "Dos lotes estaban programados para el mismo horno. Reprogramé automáticamente para optimizar el uso.",
"confidence": 0.94,
"metadata": {
"equipment": "Horno Principal",
"product_name": BAGUETTE_FRANCESA,
"batch_rescheduled": BAGUETTE_FRANCESA,
"time_adjustment_minutes": 30,
"downtime_prevented_minutes": 45
}
},
{
"days_ago": 3,
"title": "Problema Evitado: Compra Duplicada",
"message": "Detecté dos órdenes de compra casi idénticas para mantequilla. Cancelé la duplicada automáticamente.",
"alert_type": "prevented_duplicate_po",
"service": "procurement",
"priority_score": 62,
"priority_level": "standard",
"financial_impact": 245.80,
"ai_reasoning": "Dos servicios crearon órdenes similares con 10 minutos de diferencia. Cancelé la segunda para evitar sobrepedido.",
"confidence": 0.96,
"metadata": {
"ingredient_id": MANTEQUILLA_ID,
"ingredient": "Mantequilla sin Sal 82% MG",
"duplicate_po_amount": 245.80,
"time_difference_minutes": 10,
"supplier_id": LACTEOS_DEL_VALLE_ID,
"supplier": "Lácteos del Valle S.A."
}
},
{
"days_ago": 4,
"title": "Problema Evitado: Caducidad Inminente",
"message": "Prioricé automáticamente el uso de huevos que caducan en 2 días en lugar de stock nuevo.",
"alert_type": "prevented_expiration_waste",
"service": "inventory",
"priority_score": 58,
"priority_level": "standard",
"financial_impact": 34.50,
"ai_reasoning": "Detecté stock próximo a caducar. Ajusté el plan de producción para usar primero los ingredientes más antiguos.",
"confidence": 0.90,
"metadata": {
"ingredient_id": HUEVOS_ID,
"ingredient": "Huevos Frescos Categoría A",
"quantity_prioritized": 120,
"days_until_expiration": 2,
"waste_prevented_eur": 34.50
}
},
{
"days_ago": 5,
"title": "Problema Evitado: Sobrepago a Proveedor",
"message": "Detecté una discrepancia de precio en la orden de azúcar blanco. Precio cotizado: €2.20/kg, precio esperado: €1.85/kg.",
"alert_type": "prevented_price_discrepancy",
"service": "procurement",
"priority_score": 68,
"priority_level": "standard",
"financial_impact": 17.50,
"ai_reasoning": "El precio era 18.9% mayor que el histórico. Rechacé la orden automáticamente y notifiqué al proveedor.",
"confidence": 0.85,
"metadata": {
"ingredient_id": AZUCAR_BLANCO_ID,
"ingredient": "Azúcar Blanco Refinado",
"quoted_price_per_kg": 2.20,
"expected_price_per_kg": 1.85,
"quantity_kg": 50,
"savings_eur": 17.50,
"supplier": "Varios Distribuidores"
}
},
{
"days_ago": 6,
"title": "Problema Evitado: Pedido Sin Ingredientes",
"message": f"Un pedido de cliente incluía {PAN_RUSTICO_CEREALES}, pero no había suficiente stock. Sugerí sustitución con {PAN_DE_PUEBLO}.",
"alert_type": "prevented_unfulfillable_order",
"service": "orders",
"priority_score": 75,
"priority_level": "important",
"financial_impact": 0,
"ai_reasoning": "Detecté que el pedido no podía cumplirse con el stock actual. Ofrecí automáticamente una alternativa antes de confirmar.",
"confidence": 0.92,
"metadata": {
"original_product": PAN_RUSTICO_CEREALES,
"missing_ingredients": ["Semillas de girasol", "Semillas de sésamo"],
"suggested_alternative": PAN_DE_PUEBLO,
"customer_satisfaction_preserved": True
}
},
]
for scenario in prevented_scenarios:
created_at = reference_time - timedelta(days=scenario["days_ago"])
resolved_at = created_at + timedelta(seconds=1) # Instantly resolved by AI
alert = Alert(
id=uuid.uuid4(),
tenant_id=tenant_id,
item_type="alert",
alert_type=scenario["alert_type"],
service=scenario["service"],
title=scenario["title"],
message=scenario["message"],
status=AlertStatus.RESOLVED, # Already resolved by AI
priority_score=scenario["priority_score"],
priority_level=scenario["priority_level"],
type_class="prevented_issue", # KEY: This classifies as prevented
orchestrator_context={
"created_by": "ai_intervention_system",
"auto_resolved": True,
"resolution_method": "automatic"
},
business_impact={
"financial_impact": scenario["financial_impact"],
"currency": "EUR",
"orders_affected": scenario["metadata"].get("orders_affected", 0),
"impact_description": f"Ahorro estimado: €{scenario['financial_impact']:.2f}" if scenario["financial_impact"] > 0 else "Operación mejorada"
},
urgency_context={
"time_until_consequence": "0 segundos",
"consequence": "Problema resuelto automáticamente",
"resolution_time_ms": random.randint(100, 500)
},
user_agency={
"user_can_fix": False, # AI already fixed it
"requires_supplier": False,
"requires_external_party": False,
"estimated_resolution_time": "Automático"
},
trend_context=None,
smart_actions=[], # No actions needed - already resolved
ai_reasoning_summary=scenario["ai_reasoning"],
confidence_score=scenario["confidence"],
timing_decision="send_now",
scheduled_send_time=None,
placement=["dashboard"], # Only dashboard - not urgent since already resolved
action_created_at=None,
superseded_by_action_id=None,
hidden_from_ui=False,
alert_metadata=scenario["metadata"],
created_at=created_at,
updated_at=resolved_at,
resolved_at=resolved_at
)
alerts.append(alert)
return alerts
async def seed_demo_alerts():
"""Main function to seed demo alerts"""
logger.info("Starting demo alert seeding")
# Initialize database
config = AlertProcessorConfig()
engine = create_async_engine(config.DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
try:
# Delete existing alerts for demo tenants
for tenant_id in DEMO_TENANT_IDS:
logger.info("Deleting existing alerts", tenant_id=str(tenant_id))
await session.execute(
delete(Alert).where(Alert.tenant_id == tenant_id)
)
await session.commit()
logger.info("Existing alerts deleted")
# Create alerts for each tenant
reference_time = datetime.now(timezone.utc)
total_alerts_created = 0
for tenant_id in DEMO_TENANT_IDS:
logger.info("Creating prevented-issue alerts for tenant", tenant_id=str(tenant_id))
# Create prevented-issue alerts (historical AI interventions)
# NOTE: Action-needed alerts are NOT seeded - they are system-generated
prevented_alerts = create_prevented_issue_alerts(tenant_id, reference_time)
for alert in prevented_alerts:
session.add(alert)
logger.info(f"Created {len(prevented_alerts)} prevented-issue alerts")
total_alerts_created += len(prevented_alerts)
# Commit all alerts
await session.commit()
logger.info(
"Demo alert seeding completed",
total_alerts=total_alerts_created,
tenants=len(DEMO_TENANT_IDS)
)
print(f"\n✅ Successfully seeded {total_alerts_created} demo alerts")
print(f" - Prevented-issue alerts (AI interventions): {len(prevented_alerts) * len(DEMO_TENANT_IDS)}")
print(f" - Action-needed alerts: 0 (system-generated, not seeded)")
print(f" - Tenants: {len(DEMO_TENANT_IDS)}")
print(f"\n📝 Note: All alerts reference real seed data (ingredients, suppliers, products)")
except Exception as e:
logger.error("Error seeding demo alerts", error=str(e), exc_info=True)
await session.rollback()
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed_demo_alerts())