New alert system and panel de control page
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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)
|
||||
# ============================================================================
|
||||
|
||||
305
services/alert_processor/app/api/internal_demo.py
Normal file
305
services/alert_processor/app/api/internal_demo.py
Normal 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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
56
services/alert_processor/app/dependencies.py
Normal file
56
services/alert_processor/app/dependencies.py
Normal 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
|
||||
12
services/alert_processor/app/jobs/__init__.py
Normal file
12
services/alert_processor/app/jobs/__init__.py
Normal 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",
|
||||
]
|
||||
44
services/alert_processor/app/jobs/__main__.py
Normal file
44
services/alert_processor/app/jobs/__main__.py
Normal 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())
|
||||
337
services/alert_processor/app/jobs/priority_recalculation.py
Normal file
337
services/alert_processor/app/jobs/priority_recalculation.py
Normal 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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
402
services/alert_processor/app/models/events.py
Normal file
402
services/alert_processor/app/models/events.py
Normal 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'),
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
21
services/alert_processor/app/services/enrichment/__init__.py
Normal file
21
services/alert_processor/app/services/enrichment/__init__.py
Normal 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',
|
||||
]
|
||||
@@ -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
239
services/alert_processor/app/services/enrichment/email_digest.py
Normal file
239
services/alert_processor/app/services/enrichment/email_digest.py
Normal 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"),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
228
services/alert_processor/app/services/redis_publisher.py
Normal file
228
services/alert_processor/app/services/redis_publisher.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user