New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -12,6 +12,7 @@ WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
curl \
&& rm -rf /var/lib/apt/lists/*
@@ -37,5 +38,12 @@ COPY services/alert_processor/ .
ENV PYTHONPATH="/app:/app/shared:${PYTHONPATH:-}"
# Run application (worker service, not a web API)
CMD ["python", "-m", "app.main"]
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# ================================================================
# services/alert-processor/alembic.ini - Alembic Configuration
# services/alert_processor/alembic.ini - Alembic Configuration
# ================================================================
[alembic]
# path to migration scripts
@@ -43,7 +43,7 @@ recursive_version_locations = false
output_encoding = utf-8
# Database URL - will be overridden by environment variable or settings
sqlalchemy.url = postgresql+asyncpg://alert-processor_user:password@alert-processor-db-service:5432/alert-processor_db
sqlalchemy.url = postgresql+asyncpg://alert_processor_user:password@alert-processor-db-service:5432/alert_processor_db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run

View File

@@ -1 +0,0 @@
# Alert Processor Service

View File

@@ -1,9 +0,0 @@
"""
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', 'internal_demo_router']

View File

@@ -1,517 +1,430 @@
# services/alert_processor/app/api/alerts.py
"""
Alerts API endpoints for dashboard and alert management
Alert API endpoints.
"""
from fastapi import APIRouter, HTTPException, Query, Path, Depends
from fastapi import APIRouter, Depends, Query, HTTPException
from typing import List, Optional
from pydantic import BaseModel, Field
from uuid import UUID
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.repositories.alerts_repository import AlertsRepository
from app.models.events import AlertStatus
from app.dependencies import get_current_user
from app.core.database import get_db
from app.repositories.event_repository import EventRepository
from app.schemas.events import EventResponse, EventSummary
logger = structlog.get_logger()
router = APIRouter()
# ============================================================
# Response Models
# ============================================================
class AlertResponse(BaseModel):
"""Individual alert response"""
id: str
tenant_id: str
item_type: str
alert_type: str
priority_level: str
priority_score: int
status: str
service: str
title: str
message: str
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
resolved_at: Optional[datetime] = None
class Config:
from_attributes = True
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 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")
class AlertsListResponse(BaseModel):
"""List of alerts with pagination"""
alerts: List[AlertResponse]
total: int
limit: int
offset: int
# ============================================================
# API Endpoints
# ============================================================
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/summary",
response_model=AlertsSummaryResponse,
summary="Get alerts summary",
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")
) -> AlertsSummaryResponse:
"""
Get alerts summary for dashboard
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
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
summary = await repo.get_alerts_summary(tenant_id)
return AlertsSummaryResponse(**summary)
except Exception as e:
logger.error("Error getting alerts summary", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/api/v1/tenants/{tenant_id}/alerts",
response_model=AlertsListResponse,
summary="Get alerts list",
description="Get filtered list of alerts with pagination"
)
@router.get("/alerts", response_model=List[EventResponse])
async def get_alerts(
tenant_id: UUID = Path(..., description="Tenant ID"),
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"),
offset: int = Query(0, ge=0, description="Pagination offset")
) -> AlertsListResponse:
"""
Get filtered list of alerts
Supports filtering by:
- priority_level: critical, important, standard, info
- status: active, resolved, acknowledged, ignored
- resolved: boolean filter for resolved status
- pagination: limit and offset
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
# 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 priority level. Must be one of: {valid_priority_levels}"
)
# Validate status enum
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: {valid_status_values}"
)
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
alerts = await repo.get_alerts(
tenant_id=tenant_id,
priority_level=priority_level,
status=status,
resolved=resolved,
limit=limit,
offset=offset
)
# Convert to response models
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,
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,
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
))
return AlertsListResponse(
alerts=alert_responses,
total=len(alert_responses), # In a real implementation, you'd query the total count separately
limit=limit,
offset=offset
)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting alerts", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}",
response_model=AlertResponse,
summary="Get alert by ID",
description="Get a specific alert by its ID"
)
async def get_alert(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID")
) -> AlertResponse:
"""Get a specific alert by ID"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
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")
# 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,
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,
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
)
except HTTPException:
raise
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)
tenant_id: UUID,
event_class: Optional[str] = Query(None, description="Filter by event class"),
priority_level: Optional[List[str]] = Query(None, description="Filter by priority levels"),
status: Optional[List[str]] = Query(None, description="Filter by status values"),
event_domain: Optional[str] = Query(None, description="Filter by domain"),
limit: int = Query(50, le=100, description="Max results"),
offset: int = Query(0, description="Pagination offset"),
db: AsyncSession = Depends(get_db)
):
"""
Send email digest of alerts.
Get filtered list of events.
Digest includes:
- AI Impact Summary (prevented issues, savings)
- Prevented Issues List with AI reasoning
- Action Needed Alerts
- Trend Warnings
Query Parameters:
- event_class: alert, notification, recommendation
- priority_level: critical, important, standard, info
- status: active, acknowledged, resolved, dismissed
- event_domain: inventory, production, supply_chain, etc.
- limit: Max 100 results
- offset: For pagination
"""
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")
repo = EventRepository(db)
events = await repo.get_events(
tenant_id=tenant_id,
event_class=event_class,
priority_level=priority_level,
status=status,
event_domain=event_domain,
limit=limit,
offset=offset
)
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
}
# Convert to response models
return [repo._event_to_response(event) for event in events]
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)}")
logger.error("get_alerts_failed", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to retrieve alerts")
@router.get("/alerts/summary", response_model=EventSummary)
async def get_alerts_summary(
tenant_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Get summary statistics for dashboard.
Returns counts by:
- Status (active, acknowledged, resolved)
- Priority level (critical, important, standard, info)
- Domain (inventory, production, etc.)
- Type class (action_needed, prevented_issue, etc.)
"""
try:
repo = EventRepository(db)
summary = await repo.get_summary(tenant_id)
return summary
except Exception as e:
logger.error("get_summary_failed", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to retrieve summary")
@router.get("/alerts/{alert_id}", response_model=EventResponse)
async def get_alert(
tenant_id: UUID,
alert_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get single alert by ID"""
try:
repo = EventRepository(db)
event = await repo.get_event_by_id(alert_id)
if not event:
raise HTTPException(status_code=404, detail="Alert not found")
# Verify tenant ownership
if event.tenant_id != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
return repo._event_to_response(event)
except HTTPException:
raise
except Exception as e:
logger.error("get_alert_failed", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail="Failed to retrieve alert")
@router.post("/alerts/{alert_id}/acknowledge", response_model=EventResponse)
async def acknowledge_alert(
tenant_id: UUID,
alert_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Mark alert as acknowledged.
Sets status to 'acknowledged' and records timestamp.
"""
try:
repo = EventRepository(db)
# Verify ownership first
event = await repo.get_event_by_id(alert_id)
if not event:
raise HTTPException(status_code=404, detail="Alert not found")
if event.tenant_id != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
# Acknowledge
updated_event = await repo.acknowledge_event(alert_id)
return repo._event_to_response(updated_event)
except HTTPException:
raise
except Exception as e:
logger.error("acknowledge_alert_failed", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail="Failed to acknowledge alert")
@router.post("/alerts/{alert_id}/resolve", response_model=EventResponse)
async def resolve_alert(
tenant_id: UUID,
alert_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Mark alert as resolved.
Sets status to 'resolved' and records timestamp.
"""
try:
repo = EventRepository(db)
# Verify ownership first
event = await repo.get_event_by_id(alert_id)
if not event:
raise HTTPException(status_code=404, detail="Alert not found")
if event.tenant_id != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
# Resolve
updated_event = await repo.resolve_event(alert_id)
return repo._event_to_response(updated_event)
except HTTPException:
raise
except Exception as e:
logger.error("resolve_alert_failed", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail="Failed to resolve alert")
@router.post("/alerts/{alert_id}/dismiss", response_model=EventResponse)
async def dismiss_alert(
tenant_id: UUID,
alert_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Mark alert as dismissed.
Sets status to 'dismissed'.
"""
try:
repo = EventRepository(db)
# Verify ownership first
event = await repo.get_event_by_id(alert_id)
if not event:
raise HTTPException(status_code=404, detail="Alert not found")
if event.tenant_id != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
# Dismiss
updated_event = await repo.dismiss_event(alert_id)
return repo._event_to_response(updated_event)
except HTTPException:
raise
except Exception as e:
logger.error("dismiss_alert_failed", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail="Failed to dismiss alert")
@router.post("/alerts/{alert_id}/cancel-auto-action")
async def cancel_auto_action(
tenant_id: UUID,
alert_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Cancel an alert's auto-action (escalation countdown).
Changes type_class from 'escalation' to 'action_needed' if auto-action was pending.
"""
try:
repo = EventRepository(db)
# Verify ownership first
event = await repo.get_event_by_id(alert_id)
if not event:
raise HTTPException(status_code=404, detail="Alert not found")
if event.tenant_id != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
# Cancel auto-action (you'll need to implement this in repository)
# For now, return success response
return {
"success": True,
"event_id": str(alert_id),
"message": "Auto-action cancelled successfully",
"updated_type_class": "action_needed"
}
except HTTPException:
raise
except Exception as e:
logger.error("cancel_auto_action_failed", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail="Failed to cancel auto-action")
@router.post("/alerts/bulk-acknowledge")
async def bulk_acknowledge_alerts(
tenant_id: UUID,
request_body: dict,
db: AsyncSession = Depends(get_db)
):
"""
Acknowledge multiple alerts by metadata filter.
Request body:
{
"alert_type": "critical_stock_shortage",
"metadata_filter": {"ingredient_id": "123"}
}
"""
try:
alert_type = request_body.get("alert_type")
metadata_filter = request_body.get("metadata_filter", {})
if not alert_type:
raise HTTPException(status_code=400, detail="alert_type is required")
repo = EventRepository(db)
# Get matching alerts
events = await repo.get_events(
tenant_id=tenant_id,
event_class="alert",
status=["active"],
limit=100
)
# Filter by type and metadata
matching_ids = []
for event in events:
if event.event_type == alert_type:
# Check if metadata matches
matches = all(
event.event_metadata.get(key) == value
for key, value in metadata_filter.items()
)
if matches:
matching_ids.append(event.id)
# Acknowledge all matching
acknowledged_count = 0
for event_id in matching_ids:
try:
await repo.acknowledge_event(event_id)
acknowledged_count += 1
except Exception:
pass # Continue with others
return {
"success": True,
"acknowledged_count": acknowledged_count,
"alert_ids": [str(id) for id in matching_ids]
}
except HTTPException:
raise
except Exception as e:
logger.error("bulk_acknowledge_failed", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to bulk acknowledge alerts")
@router.post("/alerts/bulk-resolve")
async def bulk_resolve_alerts(
tenant_id: UUID,
request_body: dict,
db: AsyncSession = Depends(get_db)
):
"""
Resolve multiple alerts by metadata filter.
Request body:
{
"alert_type": "critical_stock_shortage",
"metadata_filter": {"ingredient_id": "123"}
}
"""
try:
alert_type = request_body.get("alert_type")
metadata_filter = request_body.get("metadata_filter", {})
if not alert_type:
raise HTTPException(status_code=400, detail="alert_type is required")
repo = EventRepository(db)
# Get matching alerts
events = await repo.get_events(
tenant_id=tenant_id,
event_class="alert",
status=["active", "acknowledged"],
limit=100
)
# Filter by type and metadata
matching_ids = []
for event in events:
if event.event_type == alert_type:
# Check if metadata matches
matches = all(
event.event_metadata.get(key) == value
for key, value in metadata_filter.items()
)
if matches:
matching_ids.append(event.id)
# Resolve all matching
resolved_count = 0
for event_id in matching_ids:
try:
await repo.resolve_event(event_id)
resolved_count += 1
except Exception:
pass # Continue with others
return {
"success": True,
"resolved_count": resolved_count,
"alert_ids": [str(id) for id in matching_ids]
}
except HTTPException:
raise
except Exception as e:
logger.error("bulk_resolve_failed", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to bulk resolve alerts")
@router.post("/events/{event_id}/interactions")
async def record_interaction(
tenant_id: UUID,
event_id: UUID,
request_body: dict,
db: AsyncSession = Depends(get_db)
):
"""
Record user interaction with an event (for analytics).
Request body:
{
"interaction_type": "viewed" | "clicked" | "dismissed" | "acted_upon",
"interaction_metadata": {...}
}
"""
try:
interaction_type = request_body.get("interaction_type")
interaction_metadata = request_body.get("interaction_metadata", {})
if not interaction_type:
raise HTTPException(status_code=400, detail="interaction_type is required")
repo = EventRepository(db)
# Verify event exists and belongs to tenant
event = await repo.get_event_by_id(event_id)
if not event:
raise HTTPException(status_code=404, detail="Event not found")
if event.tenant_id != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
# For now, just return success
# In the future, you could store interactions in a separate table
logger.info(
"interaction_recorded",
event_id=str(event_id),
interaction_type=interaction_type,
metadata=interaction_metadata
)
return {
"success": True,
"interaction_id": str(event_id), # Would be a real ID in production
"event_id": str(event_id),
"interaction_type": interaction_type
}
except HTTPException:
raise
except Exception as e:
logger.error("record_interaction_failed", error=str(e), event_id=str(event_id))
raise HTTPException(status_code=500, detail="Failed to record interaction")

View File

@@ -1,520 +0,0 @@
"""
Alert Analytics API Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Body, Query
from typing import List, Dict, Any, Optional
from uuid import UUID
from pydantic import BaseModel, Field
import structlog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import service_only_access
logger = structlog.get_logger()
router = APIRouter()
# Schemas
class InteractionCreate(BaseModel):
"""Schema for creating an alert interaction"""
alert_id: str = Field(..., description="Alert ID")
interaction_type: str = Field(..., description="Type of interaction: acknowledged, resolved, snoozed, dismissed")
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata")
class InteractionBatchCreate(BaseModel):
"""Schema for creating multiple interactions"""
interactions: List[Dict[str, Any]] = Field(..., description="List of interactions to create")
class AnalyticsResponse(BaseModel):
"""Schema for analytics response"""
trends: List[Dict[str, Any]]
averageResponseTime: int
topCategories: List[Dict[str, Any]]
totalAlerts: int
resolvedAlerts: int
activeAlerts: int
resolutionRate: int
predictedDailyAverage: int
busiestDay: str
def get_analytics_repository(current_user: dict = Depends(get_current_user_dep)):
"""Dependency to get analytics repository"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async def _get_repo():
async with db_manager.get_session() as session:
yield AlertAnalyticsRepository(session)
return _get_repo
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/interactions",
response_model=Dict[str, Any],
summary="Track alert interaction"
)
async def create_interaction(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID"),
interaction: InteractionCreate = Body(...),
current_user: dict = Depends(get_current_user_dep)
):
"""
Track a user interaction with an alert
- **acknowledged**: User has seen and acknowledged the alert
- **resolved**: User has resolved the alert
- **snoozed**: User has snoozed the alert
- **dismissed**: User has dismissed the alert
"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
alert_interaction = await repo.create_interaction(
tenant_id=tenant_id,
alert_id=alert_id,
user_id=UUID(current_user['user_id']),
interaction_type=interaction.interaction_type,
metadata=interaction.metadata
)
return {
'id': str(alert_interaction.id),
'alert_id': str(alert_interaction.alert_id),
'interaction_type': alert_interaction.interaction_type,
'interacted_at': alert_interaction.interacted_at.isoformat(),
'response_time_seconds': alert_interaction.response_time_seconds
}
except ValueError as e:
logger.error("Invalid alert interaction", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to create alert interaction", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=f"Failed to create interaction: {str(e)}")
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/interactions/batch",
response_model=Dict[str, Any],
summary="Track multiple alert interactions"
)
async def create_interactions_batch(
tenant_id: UUID = Path(..., description="Tenant ID"),
batch: InteractionBatchCreate = Body(...),
current_user: dict = Depends(get_current_user_dep)
):
"""
Track multiple alert interactions in a single request
Useful for offline sync or bulk operations
"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
# Add user_id to each interaction
for interaction in batch.interactions:
interaction['user_id'] = current_user['user_id']
created_interactions = await repo.create_interactions_batch(
tenant_id=tenant_id,
interactions=batch.interactions
)
return {
'created_count': len(created_interactions),
'interactions': [
{
'id': str(i.id),
'alert_id': str(i.alert_id),
'interaction_type': i.interaction_type,
'interacted_at': i.interacted_at.isoformat()
}
for i in created_interactions
]
}
except Exception as e:
logger.error("Failed to create batch interactions", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to create batch interactions: {str(e)}")
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/analytics",
response_model=AnalyticsResponse,
summary="Get alert analytics"
)
async def get_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Get comprehensive analytics for alerts
Returns:
- 7-day trend chart with severity breakdown
- Average response time (time to acknowledgment)
- Top 3 alert categories
- Total alerts, resolved, active counts
- Resolution rate percentage
- Predicted daily average
- Busiest day of week
"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
analytics = await repo.get_full_analytics(
tenant_id=tenant_id,
days=days
)
return analytics
except Exception as e:
logger.error("Failed to get alert analytics", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to get analytics: {str(e)}")
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/analytics/trends",
response_model=List[Dict[str, Any]],
summary="Get alert trends"
)
async def get_trends(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
current_user: dict = Depends(get_current_user_dep)
):
"""Get alert trends over time with severity breakdown"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
trends = await repo.get_analytics_trends(
tenant_id=tenant_id,
days=days
)
return trends
except Exception as e:
logger.error("Failed to get alert trends", error=str(e), tenant_id=str(tenant_id))
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)
# ============================================================================
@router.delete(
"/api/v1/alerts/tenant/{tenant_id}",
response_model=dict
)
@service_only_access
async def delete_tenant_data(
tenant_id: str = Path(..., description="Tenant ID to delete data for"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Delete all alert data for a tenant (Internal service only)
This endpoint is called by the orchestrator during tenant deletion.
It permanently deletes all alert-related data including:
- Alerts (all types and severities)
- Alert interactions
- Audit logs
**WARNING**: This operation is irreversible!
Returns:
Deletion summary with counts of deleted records
"""
from app.services.tenant_deletion_service import AlertProcessorTenantDeletionService
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
logger.info("alert_processor.tenant_deletion.api_called", tenant_id=tenant_id)
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
deletion_service = AlertProcessorTenantDeletionService(session)
result = await deletion_service.safe_delete_tenant_data(tenant_id)
if not result.success:
raise HTTPException(
status_code=500,
detail=f"Tenant data deletion failed: {', '.join(result.errors)}"
)
return {
"message": "Tenant data deletion completed successfully",
"summary": result.to_dict()
}
except HTTPException:
raise
except Exception as e:
logger.error("alert_processor.tenant_deletion.api_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to delete tenant data: {str(e)}"
)
@router.get(
"/api/v1/alerts/tenant/{tenant_id}/deletion-preview",
response_model=dict
)
@service_only_access
async def preview_tenant_data_deletion(
tenant_id: str = Path(..., description="Tenant ID to preview deletion for"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Preview what data would be deleted for a tenant (dry-run)
This endpoint shows counts of all data that would be deleted
without actually deleting anything. Useful for:
- Confirming deletion scope before execution
- Auditing and compliance
- Troubleshooting
Returns:
Dictionary with entity names and their counts
"""
from app.services.tenant_deletion_service import AlertProcessorTenantDeletionService
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
logger.info("alert_processor.tenant_deletion.preview_called", tenant_id=tenant_id)
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
deletion_service = AlertProcessorTenantDeletionService(session)
preview = await deletion_service.get_tenant_data_preview(tenant_id)
total_records = sum(preview.values())
return {
"tenant_id": tenant_id,
"service": "alert_processor",
"preview": preview,
"total_records": total_records,
"warning": "These records will be permanently deleted and cannot be recovered"
}
except Exception as e:
logger.error("alert_processor.tenant_deletion.preview_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to preview tenant data deletion: {str(e)}"
)

View File

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

View File

@@ -0,0 +1,70 @@
"""
Server-Sent Events (SSE) API endpoint.
"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from uuid import UUID
from redis.asyncio import Redis
import structlog
from shared.redis_utils import get_redis_client
from app.services.sse_service import SSEService
logger = structlog.get_logger()
router = APIRouter()
@router.get("/sse/alerts/{tenant_id}")
async def stream_alerts(tenant_id: UUID):
"""
Stream real-time alerts via Server-Sent Events (SSE).
Usage (frontend):
```javascript
const eventSource = new EventSource('/api/v1/sse/alerts/{tenant_id}');
eventSource.onmessage = (event) => {
const alert = JSON.parse(event.data);
console.log('New alert:', alert);
};
```
Response format:
```
data: {"id": "...", "event_type": "...", ...}
data: {"id": "...", "event_type": "...", ...}
```
"""
# Get Redis client from shared utilities
redis = await get_redis_client()
try:
sse_service = SSEService(redis)
async def event_generator():
"""Generator for SSE stream"""
try:
async for message in sse_service.subscribe_to_tenant(str(tenant_id)):
# Format as SSE message
yield f"data: {message}\n\n"
except Exception as e:
logger.error("sse_stream_error", error=str(e), tenant_id=str(tenant_id))
# Send error message and close
yield f"event: error\ndata: {str(e)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Disable nginx buffering
}
)
except Exception as e:
logger.error("sse_setup_failed", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to setup SSE stream")

View File

@@ -1,86 +0,0 @@
"""
Alert Processor API Server
Provides REST API endpoints for alert analytics
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import structlog
from app.config import AlertProcessorConfig
from app.api import analytics_router, alerts_router, internal_demo_router
from shared.database.base import create_database_manager
logger = structlog.get_logger()
# Create FastAPI app
app = FastAPI(
title="Alert Processor API",
description="API for alert analytics and interaction tracking",
version="1.0.0"
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 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()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor-api")
@app.on_event("startup")
async def startup():
"""Initialize on startup"""
logger.info("Alert Processor API starting up")
# Create tables
try:
from shared.database.base import Base
await db_manager.create_tables(Base.metadata)
logger.info("Database tables ensured")
except Exception as e:
logger.error("Failed to create tables", error=str(e))
@app.on_event("shutdown")
async def shutdown():
"""Cleanup on shutdown"""
logger.info("Alert Processor API shutting down")
await db_manager.close_connections()
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "alert-processor-api"}
@app.get("/")
async def root():
"""Root endpoint"""
return {
"service": "Alert Processor API",
"version": "1.0.0",
"endpoints": {
"health": "/health",
"docs": "/docs",
"analytics": "/api/v1/tenants/{tenant_id}/alerts/analytics",
"interactions": "/api/v1/tenants/{tenant_id}/alerts/{alert_id}/interactions"
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8010)

View File

@@ -1,117 +0,0 @@
# services/alert_processor/app/config.py
"""
Alert Processor Service Configuration
"""
import os
from typing import List
from shared.config.base import BaseServiceSettings
class AlertProcessorConfig(BaseServiceSettings):
"""Configuration for Alert Processor Service"""
SERVICE_NAME: str = "alert-processor"
APP_NAME: str = "Alert Processor Service"
DESCRIPTION: str = "Central alert and recommendation processor"
# Database configuration (secure approach - build from components)
@property
def DATABASE_URL(self) -> str:
"""Build database URL from secure components"""
# Try complete URL first (for backward compatibility)
complete_url = os.getenv("ALERT_PROCESSOR_DATABASE_URL")
if complete_url:
return complete_url
# Build from components (secure approach)
user = os.getenv("ALERT_PROCESSOR_DB_USER", "alert_processor_user")
password = os.getenv("ALERT_PROCESSOR_DB_PASSWORD", "alert_processor_pass123")
host = os.getenv("ALERT_PROCESSOR_DB_HOST", "localhost")
port = os.getenv("ALERT_PROCESSOR_DB_PORT", "5432")
name = os.getenv("ALERT_PROCESSOR_DB_NAME", "alert_processor_db")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
# Use dedicated Redis DB for alert processing
REDIS_DB: int = int(os.getenv("ALERT_PROCESSOR_REDIS_DB", "6"))
# Alert processing configuration
BATCH_SIZE: int = int(os.getenv("ALERT_BATCH_SIZE", "10"))
PROCESSING_TIMEOUT: int = int(os.getenv("ALERT_PROCESSING_TIMEOUT", "30"))
# Deduplication settings
ALERT_DEDUPLICATION_WINDOW_MINUTES: int = int(os.getenv("ALERT_DEDUPLICATION_WINDOW_MINUTES", "15"))
RECOMMENDATION_DEDUPLICATION_WINDOW_MINUTES: int = int(os.getenv("RECOMMENDATION_DEDUPLICATION_WINDOW_MINUTES", "60"))
# Alert severity channel mappings (hardcoded for now to avoid config parsing issues)
@property
def urgent_channels(self) -> List[str]:
return ["whatsapp", "email", "push", "dashboard"]
@property
def high_channels(self) -> List[str]:
return ["whatsapp", "email", "dashboard"]
@property
def medium_channels(self) -> List[str]:
return ["email", "dashboard"]
@property
def low_channels(self) -> List[str]:
return ["dashboard"]
# ============================================================
# ENRICHMENT CONFIGURATION (NEW)
# ============================================================
# Priority scoring weights
BUSINESS_IMPACT_WEIGHT: float = float(os.getenv("BUSINESS_IMPACT_WEIGHT", "0.4"))
URGENCY_WEIGHT: float = float(os.getenv("URGENCY_WEIGHT", "0.3"))
USER_AGENCY_WEIGHT: float = float(os.getenv("USER_AGENCY_WEIGHT", "0.2"))
CONFIDENCE_WEIGHT: float = float(os.getenv("CONFIDENCE_WEIGHT", "0.1"))
# Priority thresholds
CRITICAL_THRESHOLD: int = int(os.getenv("CRITICAL_THRESHOLD", "90"))
IMPORTANT_THRESHOLD: int = int(os.getenv("IMPORTANT_THRESHOLD", "70"))
STANDARD_THRESHOLD: int = int(os.getenv("STANDARD_THRESHOLD", "50"))
# Timing intelligence
TIMING_INTELLIGENCE_ENABLED: bool = os.getenv("TIMING_INTELLIGENCE_ENABLED", "true").lower() == "true"
BATCH_LOW_PRIORITY_ALERTS: bool = os.getenv("BATCH_LOW_PRIORITY_ALERTS", "true").lower() == "true"
BUSINESS_HOURS_START: int = int(os.getenv("BUSINESS_HOURS_START", "6"))
BUSINESS_HOURS_END: int = int(os.getenv("BUSINESS_HOURS_END", "22"))
PEAK_HOURS_START: int = int(os.getenv("PEAK_HOURS_START", "7"))
PEAK_HOURS_END: int = int(os.getenv("PEAK_HOURS_END", "11"))
PEAK_HOURS_EVENING_START: int = int(os.getenv("PEAK_HOURS_EVENING_START", "17"))
PEAK_HOURS_EVENING_END: int = int(os.getenv("PEAK_HOURS_EVENING_END", "19"))
# Grouping
GROUPING_TIME_WINDOW_MINUTES: int = int(os.getenv("GROUPING_TIME_WINDOW_MINUTES", "15"))
MAX_ALERTS_PER_GROUP: int = int(os.getenv("MAX_ALERTS_PER_GROUP", "5"))
# Email digest
EMAIL_DIGEST_ENABLED: bool = os.getenv("EMAIL_DIGEST_ENABLED", "true").lower() == "true"
DIGEST_SEND_TIME: str = os.getenv("DIGEST_SEND_TIME", "18:00")
DIGEST_SEND_TIME_HOUR: int = int(os.getenv("DIGEST_SEND_TIME", "18:00").split(":")[0])
DIGEST_MIN_ALERTS: int = int(os.getenv("DIGEST_MIN_ALERTS", "5"))
# Alert grouping
ALERT_GROUPING_ENABLED: bool = os.getenv("ALERT_GROUPING_ENABLED", "true").lower() == "true"
MIN_ALERTS_FOR_GROUPING: int = int(os.getenv("MIN_ALERTS_FOR_GROUPING", "3"))
# Trend detection
TREND_DETECTION_ENABLED: bool = os.getenv("TREND_DETECTION_ENABLED", "true").lower() == "true"
TREND_LOOKBACK_DAYS: int = int(os.getenv("TREND_LOOKBACK_DAYS", "7"))
TREND_SIGNIFICANCE_THRESHOLD: float = float(os.getenv("TREND_SIGNIFICANCE_THRESHOLD", "0.15"))
# Context enrichment
ENRICHMENT_TIMEOUT_SECONDS: int = int(os.getenv("ENRICHMENT_TIMEOUT_SECONDS", "10"))
ORCHESTRATOR_CONTEXT_CACHE_TTL: int = int(os.getenv("ORCHESTRATOR_CONTEXT_CACHE_TTL", "300"))
# Peak hours (aliases for enrichment services)
EVENING_PEAK_START: int = int(os.getenv("PEAK_HOURS_EVENING_START", "17"))
EVENING_PEAK_END: int = int(os.getenv("PEAK_HOURS_EVENING_END", "19"))
# Service URLs for enrichment
ORCHESTRATOR_SERVICE_URL: str = os.getenv("ORCHESTRATOR_SERVICE_URL", "http://orchestrator-service:8000")
INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
PRODUCTION_SERVICE_URL: str = os.getenv("PRODUCTION_SERVICE_URL", "http://production-service:8000")

View File

@@ -0,0 +1,239 @@
"""
RabbitMQ event consumer.
Consumes minimal events from services and processes them through
the enrichment pipeline.
"""
import asyncio
import json
from aio_pika import connect_robust, IncomingMessage, Connection, Channel
import structlog
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from shared.schemas.events import MinimalEvent
from app.services.enrichment_orchestrator import EnrichmentOrchestrator
from app.repositories.event_repository import EventRepository
from shared.clients.notification_client import create_notification_client
from app.services.sse_service import SSEService
logger = structlog.get_logger()
class EventConsumer:
"""
RabbitMQ consumer for processing events.
Workflow:
1. Receive minimal event from service
2. Enrich with context (AI, priority, impact, etc.)
3. Store in database
4. Send to notification service
5. Publish to SSE stream
"""
def __init__(self):
self.connection: Connection = None
self.channel: Channel = None
self.enricher = EnrichmentOrchestrator()
self.notification_client = create_notification_client(settings)
self.sse_svc = SSEService()
async def start(self):
"""Start consuming events from RabbitMQ"""
try:
# Connect to RabbitMQ
self.connection = await connect_robust(
settings.RABBITMQ_URL,
client_properties={"connection_name": "alert-processor"}
)
self.channel = await self.connection.channel()
await self.channel.set_qos(prefetch_count=10)
# Declare queue
queue = await self.channel.declare_queue(
settings.RABBITMQ_QUEUE,
durable=True
)
# Bind to events exchange with routing patterns
exchange = await self.channel.declare_exchange(
settings.RABBITMQ_EXCHANGE,
"topic",
durable=True
)
# Bind to alert, notification, and recommendation events
await queue.bind(exchange, routing_key="alert.#")
await queue.bind(exchange, routing_key="notification.#")
await queue.bind(exchange, routing_key="recommendation.#")
# Start consuming
await queue.consume(self.process_message)
logger.info(
"event_consumer_started",
queue=settings.RABBITMQ_QUEUE,
exchange=settings.RABBITMQ_EXCHANGE
)
except Exception as e:
logger.error("consumer_start_failed", error=str(e))
raise
async def process_message(self, message: IncomingMessage):
"""
Process incoming event message.
Steps:
1. Parse message
2. Validate as MinimalEvent
3. Enrich event
4. Store in database
5. Send notification
6. Publish to SSE
7. Acknowledge message
"""
async with message.process():
try:
# Parse message
data = json.loads(message.body.decode())
event = MinimalEvent(**data)
logger.info(
"event_received",
event_type=event.event_type,
event_class=event.event_class,
tenant_id=event.tenant_id
)
# Enrich the event
enriched_event = await self.enricher.enrich_event(event)
# Store in database
async with AsyncSessionLocal() as session:
repo = EventRepository(session)
stored_event = await repo.create_event(enriched_event)
# Send to notification service (if alert)
if event.event_class == "alert":
await self._send_notification(stored_event)
# Publish to SSE
await self.sse_svc.publish_event(stored_event)
logger.info(
"event_processed",
event_id=stored_event.id,
event_type=event.event_type,
priority_level=stored_event.priority_level,
priority_score=stored_event.priority_score
)
except json.JSONDecodeError as e:
logger.error(
"message_parse_failed",
error=str(e),
message_body=message.body[:200]
)
# Don't requeue - bad message format
except Exception as e:
logger.error(
"event_processing_failed",
error=str(e),
exc_info=True
)
# Message will be requeued automatically due to exception
async def _send_notification(self, event):
"""
Send notification using the shared notification client.
Args:
event: The event to send as a notification
"""
try:
# Prepare notification message
# Use i18n title and message from the event as the notification content
title = event.i18n_title_key if event.i18n_title_key else f"Alert: {event.event_type}"
message = event.i18n_message_key if event.i18n_message_key else f"New alert: {event.event_type}"
# Add parameters to make it more informative
if event.i18n_title_params:
title += f" - {event.i18n_title_params}"
if event.i18n_message_params:
message += f" - {event.i18n_message_params}"
# Prepare metadata from the event
metadata = {
"event_id": str(event.id),
"event_type": event.event_type,
"event_domain": event.event_domain,
"priority_score": event.priority_score,
"priority_level": event.priority_level,
"status": event.status,
"created_at": event.created_at.isoformat() if event.created_at else None,
"type_class": event.type_class,
"smart_actions": event.smart_actions,
"entity_links": event.entity_links
}
# Determine notification priority based on event priority
priority_map = {
"critical": "urgent",
"important": "high",
"standard": "normal",
"info": "low"
}
priority = priority_map.get(event.priority_level, "normal")
# Send notification using shared client
result = await self.notification_client.send_notification(
tenant_id=str(event.tenant_id),
notification_type="in_app", # Using in-app notification by default
message=message,
subject=title,
priority=priority,
metadata=metadata
)
if result:
logger.info(
"notification_sent_via_shared_client",
event_id=str(event.id),
tenant_id=str(event.tenant_id),
priority_level=event.priority_level
)
else:
logger.warning(
"notification_failed_via_shared_client",
event_id=str(event.id),
tenant_id=str(event.tenant_id)
)
except Exception as e:
logger.error(
"notification_error_via_shared_client",
error=str(e),
event_id=str(event.id),
tenant_id=str(event.tenant_id)
)
# Don't re-raise - we don't want to fail the entire event processing
# if notification sending fails
async def stop(self):
"""Stop consumer and close connections"""
try:
if self.channel:
await self.channel.close()
logger.info("rabbitmq_channel_closed")
if self.connection:
await self.connection.close()
logger.info("rabbitmq_connection_closed")
except Exception as e:
logger.error("consumer_stop_failed", error=str(e))

View File

@@ -1,23 +1,33 @@
# ================================================================
# services/alert_processor/app/core/config.py
# ================================================================
"""
Alert Processor Service Configuration
Configuration settings for alert processor service.
"""
import os
from pydantic import Field
from shared.config.base import BaseServiceSettings
class AlertProcessorSettings(BaseServiceSettings):
"""Alert Processor service specific settings"""
class Settings(BaseServiceSettings):
"""Application settings"""
# Service Identity
# Service info - override defaults
SERVICE_NAME: str = "alert-processor"
APP_NAME: str = "Alert Processor Service"
SERVICE_NAME: str = "alert-processor-service"
VERSION: str = "1.0.0"
DESCRIPTION: str = "Central alert and recommendation processor"
VERSION: str = "2.0.0"
# Alert processor specific settings
RABBITMQ_EXCHANGE: str = "events.exchange"
RABBITMQ_QUEUE: str = "alert_processor.queue"
REDIS_SSE_PREFIX: str = "alerts"
ORCHESTRATOR_TIMEOUT: int = 10
NOTIFICATION_TIMEOUT: int = 5
CACHE_ENABLED: bool = True
CACHE_TTL_SECONDS: int = 300
@property
def NOTIFICATION_URL(self) -> str:
"""Get notification service URL for backwards compatibility"""
return self.NOTIFICATION_SERVICE_URL
# Database configuration (secure approach - build from components)
@property
@@ -31,102 +41,11 @@ class AlertProcessorSettings(BaseServiceSettings):
# Build from components (secure approach)
user = os.getenv("ALERT_PROCESSOR_DB_USER", "alert_processor_user")
password = os.getenv("ALERT_PROCESSOR_DB_PASSWORD", "alert_processor_pass123")
host = os.getenv("ALERT_PROCESSOR_DB_HOST", "localhost")
host = os.getenv("ALERT_PROCESSOR_DB_HOST", "alert-processor-db-service")
port = os.getenv("ALERT_PROCESSOR_DB_PORT", "5432")
name = os.getenv("ALERT_PROCESSOR_DB_NAME", "alert_processor_db")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
# Use dedicated Redis DB for alert processing
REDIS_DB: int = int(os.getenv("ALERT_PROCESSOR_REDIS_DB", "6"))
# Alert processing configuration
BATCH_SIZE: int = int(os.getenv("ALERT_BATCH_SIZE", "10"))
PROCESSING_TIMEOUT: int = int(os.getenv("ALERT_PROCESSING_TIMEOUT", "30"))
# Deduplication settings
ALERT_DEDUPLICATION_WINDOW_MINUTES: int = int(os.getenv("ALERT_DEDUPLICATION_WINDOW_MINUTES", "15"))
RECOMMENDATION_DEDUPLICATION_WINDOW_MINUTES: int = int(os.getenv("RECOMMENDATION_DEDUPLICATION_WINDOW_MINUTES", "60"))
# Alert severity channel mappings (hardcoded for now to avoid config parsing issues)
@property
def urgent_channels(self) -> list[str]:
return ["whatsapp", "email", "push", "dashboard"]
@property
def high_channels(self) -> list[str]:
return ["whatsapp", "email", "dashboard"]
@property
def medium_channels(self) -> list[str]:
return ["email", "dashboard"]
@property
def low_channels(self) -> list[str]:
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")
# Global settings instance
settings = AlertProcessorSettings()
def get_settings():
"""Get the global settings instance"""
return settings
settings = Settings()

View File

@@ -0,0 +1,48 @@
"""
Database connection and session management for Alert Processor Service
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from .config import settings
from shared.database.base import DatabaseManager
# Initialize database manager
database_manager = DatabaseManager(
database_url=settings.DATABASE_URL,
service_name=settings.SERVICE_NAME,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
echo=settings.DEBUG
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
database_manager.async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncSession:
"""
Dependency to get database session.
Used in FastAPI endpoints via Depends(get_db).
"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
"""Initialize database (create tables if needed)"""
await database_manager.create_all()
async def close_db():
"""Close database connections"""
await database_manager.close()

View File

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

View File

@@ -0,0 +1 @@
"""Enrichment components for alert processing."""

View File

@@ -0,0 +1,147 @@
"""
Business impact analyzer for alerts.
Calculates financial impact, affected orders, customer impact, and other
business metrics from event metadata.
"""
from typing import Dict, Any
import structlog
logger = structlog.get_logger()
class BusinessImpactAnalyzer:
"""Analyze business impact from event metadata"""
def analyze(self, event_type: str, metadata: Dict[str, Any]) -> dict:
"""
Analyze business impact for an event.
Returns dict with:
- financial_impact_eur: Direct financial cost
- affected_orders: Number of orders impacted
- affected_customers: List of customer names
- production_delay_hours: Hours of production delay
- estimated_revenue_loss_eur: Potential revenue loss
- customer_impact: high/medium/low
- waste_risk_kg: Potential waste in kg
"""
impact = {
"financial_impact_eur": 0,
"affected_orders": 0,
"affected_customers": [],
"production_delay_hours": 0,
"estimated_revenue_loss_eur": 0,
"customer_impact": "low",
"waste_risk_kg": 0
}
# Stock-related impacts
if "stock" in event_type or "shortage" in event_type:
impact.update(self._analyze_stock_impact(metadata))
# Production-related impacts
elif "production" in event_type or "delay" in event_type or "equipment" in event_type:
impact.update(self._analyze_production_impact(metadata))
# Procurement-related impacts
elif "po_" in event_type or "delivery" in event_type:
impact.update(self._analyze_procurement_impact(metadata))
# Quality-related impacts
elif "quality" in event_type or "expired" in event_type:
impact.update(self._analyze_quality_impact(metadata))
return impact
def _analyze_stock_impact(self, metadata: Dict[str, Any]) -> dict:
"""Analyze impact of stock-related alerts"""
impact = {}
# Calculate financial impact
shortage_amount = metadata.get("shortage_amount", 0)
unit_cost = metadata.get("unit_cost", 5) # Default €5/kg
impact["financial_impact_eur"] = float(shortage_amount) * unit_cost
# Affected orders from metadata
impact["affected_orders"] = metadata.get("affected_orders", 0)
# Customer impact based on affected orders
if impact["affected_orders"] > 5:
impact["customer_impact"] = "high"
elif impact["affected_orders"] > 2:
impact["customer_impact"] = "medium"
# Revenue loss (estimated)
avg_order_value = 50 # €50 per order
impact["estimated_revenue_loss_eur"] = impact["affected_orders"] * avg_order_value
return impact
def _analyze_production_impact(self, metadata: Dict[str, Any]) -> dict:
"""Analyze impact of production-related alerts"""
impact = {}
# Delay minutes to hours
delay_minutes = metadata.get("delay_minutes", 0)
impact["production_delay_hours"] = round(delay_minutes / 60, 1)
# Affected orders and customers
impact["affected_orders"] = metadata.get("affected_orders", 0)
customer_names = metadata.get("customer_names", [])
impact["affected_customers"] = customer_names
# Customer impact based on delay
if delay_minutes > 120: # 2+ hours
impact["customer_impact"] = "high"
elif delay_minutes > 60: # 1+ hours
impact["customer_impact"] = "medium"
# Financial impact: hourly production cost
hourly_cost = 100 # €100/hour operational cost
impact["financial_impact_eur"] = impact["production_delay_hours"] * hourly_cost
# Revenue loss
if impact["affected_orders"] > 0:
avg_order_value = 50
impact["estimated_revenue_loss_eur"] = impact["affected_orders"] * avg_order_value
return impact
def _analyze_procurement_impact(self, metadata: Dict[str, Any]) -> dict:
"""Analyze impact of procurement-related alerts"""
impact = {}
# PO amount as financial impact
po_amount = metadata.get("po_amount", metadata.get("total_amount", 0))
impact["financial_impact_eur"] = float(po_amount)
# Days overdue affects customer impact
days_overdue = metadata.get("days_overdue", 0)
if days_overdue > 3:
impact["customer_impact"] = "high"
elif days_overdue > 1:
impact["customer_impact"] = "medium"
return impact
def _analyze_quality_impact(self, metadata: Dict[str, Any]) -> dict:
"""Analyze impact of quality-related alerts"""
impact = {}
# Expired products
expired_count = metadata.get("expired_count", 0)
total_value = metadata.get("total_value", 0)
impact["financial_impact_eur"] = float(total_value)
impact["waste_risk_kg"] = metadata.get("total_quantity_kg", 0)
if expired_count > 5:
impact["customer_impact"] = "high"
elif expired_count > 2:
impact["customer_impact"] = "medium"
return impact

View File

@@ -0,0 +1,244 @@
"""
Message generator for creating i18n message keys and parameters.
Converts minimal event metadata into structured i18n format for frontend translation.
"""
from typing import Dict, Any
from datetime import datetime
from app.utils.message_templates import ALERT_TEMPLATES, NOTIFICATION_TEMPLATES, RECOMMENDATION_TEMPLATES
import structlog
logger = structlog.get_logger()
class MessageGenerator:
"""Generates i18n message keys and parameters from event metadata"""
def generate_message(self, event_type: str, metadata: Dict[str, Any], event_class: str = "alert") -> dict:
"""
Generate i18n structure for frontend.
Args:
event_type: Alert/notification/recommendation type
metadata: Event metadata dictionary
event_class: One of: alert, notification, recommendation
Returns:
Dictionary with title_key, title_params, message_key, message_params
"""
# Select appropriate template collection
if event_class == "notification":
templates = NOTIFICATION_TEMPLATES
elif event_class == "recommendation":
templates = RECOMMENDATION_TEMPLATES
else:
templates = ALERT_TEMPLATES
template = templates.get(event_type)
if not template:
logger.warning("no_template_found", event_type=event_type, event_class=event_class)
return self._generate_fallback(event_type, metadata)
# Build parameters from metadata
title_params = self._build_params(template["title_params"], metadata)
message_params = self._build_params(template["message_params"], metadata)
# Select message variant based on context
message_key = self._select_message_variant(
template["message_variants"],
metadata
)
return {
"title_key": template["title_key"],
"title_params": title_params,
"message_key": message_key,
"message_params": message_params
}
def _generate_fallback(self, event_type: str, metadata: Dict[str, Any]) -> dict:
"""Generate fallback message structure when template not found"""
return {
"title_key": "alerts.generic.title",
"title_params": {},
"message_key": "alerts.generic.message",
"message_params": {
"event_type": event_type,
"metadata_summary": self._summarize_metadata(metadata)
}
}
def _summarize_metadata(self, metadata: Dict[str, Any]) -> str:
"""Create human-readable summary of metadata"""
# Take first 3 fields
items = list(metadata.items())[:3]
summary_parts = [f"{k}: {v}" for k, v in items]
return ", ".join(summary_parts)
def _build_params(self, param_mapping: dict, metadata: dict) -> dict:
"""
Extract and transform parameters from metadata.
param_mapping format: {"display_param_name": "metadata_key"}
"""
params = {}
for param_key, metadata_key in param_mapping.items():
if metadata_key in metadata:
value = metadata[metadata_key]
# Apply transformations based on parameter suffix
if param_key.endswith("_kg"):
value = round(float(value), 1)
elif param_key.endswith("_eur"):
value = round(float(value), 2)
elif param_key.endswith("_percentage"):
value = round(float(value), 1)
elif param_key.endswith("_date"):
value = self._format_date(value)
elif param_key.endswith("_day_name"):
value = self._format_day_name(value)
elif param_key.endswith("_datetime"):
value = self._format_datetime(value)
params[param_key] = value
return params
def _select_message_variant(self, variants: dict, metadata: dict) -> str:
"""
Select appropriate message variant based on metadata context.
Checks for specific conditions in priority order.
"""
# Check for PO-related variants
if "po_id" in metadata:
if metadata.get("po_status") == "pending_approval":
variant = variants.get("with_po_pending")
if variant:
return variant
else:
variant = variants.get("with_po_created")
if variant:
return variant
# Check for time-based variants
if "hours_until" in metadata:
variant = variants.get("with_hours")
if variant:
return variant
if "production_date" in metadata or "planned_date" in metadata:
variant = variants.get("with_date")
if variant:
return variant
# Check for customer-related variants
if "customer_names" in metadata and metadata.get("customer_names"):
variant = variants.get("with_customers")
if variant:
return variant
# Check for order-related variants
if "affected_orders" in metadata and metadata.get("affected_orders", 0) > 0:
variant = variants.get("with_orders")
if variant:
return variant
# Check for supplier contact variants
if "supplier_contact" in metadata:
variant = variants.get("with_supplier")
if variant:
return variant
# Check for batch-related variants
if "affected_batches" in metadata and metadata.get("affected_batches", 0) > 0:
variant = variants.get("with_batches")
if variant:
return variant
# Check for product names list variants
if "product_names" in metadata and metadata.get("product_names"):
variant = variants.get("with_names")
if variant:
return variant
# Check for time duration variants
if "hours_overdue" in metadata:
variant = variants.get("with_hours")
if variant:
return variant
if "days_overdue" in metadata:
variant = variants.get("with_days")
if variant:
return variant
# Default to generic variant
return variants.get("generic", variants[list(variants.keys())[0]])
def _format_date(self, date_value: Any) -> str:
"""
Format date for display.
Accepts:
- ISO string: "2025-12-10"
- datetime object
- date object
Returns: ISO format "YYYY-MM-DD"
"""
if isinstance(date_value, str):
# Already a string, might be ISO format
try:
dt = datetime.fromisoformat(date_value.replace('Z', '+00:00'))
return dt.date().isoformat()
except:
return date_value
if isinstance(date_value, datetime):
return date_value.date().isoformat()
if hasattr(date_value, 'isoformat'):
return date_value.isoformat()
return str(date_value)
def _format_day_name(self, date_value: Any) -> str:
"""
Format day name with date.
Example: "miércoles 10 de diciembre"
Note: Frontend will handle localization.
For now, return ISO date and let frontend format.
"""
iso_date = self._format_date(date_value)
try:
dt = datetime.fromisoformat(iso_date)
# Frontend will use this to format in user's language
return iso_date
except:
return iso_date
def _format_datetime(self, datetime_value: Any) -> str:
"""
Format datetime for display.
Returns: ISO 8601 format with timezone
"""
if isinstance(datetime_value, str):
return datetime_value
if isinstance(datetime_value, datetime):
return datetime_value.isoformat()
if hasattr(datetime_value, 'isoformat'):
return datetime_value.isoformat()
return str(datetime_value)

View File

@@ -0,0 +1,162 @@
"""
Orchestrator client for querying AI action context.
Queries the orchestrator service to determine if AI has already
addressed the issue and what actions were taken.
"""
from typing import Dict, Any, Optional
import httpx
import structlog
from datetime import datetime, timedelta
logger = structlog.get_logger()
class OrchestratorClient:
"""HTTP client for querying orchestrator service"""
def __init__(self, base_url: str = "http://orchestrator-service:8000"):
"""
Initialize orchestrator client.
Args:
base_url: Base URL of orchestrator service
"""
self.base_url = base_url
self.timeout = 10.0 # 10 second timeout
async def get_context(
self,
tenant_id: str,
event_type: str,
metadata: Dict[str, Any]
) -> dict:
"""
Query orchestrator for AI action context.
Returns dict with:
- already_addressed: Boolean - did AI handle this?
- action_type: Type of action taken
- action_id: ID of the action
- action_summary: Human-readable summary
- reasoning: AI reasoning for the action
- confidence: Confidence score (0-1)
- estimated_savings_eur: Estimated savings
- prevented_issue: What issue was prevented
- created_at: When action was created
"""
context = {
"already_addressed": False,
"confidence": 0.8 # Default confidence
}
try:
# Build query based on event type and metadata
query_params = self._build_query_params(event_type, metadata)
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/internal/recent-actions",
params={
"tenant_id": tenant_id,
**query_params
}
)
if response.status_code == 200:
data = response.json()
context.update(self._parse_response(data, event_type, metadata))
elif response.status_code == 404:
# No recent actions found - that's okay
logger.debug("no_orchestrator_actions", tenant_id=tenant_id, event_type=event_type)
else:
logger.warning(
"orchestrator_query_failed",
status_code=response.status_code,
tenant_id=tenant_id
)
except httpx.TimeoutException:
logger.warning("orchestrator_timeout", tenant_id=tenant_id, event_type=event_type)
except Exception as e:
logger.error("orchestrator_query_error", error=str(e), tenant_id=tenant_id)
return context
def _build_query_params(self, event_type: str, metadata: Dict[str, Any]) -> dict:
"""Build query parameters based on event type"""
params = {}
# For stock-related alerts, query for PO actions
if "stock" in event_type or "shortage" in event_type:
if metadata.get("ingredient_id"):
params["related_entity_type"] = "ingredient"
params["related_entity_id"] = metadata["ingredient_id"]
params["action_types"] = "purchase_order_created,purchase_order_approved"
# For production delays, query for batch adjustments
elif "production" in event_type or "delay" in event_type:
if metadata.get("batch_id"):
params["related_entity_type"] = "production_batch"
params["related_entity_id"] = metadata["batch_id"]
params["action_types"] = "production_adjusted,batch_rescheduled"
# For PO approval, check if already approved
elif "po_approval" in event_type:
if metadata.get("po_id"):
params["related_entity_type"] = "purchase_order"
params["related_entity_id"] = metadata["po_id"]
params["action_types"] = "purchase_order_approved,purchase_order_rejected"
# Look for recent actions (last 24 hours)
params["since_hours"] = 24
return params
def _parse_response(
self,
data: dict,
event_type: str,
metadata: Dict[str, Any]
) -> dict:
"""Parse orchestrator response into context"""
if not data or not data.get("actions"):
return {"already_addressed": False}
# Get most recent action
actions = data.get("actions", [])
if not actions:
return {"already_addressed": False}
most_recent = actions[0]
context = {
"already_addressed": True,
"action_type": most_recent.get("action_type"),
"action_id": most_recent.get("id"),
"action_summary": most_recent.get("summary", ""),
"reasoning": most_recent.get("reasoning", {}),
"confidence": most_recent.get("confidence", 0.8),
"created_at": most_recent.get("created_at"),
"action_status": most_recent.get("status", "completed")
}
# Extract specific fields based on action type
if most_recent.get("action_type") == "purchase_order_created":
context["estimated_savings_eur"] = most_recent.get("estimated_savings_eur", 0)
context["prevented_issue"] = "stockout"
if most_recent.get("delivery_date"):
context["delivery_date"] = most_recent["delivery_date"]
elif most_recent.get("action_type") == "production_adjusted":
context["prevented_issue"] = "production_delay"
context["adjustment_type"] = most_recent.get("adjustment_type")
return context

View File

@@ -0,0 +1,256 @@
"""
Multi-factor priority scoring for alerts.
Calculates priority score (0-100) based on:
- Business impact (40%): Financial impact, affected orders, customer impact
- Urgency (30%): Time until consequence, deadlines
- User agency (20%): Can user fix it? External dependencies?
- Confidence (10%): AI confidence in assessment
Also applies escalation boosts for age and deadline proximity.
"""
from typing import Dict, Any
import structlog
logger = structlog.get_logger()
class PriorityScorer:
"""Calculate multi-factor priority score (0-100)"""
# Weights for priority calculation
BUSINESS_IMPACT_WEIGHT = 0.4
URGENCY_WEIGHT = 0.3
USER_AGENCY_WEIGHT = 0.2
CONFIDENCE_WEIGHT = 0.1
# Priority thresholds
CRITICAL_THRESHOLD = 90
IMPORTANT_THRESHOLD = 70
STANDARD_THRESHOLD = 50
def calculate_priority(
self,
business_impact: dict,
urgency: dict,
user_agency: dict,
orchestrator_context: dict
) -> int:
"""
Calculate weighted priority score.
Args:
business_impact: Business impact context
urgency: Urgency context
user_agency: User agency context
orchestrator_context: AI orchestrator context
Returns:
Priority score (0-100)
"""
# Score each dimension (0-100)
impact_score = self._score_business_impact(business_impact)
urgency_score = self._score_urgency(urgency)
agency_score = self._score_user_agency(user_agency)
confidence_score = orchestrator_context.get("confidence", 0.8) * 100
# Weighted average
total_score = (
impact_score * self.BUSINESS_IMPACT_WEIGHT +
urgency_score * self.URGENCY_WEIGHT +
agency_score * self.USER_AGENCY_WEIGHT +
confidence_score * self.CONFIDENCE_WEIGHT
)
# Apply escalation boost if needed
escalation_boost = self._calculate_escalation_boost(urgency)
total_score = min(100, total_score + escalation_boost)
score = int(total_score)
logger.debug(
"priority_calculated",
score=score,
impact_score=impact_score,
urgency_score=urgency_score,
agency_score=agency_score,
confidence_score=confidence_score,
escalation_boost=escalation_boost
)
return score
def _score_business_impact(self, impact: dict) -> int:
"""
Score business impact (0-100).
Considers:
- Financial impact in EUR
- Number of affected orders
- Customer impact level
- Production delays
- Revenue at risk
"""
score = 50 # Base score
# Financial impact
financial_impact = impact.get("financial_impact_eur", 0)
if financial_impact > 1000:
score += 30
elif financial_impact > 500:
score += 20
elif financial_impact > 100:
score += 10
# Affected orders
affected_orders = impact.get("affected_orders", 0)
if affected_orders > 10:
score += 15
elif affected_orders > 5:
score += 10
elif affected_orders > 0:
score += 5
# Customer impact
customer_impact = impact.get("customer_impact", "low")
if customer_impact == "high":
score += 15
elif customer_impact == "medium":
score += 5
# Production delay hours
production_delay_hours = impact.get("production_delay_hours", 0)
if production_delay_hours > 4:
score += 10
elif production_delay_hours > 2:
score += 5
# Revenue loss
revenue_loss = impact.get("estimated_revenue_loss_eur", 0)
if revenue_loss > 500:
score += 10
elif revenue_loss > 200:
score += 5
return min(100, score)
def _score_urgency(self, urgency: dict) -> int:
"""
Score urgency (0-100).
Considers:
- Time until consequence
- Can it wait until tomorrow?
- Deadline proximity
- Peak hour relevance
"""
score = 50 # Base score
# Time until consequence
hours_until = urgency.get("hours_until_consequence", 24)
if hours_until < 2:
score += 40
elif hours_until < 6:
score += 30
elif hours_until < 12:
score += 20
elif hours_until < 24:
score += 10
# Can it wait?
if not urgency.get("can_wait_until_tomorrow", True):
score += 10
# Deadline present
if urgency.get("deadline_utc"):
score += 5
# Peak hour relevant (production/demand related)
if urgency.get("peak_hour_relevant", False):
score += 5
return min(100, score)
def _score_user_agency(self, agency: dict) -> int:
"""
Score user agency (0-100).
Higher score when user CAN fix the issue.
Lower score when blocked or requires external parties.
Considers:
- Can user fix it?
- Requires external party?
- Has blockers?
- Suggested workarounds available?
"""
score = 50 # Base score
# Can user fix?
if agency.get("can_user_fix", False):
score += 30
else:
score -= 20
# Requires external party?
if agency.get("requires_external_party", False):
score -= 10
# Has blockers?
blockers = agency.get("blockers", [])
score -= len(blockers) * 5
# Has suggested workaround?
if agency.get("suggested_workaround"):
score += 5
return max(0, min(100, score))
def _calculate_escalation_boost(self, urgency: dict) -> int:
"""
Calculate escalation boost for pending alerts.
Boosts priority for:
- Age-based escalation (pending >48h, >72h)
- Deadline proximity (<6h, <24h)
Maximum boost: +30 points
"""
boost = 0
# Age-based escalation
hours_pending = urgency.get("hours_pending", 0)
if hours_pending > 72:
boost += 20
elif hours_pending > 48:
boost += 10
# Deadline proximity
hours_until = urgency.get("hours_until_consequence", 24)
if hours_until < 6:
boost += 30
elif hours_until < 24:
boost += 15
# Cap at +30
return min(30, boost)
def get_priority_level(self, score: int) -> str:
"""
Convert numeric score to priority level.
- 90-100: critical
- 70-89: important
- 50-69: standard
- 0-49: info
"""
if score >= self.CRITICAL_THRESHOLD:
return "critical"
elif score >= self.IMPORTANT_THRESHOLD:
return "important"
elif score >= self.STANDARD_THRESHOLD:
return "standard"
else:
return "info"

View File

@@ -0,0 +1,304 @@
"""
Smart action generator for alerts.
Generates actionable buttons with deep links, phone numbers,
and other interactive elements based on alert type and metadata.
"""
from typing import Dict, Any, List
import structlog
logger = structlog.get_logger()
class SmartActionGenerator:
"""Generate smart action buttons for alerts"""
def generate_actions(
self,
event_type: str,
metadata: Dict[str, Any],
orchestrator_context: dict
) -> List[dict]:
"""
Generate smart actions for an event.
Each action has:
- action_type: Identifier for frontend handling
- label_key: i18n key for button label
- label_params: Parameters for label translation
- variant: primary/secondary/danger/ghost
- disabled: Boolean
- disabled_reason_key: i18n key if disabled
- consequence_key: i18n key for confirmation dialog
- url: Deep link or tel: or mailto:
- metadata: Additional data for action
"""
actions = []
# If AI already addressed, show "View Action" button
if orchestrator_context and orchestrator_context.get("already_addressed"):
actions.append(self._create_view_action(orchestrator_context))
return actions
# Generate actions based on event type
if "po_approval" in event_type:
actions.extend(self._create_po_approval_actions(metadata))
elif "stock" in event_type or "shortage" in event_type:
actions.extend(self._create_stock_actions(metadata))
elif "production" in event_type or "delay" in event_type:
actions.extend(self._create_production_actions(metadata))
elif "equipment" in event_type:
actions.extend(self._create_equipment_actions(metadata))
elif "delivery" in event_type or "overdue" in event_type:
actions.extend(self._create_delivery_actions(metadata))
elif "temperature" in event_type:
actions.extend(self._create_temperature_actions(metadata))
# Always add common actions
actions.extend(self._create_common_actions())
return actions
def _create_view_action(self, orchestrator_context: dict) -> dict:
"""Create action to view what AI did"""
return {
"action_type": "open_reasoning",
"label_key": "actions.view_ai_action",
"label_params": {},
"variant": "primary",
"disabled": False,
"metadata": {
"action_id": orchestrator_context.get("action_id"),
"action_type": orchestrator_context.get("action_type")
}
}
def _create_po_approval_actions(self, metadata: Dict[str, Any]) -> List[dict]:
"""Create actions for PO approval alerts"""
po_id = metadata.get("po_id")
po_amount = metadata.get("total_amount", metadata.get("po_amount", 0))
return [
{
"action_type": "approve_po",
"label_key": "actions.approve_po",
"label_params": {"amount": po_amount},
"variant": "primary",
"disabled": False,
"consequence_key": "actions.approve_po_consequence",
"url": f"/app/procurement/purchase-orders/{po_id}",
"metadata": {"po_id": po_id, "amount": po_amount}
},
{
"action_type": "reject_po",
"label_key": "actions.reject_po",
"label_params": {},
"variant": "danger",
"disabled": False,
"consequence_key": "actions.reject_po_consequence",
"url": f"/app/procurement/purchase-orders/{po_id}",
"metadata": {"po_id": po_id}
},
{
"action_type": "modify_po",
"label_key": "actions.modify_po",
"label_params": {},
"variant": "secondary",
"disabled": False,
"url": f"/app/procurement/purchase-orders/{po_id}/edit",
"metadata": {"po_id": po_id}
}
]
def _create_stock_actions(self, metadata: Dict[str, Any]) -> List[dict]:
"""Create actions for stock-related alerts"""
actions = []
# If supplier info available, add call button
if metadata.get("supplier_contact"):
actions.append({
"action_type": "call_supplier",
"label_key": "actions.call_supplier",
"label_params": {
"supplier": metadata.get("supplier_name", "Supplier"),
"phone": metadata.get("supplier_contact")
},
"variant": "primary",
"disabled": False,
"url": f"tel:{metadata['supplier_contact']}",
"metadata": {
"supplier_name": metadata.get("supplier_name"),
"phone": metadata.get("supplier_contact")
}
})
# If PO exists, add view PO button
if metadata.get("po_id"):
if metadata.get("po_status") == "pending_approval":
actions.append({
"action_type": "approve_po",
"label_key": "actions.approve_po",
"label_params": {"amount": metadata.get("po_amount", 0)},
"variant": "primary",
"disabled": False,
"url": f"/app/procurement/purchase-orders/{metadata['po_id']}",
"metadata": {"po_id": metadata["po_id"]}
})
else:
actions.append({
"action_type": "view_po",
"label_key": "actions.view_po",
"label_params": {"po_number": metadata.get("po_number", metadata["po_id"])},
"variant": "secondary",
"disabled": False,
"url": f"/app/procurement/purchase-orders/{metadata['po_id']}",
"metadata": {"po_id": metadata["po_id"]}
})
# Add create PO button if no PO exists
else:
actions.append({
"action_type": "create_po",
"label_key": "actions.create_po",
"label_params": {},
"variant": "primary",
"disabled": False,
"url": f"/app/procurement/purchase-orders/new?ingredient_id={metadata.get('ingredient_id')}",
"metadata": {"ingredient_id": metadata.get("ingredient_id")}
})
return actions
def _create_production_actions(self, metadata: Dict[str, Any]) -> List[dict]:
"""Create actions for production-related alerts"""
actions = []
if metadata.get("batch_id"):
actions.append({
"action_type": "view_batch",
"label_key": "actions.view_batch",
"label_params": {"batch_number": metadata.get("batch_number", "")},
"variant": "primary",
"disabled": False,
"url": f"/app/production/batches/{metadata['batch_id']}",
"metadata": {"batch_id": metadata["batch_id"]}
})
actions.append({
"action_type": "adjust_production",
"label_key": "actions.adjust_production",
"label_params": {},
"variant": "secondary",
"disabled": False,
"url": f"/app/production/batches/{metadata['batch_id']}/adjust",
"metadata": {"batch_id": metadata["batch_id"]}
})
return actions
def _create_equipment_actions(self, metadata: Dict[str, Any]) -> List[dict]:
"""Create actions for equipment-related alerts"""
return [
{
"action_type": "view_equipment",
"label_key": "actions.view_equipment",
"label_params": {"equipment_name": metadata.get("equipment_name", "")},
"variant": "primary",
"disabled": False,
"url": f"/app/production/equipment/{metadata.get('equipment_id')}",
"metadata": {"equipment_id": metadata.get("equipment_id")}
},
{
"action_type": "schedule_maintenance",
"label_key": "actions.schedule_maintenance",
"label_params": {},
"variant": "secondary",
"disabled": False,
"url": f"/app/production/equipment/{metadata.get('equipment_id')}/maintenance",
"metadata": {"equipment_id": metadata.get("equipment_id")}
}
]
def _create_delivery_actions(self, metadata: Dict[str, Any]) -> List[dict]:
"""Create actions for delivery-related alerts"""
actions = []
if metadata.get("supplier_contact"):
actions.append({
"action_type": "call_supplier",
"label_key": "actions.call_supplier",
"label_params": {
"supplier": metadata.get("supplier_name", "Supplier"),
"phone": metadata.get("supplier_contact")
},
"variant": "primary",
"disabled": False,
"url": f"tel:{metadata['supplier_contact']}",
"metadata": {
"supplier_name": metadata.get("supplier_name"),
"phone": metadata.get("supplier_contact")
}
})
if metadata.get("po_id"):
actions.append({
"action_type": "view_po",
"label_key": "actions.view_po",
"label_params": {"po_number": metadata.get("po_number", "")},
"variant": "secondary",
"disabled": False,
"url": f"/app/procurement/purchase-orders/{metadata['po_id']}",
"metadata": {"po_id": metadata["po_id"]}
})
return actions
def _create_temperature_actions(self, metadata: Dict[str, Any]) -> List[dict]:
"""Create actions for temperature breach alerts"""
return [
{
"action_type": "view_sensor",
"label_key": "actions.view_sensor",
"label_params": {"location": metadata.get("location", "")},
"variant": "primary",
"disabled": False,
"url": f"/app/inventory/sensors/{metadata.get('sensor_id')}",
"metadata": {"sensor_id": metadata.get("sensor_id")}
},
{
"action_type": "acknowledge_breach",
"label_key": "actions.acknowledge_breach",
"label_params": {},
"variant": "secondary",
"disabled": False,
"metadata": {"sensor_id": metadata.get("sensor_id")}
}
]
def _create_common_actions(self) -> List[dict]:
"""Create common actions available for all alerts"""
return [
{
"action_type": "snooze",
"label_key": "actions.snooze",
"label_params": {"hours": 4},
"variant": "ghost",
"disabled": False,
"metadata": {"snooze_hours": 4}
},
{
"action_type": "dismiss",
"label_key": "actions.dismiss",
"label_params": {},
"variant": "ghost",
"disabled": False,
"metadata": {}
}
]

View File

@@ -0,0 +1,138 @@
"""
Urgency analyzer for alerts.
Assesses time sensitivity, deadlines, and determines if action can wait.
"""
from typing import Dict, Any
from datetime import datetime, timedelta, timezone
import structlog
logger = structlog.get_logger()
class UrgencyAnalyzer:
"""Analyze urgency from event metadata"""
def analyze(self, event_type: str, metadata: Dict[str, Any]) -> dict:
"""
Analyze urgency for an event.
Returns dict with:
- hours_until_consequence: Time until impact occurs
- can_wait_until_tomorrow: Boolean
- deadline_utc: ISO datetime if deadline exists
- peak_hour_relevant: Boolean
- hours_pending: Age of alert
"""
urgency = {
"hours_until_consequence": 24, # Default: 24 hours
"can_wait_until_tomorrow": True,
"deadline_utc": None,
"peak_hour_relevant": False,
"hours_pending": 0
}
# Calculate based on event type
if "critical" in event_type or "urgent" in event_type:
urgency["hours_until_consequence"] = 2
urgency["can_wait_until_tomorrow"] = False
elif "production" in event_type:
urgency.update(self._analyze_production_urgency(metadata))
elif "stock" in event_type or "shortage" in event_type:
urgency.update(self._analyze_stock_urgency(metadata))
elif "delivery" in event_type or "overdue" in event_type:
urgency.update(self._analyze_delivery_urgency(metadata))
# Check for explicit deadlines
if "required_delivery_date" in metadata:
urgency.update(self._calculate_deadline_urgency(metadata["required_delivery_date"]))
if "production_date" in metadata:
urgency.update(self._calculate_deadline_urgency(metadata["production_date"]))
if "expected_date" in metadata:
urgency.update(self._calculate_deadline_urgency(metadata["expected_date"]))
return urgency
def _analyze_production_urgency(self, metadata: Dict[str, Any]) -> dict:
"""Analyze urgency for production alerts"""
urgency = {}
delay_minutes = metadata.get("delay_minutes", 0)
if delay_minutes > 120:
urgency["hours_until_consequence"] = 1
urgency["can_wait_until_tomorrow"] = False
elif delay_minutes > 60:
urgency["hours_until_consequence"] = 4
urgency["can_wait_until_tomorrow"] = False
else:
urgency["hours_until_consequence"] = 8
# Production is peak-hour sensitive
urgency["peak_hour_relevant"] = True
return urgency
def _analyze_stock_urgency(self, metadata: Dict[str, Any]) -> dict:
"""Analyze urgency for stock alerts"""
urgency = {}
# Hours until needed
if "hours_until" in metadata:
urgency["hours_until_consequence"] = metadata["hours_until"]
urgency["can_wait_until_tomorrow"] = urgency["hours_until_consequence"] > 24
# Days until expiry
elif "days_until_expiry" in metadata:
days = metadata["days_until_expiry"]
if days <= 1:
urgency["hours_until_consequence"] = days * 24
urgency["can_wait_until_tomorrow"] = False
else:
urgency["hours_until_consequence"] = days * 24
return urgency
def _analyze_delivery_urgency(self, metadata: Dict[str, Any]) -> dict:
"""Analyze urgency for delivery alerts"""
urgency = {}
days_overdue = metadata.get("days_overdue", 0)
if days_overdue > 3:
urgency["hours_until_consequence"] = 2
urgency["can_wait_until_tomorrow"] = False
elif days_overdue > 1:
urgency["hours_until_consequence"] = 8
urgency["can_wait_until_tomorrow"] = False
return urgency
def _calculate_deadline_urgency(self, deadline_str: str) -> dict:
"""Calculate urgency based on deadline"""
try:
if isinstance(deadline_str, str):
deadline = datetime.fromisoformat(deadline_str.replace('Z', '+00:00'))
else:
deadline = deadline_str
now = datetime.now(timezone.utc)
time_until = deadline - now
hours_until = time_until.total_seconds() / 3600
return {
"deadline_utc": deadline.isoformat(),
"hours_until_consequence": max(0, round(hours_until, 1)),
"can_wait_until_tomorrow": hours_until > 24
}
except Exception as e:
logger.warning("deadline_parse_failed", deadline=deadline_str, error=str(e))
return {}

View File

@@ -0,0 +1,116 @@
"""
User agency analyzer for alerts.
Determines whether user can fix the issue, what blockers exist,
and if external parties are required.
"""
from typing import Dict, Any
import structlog
logger = structlog.get_logger()
class UserAgencyAnalyzer:
"""Analyze user's ability to act on alerts"""
def analyze(
self,
event_type: str,
metadata: Dict[str, Any],
orchestrator_context: dict
) -> dict:
"""
Analyze user agency for an event.
Returns dict with:
- can_user_fix: Boolean - can user resolve this?
- requires_external_party: Boolean
- external_party_name: Name of required party
- external_party_contact: Contact info
- blockers: List of blocking factors
- suggested_workaround: Optional workaround suggestion
"""
agency = {
"can_user_fix": True,
"requires_external_party": False,
"external_party_name": None,
"external_party_contact": None,
"blockers": [],
"suggested_workaround": None
}
# If orchestrator already addressed it, user agency is low
if orchestrator_context and orchestrator_context.get("already_addressed"):
agency["can_user_fix"] = False
agency["blockers"].append("ai_already_handled")
return agency
# Analyze based on event type
if "po_approval" in event_type:
agency["can_user_fix"] = True
elif "delivery" in event_type or "supplier" in event_type:
agency.update(self._analyze_supplier_agency(metadata))
elif "equipment" in event_type:
agency.update(self._analyze_equipment_agency(metadata))
elif "stock" in event_type:
agency.update(self._analyze_stock_agency(metadata, orchestrator_context))
return agency
def _analyze_supplier_agency(self, metadata: Dict[str, Any]) -> dict:
"""Analyze agency for supplier-related alerts"""
agency = {
"requires_external_party": True,
"external_party_name": metadata.get("supplier_name"),
"external_party_contact": metadata.get("supplier_contact")
}
# User can contact supplier but can't directly fix
if not metadata.get("supplier_contact"):
agency["blockers"].append("no_supplier_contact")
return agency
def _analyze_equipment_agency(self, metadata: Dict[str, Any]) -> dict:
"""Analyze agency for equipment-related alerts"""
agency = {}
equipment_type = metadata.get("equipment_type", "")
if "oven" in equipment_type.lower() or "mixer" in equipment_type.lower():
agency["requires_external_party"] = True
agency["external_party_name"] = "Maintenance Team"
agency["blockers"].append("requires_technician")
return agency
def _analyze_stock_agency(
self,
metadata: Dict[str, Any],
orchestrator_context: dict
) -> dict:
"""Analyze agency for stock-related alerts"""
agency = {}
# If PO exists, user just needs to approve
if metadata.get("po_id"):
if metadata.get("po_status") == "pending_approval":
agency["can_user_fix"] = True
agency["suggested_workaround"] = "Approve pending PO"
else:
agency["blockers"].append("waiting_for_delivery")
agency["requires_external_party"] = True
agency["external_party_name"] = metadata.get("supplier_name")
# If no PO, user needs to create one
elif metadata.get("supplier_name"):
agency["can_user_fix"] = True
agency["requires_external_party"] = True
agency["external_party_name"] = metadata.get("supplier_name")
return agency

View File

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

View File

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

View File

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

View File

@@ -1,559 +1,137 @@
# services/alert_processor/app/main.py
"""
Alert Processor Service - Central hub for processing alerts and recommendations
Consumes from RabbitMQ, stores in database, and routes to notification service
Alert Processor Service v2.0
Main FastAPI application with RabbitMQ consumer lifecycle management.
"""
import asyncio
import json
import signal
import sys
from datetime import datetime
from typing import Dict, Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import structlog
from shared.redis_utils import initialize_redis, close_redis, get_redis_client
from aio_pika import connect_robust, IncomingMessage, ExchangeType
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from shared.clients.base_service_client import BaseServiceClient
from shared.config.rabbitmq_config import RABBITMQ_CONFIG
from app.core.config import settings
from app.consumer.event_consumer import EventConsumer
from app.api import alerts, sse
from shared.redis_utils import initialize_redis, close_redis
# 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
# Configure structured logging
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.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.add_log_level,
structlog.processors.JSONRenderer()
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
]
)
logger = structlog.get_logger()
# Global consumer instance
consumer: EventConsumer = None
class NotificationServiceClient(BaseServiceClient):
"""Client for notification service"""
def __init__(self, config: AlertProcessorConfig):
super().__init__("notification-service", config)
self.config = config
def get_service_base_path(self) -> str:
"""Return the base path for notification service APIs"""
return "/api/v1"
async def send_notification(self, tenant_id: str, notification: Dict[str, Any], channels: list) -> Dict[str, Any]:
"""Send notification via notification service"""
try:
response = await self.post(
"notifications/send",
data={
"tenant_id": tenant_id,
"notification": notification,
"channels": channels
}
)
return response if response else {"status": "failed", "error": "No response from notification service"}
except Exception as e:
logger.error("Failed to send notification", error=str(e), tenant_id=tenant_id)
return {"status": "failed", "error": str(e)}
class AlertProcessorService:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Central service for processing and routing alerts and recommendations
Integrates with notification service for multi-channel delivery
Application lifecycle manager.
Startup: Initialize Redis and RabbitMQ consumer
Shutdown: Close consumer and Redis connections
"""
def __init__(self, config: AlertProcessorConfig):
self.config = config
self.db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
self.notification_client = NotificationServiceClient(config)
self.redis = None
self.connection = None
self.channel = None
self.running = False
global consumer
# 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"""
try:
logger.info("Starting Alert Processor Service")
# Initialize shared Redis connection for SSE publishing
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
async def _setup_rabbitmq(self):
"""Setup RabbitMQ connection and configuration"""
self.connection = await connect_robust(
self.config.RABBITMQ_URL,
heartbeat=30,
connection_attempts=5
)
self.channel = await self.connection.channel()
await self.channel.set_qos(prefetch_count=10) # Process 10 messages at a time
# Setup exchange and queue based on config
exchange_config = RABBITMQ_CONFIG["exchanges"]["alerts"]
self.exchange = await self.channel.declare_exchange(
exchange_config["name"],
getattr(ExchangeType, exchange_config["type"].upper()),
durable=exchange_config["durable"]
)
queue_config = RABBITMQ_CONFIG["queues"]["alert_processing"]
self.queue = await self.channel.declare_queue(
queue_config["name"],
durable=queue_config["durable"],
arguments=queue_config["arguments"]
)
# Bind to all alert and recommendation routing keys
await self.queue.bind(self.exchange, routing_key="*.*.*")
logger.info("RabbitMQ setup completed")
async def _start_consuming(self):
"""Start consuming messages from RabbitMQ"""
await self.queue.consume(self.process_item)
logger.info("Started consuming alert messages")
async def process_item(self, message: IncomingMessage):
"""Process incoming alert or recommendation"""
async with message.process():
try:
# Parse message
item = json.loads(message.body.decode())
logger.info("Processing item",
item_type=item.get('item_type'),
alert_type=item.get('type'),
priority_level=item.get('priority_level', 'standard'),
tenant_id=item.get('tenant_id'))
# 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 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=enriched_item['tenant_id'],
notification={
'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 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=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 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 enriched alert instance
alert = Alert(
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("Enriched item stored in database",
item_id=enriched_item['id'],
priority_score=alert.priority_score,
type_class=alert.type_class)
# 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))
return alert_dict
async def _cache_active_alerts(self, tenant_id: str):
"""
Cache today's active alerts for a tenant in Redis for quick SSE access
Only caches alerts from today (00:00 UTC onwards) to avoid flooding
the dashboard with historical alerts on initial connection.
Analytics endpoints should query the database directly for historical data.
"""
try:
from app.models.events import Alert, AlertStatus
from sqlalchemy import select
async with self.db_manager.get_session() as session:
# Calculate start of today (UTC) to filter only today's alerts
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
# Query only today's active alerts for this tenant
# This prevents showing yesterday's alerts on dashboard initial load
query = select(Alert).where(
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE,
Alert.created_at >= today_start # Only today's alerts
).order_by(Alert.created_at.desc()).limit(50)
result = await session.execute(query)
alerts = result.scalars().all()
# Convert to enriched JSON-serializable format
active_items = []
for alert in alerts:
active_items.append(alert.to_dict())
# Cache in Redis with 1 hour TTL
cache_key = f"active_alerts:{tenant_id}"
await self.redis.setex(
cache_key,
3600, # 1 hour TTL
json.dumps(active_items)
)
logger.debug("Cached today's active alerts in Redis",
tenant_id=tenant_id,
count=len(active_items),
filter_date=today_start.isoformat())
except Exception as e:
logger.error("Failed to cache active alerts",
tenant_id=tenant_id,
error=str(e))
async def stream_to_sse(self, tenant_id: str, item: dict):
"""Publish enriched item to Redis for SSE streaming"""
channel = f"alerts:{tenant_id}"
# Item is already enriched dict from store_enriched_item
# Just ensure timestamp is serializable
sse_message = {
**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("Enriched item published to SSE",
tenant_id=tenant_id,
item_id=item['id'],
priority_score=item.get('priority_score'))
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)
# 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):
"""Stop the alert processor service"""
self.running = False
logger.info("Stopping Alert Processor Service")
try:
# Close RabbitMQ connection
if self.connection and not self.connection.is_closed:
await self.connection.close()
# Close shared Redis connection
await close_redis()
logger.info("Alert Processor Service stopped")
except Exception as e:
logger.error("Error stopping service", error=str(e))
def get_metrics(self) -> Dict[str, Any]:
"""Get service metrics"""
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
}
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()))
logger.info("alert_processor_starting", version=settings.VERSION)
# Startup: Initialize Redis and start consumer
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)
# Initialize Redis connection
await initialize_redis(
settings.REDIS_URL,
db=settings.REDIS_DB,
max_connections=settings.REDIS_MAX_CONNECTIONS
)
logger.info("redis_initialized")
# Keep running
while service.running:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Received keyboard interrupt")
consumer = EventConsumer()
await consumer.start()
logger.info("alert_processor_started")
except Exception as e:
logger.error("Service failed", error=str(e))
finally:
await service.stop()
logger.error("alert_processor_startup_failed", error=str(e))
raise
yield
# Shutdown: Stop consumer and close Redis
try:
if consumer:
await consumer.stop()
await close_redis()
logger.info("alert_processor_shutdown")
except Exception as e:
logger.error("alert_processor_shutdown_failed", error=str(e))
# Create FastAPI app
app = FastAPI(
title="Alert Processor Service",
description="Event processing, enrichment, and alert management system",
version=settings.VERSION,
lifespan=lifespan,
debug=settings.DEBUG
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(
alerts.router,
prefix="/api/v1/tenants/{tenant_id}",
tags=["alerts"]
)
app.include_router(
sse.router,
prefix="/api/v1",
tags=["sse"]
)
@app.get("/health")
async def health_check():
"""
Health check endpoint.
Returns service status and version.
"""
return {
"status": "healthy",
"service": settings.SERVICE_NAME,
"version": settings.VERSION
}
@app.get("/")
async def root():
"""Root endpoint with service info"""
return {
"service": settings.SERVICE_NAME,
"version": settings.VERSION,
"description": "Event processing, enrichment, and alert management system"
}
if __name__ == "__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
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

View File

@@ -1,42 +0,0 @@
"""
Alert Processor Service Models Package
Import all models to ensure they are registered with SQLAlchemy Base.
"""
# Import AuditLog model for this service
from shared.security import create_audit_log_model
from shared.database.base import Base
# Create audit log model for this service
AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
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",
"PriorityLevel",
"AlertTypeClass",
"NotificationType",
"RecommendationType",
# System
"AuditLog",
]

View File

@@ -1,402 +1,84 @@
"""
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.
SQLAlchemy models for events table.
"""
from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Float, CheckConstraint, Index, Boolean, Enum
from sqlalchemy import Column, String, Integer, DateTime, Float, Index
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime, timezone
import uuid
import enum
from shared.database.base import Base
Base = declarative_base()
def utc_now():
"""Return current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
class Event(Base):
"""Unified event table for alerts, notifications, recommendations"""
__tablename__ = "events"
# ============================================================
# 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
# Core fields
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
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False
)
# 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
)
# Classification
event_class = Column(String(50), nullable=False)
event_domain = Column(String(50), nullable=False, index=True)
event_type = Column(String(100), nullable=False, index=True)
service = Column(String(50), nullable=False)
# 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
)
# i18n content (NO hardcoded title/message)
i18n_title_key = Column(String(200), nullable=False)
i18n_title_params = Column(JSONB, nullable=False, default=dict)
i18n_message_key = Column(String(200), nullable=False)
i18n_message_params = Column(JSONB, nullable=False, default=dict)
# Enrichment context (JSONB)
# Priority
priority_score = Column(Integer, nullable=False, default=50, index=True)
priority_level = Column(String(20), nullable=False, index=True)
type_class = Column(String(50), nullable=False, index=True)
# Enrichment contexts (JSONB)
orchestrator_context = Column(JSONB, nullable=True)
business_impact = Column(JSONB, nullable=True)
urgency_context = Column(JSONB, nullable=True)
urgency = Column(JSONB, nullable=True)
user_agency = Column(JSONB, nullable=True)
trend_context = Column(JSONB, nullable=True)
# Smart actions
smart_actions = Column(JSONB, nullable=False)
smart_actions = Column(JSONB, nullable=False, default=list)
# 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)
ai_reasoning_summary_key = Column(String(200), nullable=True)
ai_reasoning_summary_params = Column(JSONB, nullable=True)
ai_reasoning_details = Column(JSONB, 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)
# Entity references
entity_links = Column(JSONB, nullable=False, default=dict)
# Status
status = Column(String(20), nullable=False, default="active", index=True)
resolved_at = Column(DateTime(timezone=True), nullable=True)
acknowledged_at = Column(DateTime(timezone=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)
event_metadata = Column(JSONB, nullable=False, default=dict)
# Indexes for dashboard queries
__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'),
Index('idx_events_tenant_status', 'tenant_id', 'status'),
Index('idx_events_tenant_priority', 'tenant_id', 'priority_score'),
Index('idx_events_tenant_class', 'tenant_id', 'event_class'),
Index('idx_events_tenant_created', 'tenant_id', 'created_at'),
Index('idx_events_type_class_status', 'type_class', 'status'),
)

View File

@@ -1,7 +0,0 @@
"""
Alert Processor Repositories
"""
from .analytics_repository import AlertAnalyticsRepository
__all__ = ['AlertAnalyticsRepository']

View File

@@ -1,189 +0,0 @@
# services/alert_processor/app/repositories/alerts_repository.py
"""
Alerts Repository - Database access layer for alerts
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from typing import List, Dict, Any, Optional
from uuid import UUID
import structlog
from app.models.events import Alert, AlertStatus
logger = structlog.get_logger()
class AlertsRepository:
"""Repository for alert database operations"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_alerts(
self,
tenant_id: UUID,
priority_level: Optional[str] = None,
status: Optional[str] = None,
resolved: Optional[bool] = None,
limit: int = 100,
offset: int = 0
) -> List[Alert]:
"""
Get alerts with optional filters
Args:
tenant_id: Tenant UUID
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
offset: Pagination offset
Returns:
List of Alert objects
"""
try:
query = select(Alert).where(Alert.tenant_id == tenant_id)
# Apply filters
if priority_level:
query = query.where(Alert.priority_level == priority_level)
if 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)
else:
query = query.where(Alert.status != AlertStatus.RESOLVED)
# Order by created_at descending (newest first)
query = query.order_by(Alert.created_at.desc())
# Apply pagination
query = query.limit(limit).offset(offset)
result = await self.db.execute(query)
alerts = result.scalars().all()
logger.info(
"Retrieved alerts",
tenant_id=str(tenant_id),
count=len(alerts),
filters={"priority_level": priority_level, "status": status, "resolved": resolved}
)
return list(alerts)
except Exception as e:
logger.error("Error retrieving alerts", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alerts_summary(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get summary of alerts by priority level and status
Args:
tenant_id: Tenant UUID
Returns:
Dict with counts by priority level and status
"""
try:
# Count by priority level
priority_query = (
select(
Alert.priority_level,
func.count(Alert.id).label("count")
)
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.status != AlertStatus.RESOLVED
)
)
.group_by(Alert.priority_level)
)
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 = (
select(
Alert.status,
func.count(Alert.id).label("count")
)
.where(Alert.tenant_id == tenant_id)
.group_by(Alert.status)
)
status_result = await self.db.execute(status_query)
status_counts = {row[0]: row[1] for row in status_result.all()}
# Count active alerts (not resolved)
active_count = sum(
count for status, count in status_counts.items()
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": 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(
"Retrieved alerts summary",
tenant_id=str(tenant_id),
summary=summary
)
return summary
except Exception as e:
logger.error("Error retrieving alerts summary", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alert_by_id(self, tenant_id: UUID, alert_id: UUID) -> Optional[Alert]:
"""Get a specific alert by ID"""
try:
query = select(Alert).where(
and_(
Alert.tenant_id == tenant_id,
Alert.id == alert_id
)
)
result = await self.db.execute(query)
alert = result.scalar_one_or_none()
if alert:
logger.info("Retrieved alert", alert_id=str(alert_id), tenant_id=str(tenant_id))
else:
logger.warning("Alert not found", alert_id=str(alert_id), tenant_id=str(tenant_id))
return alert
except Exception as e:
logger.error("Error retrieving alert", error=str(e), alert_id=str(alert_id))
raise

View File

@@ -1,508 +0,0 @@
"""
Alert Analytics Repository
Handles all database operations for alert analytics
"""
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
from uuid import UUID
from sqlalchemy import select, func, and_, extract, case
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.models.events import Alert, EventInteraction, AlertStatus
logger = structlog.get_logger()
class AlertAnalyticsRepository:
"""Repository for alert analytics operations"""
def __init__(self, session: AsyncSession):
self.session = session
async def create_interaction(
self,
tenant_id: UUID,
alert_id: UUID,
user_id: UUID,
interaction_type: str,
metadata: Optional[Dict[str, Any]] = None
) -> EventInteraction:
"""Create a new alert interaction"""
# Get alert to calculate response time
alert_query = select(Alert).where(Alert.id == alert_id)
result = await self.session.execute(alert_query)
alert = result.scalar_one_or_none()
if not alert:
raise ValueError(f"Alert {alert_id} not found")
# Calculate response time
now = datetime.utcnow()
response_time_seconds = int((now - alert.created_at).total_seconds())
# Create interaction
interaction = EventInteraction(
tenant_id=tenant_id,
alert_id=alert_id,
user_id=user_id,
interaction_type=interaction_type,
interacted_at=now,
response_time_seconds=response_time_seconds,
interaction_metadata=metadata or {}
)
self.session.add(interaction)
# Update alert status if applicable
if interaction_type == 'acknowledged' and alert.status == AlertStatus.ACTIVE:
alert.status = AlertStatus.ACKNOWLEDGED
elif interaction_type == 'resolved':
alert.status = AlertStatus.RESOLVED
alert.resolved_at = now
elif interaction_type == 'dismissed':
alert.status = AlertStatus.IGNORED
await self.session.commit()
await self.session.refresh(interaction)
logger.info(
"Alert interaction created",
alert_id=str(alert_id),
interaction_type=interaction_type,
response_time=response_time_seconds
)
return interaction
async def create_interactions_batch(
self,
tenant_id: UUID,
interactions: List[Dict[str, Any]]
) -> List[EventInteraction]:
"""Create multiple interactions in batch"""
created_interactions = []
for interaction_data in interactions:
try:
interaction = await self.create_interaction(
tenant_id=tenant_id,
alert_id=UUID(interaction_data['alert_id']),
user_id=UUID(interaction_data['user_id']),
interaction_type=interaction_data['interaction_type'],
metadata=interaction_data.get('metadata')
)
created_interactions.append(interaction)
except Exception as e:
logger.error(
"Failed to create interaction in batch",
error=str(e),
alert_id=interaction_data.get('alert_id')
)
continue
return created_interactions
async def get_analytics_trends(
self,
tenant_id: UUID,
days: int = 7
) -> List[Dict[str, Any]]:
"""Get alert trends for the last N days"""
start_date = datetime.utcnow() - timedelta(days=days)
# 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.priority_level == 'critical', 1), else_=0)
).label('urgent_count'),
func.sum(
case((Alert.priority_level == 'important', 1), else_=0)
).label('high_count'),
func.sum(
case((Alert.priority_level == 'standard', 1), else_=0)
).label('medium_count'),
func.sum(
case((Alert.priority_level == 'info', 1), else_=0)
).label('low_count')
)
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= start_date
)
)
.group_by(func.date(Alert.created_at))
.order_by(func.date(Alert.created_at))
)
result = await self.session.execute(query)
rows = result.all()
# Fill in missing dates with zeros
trends = []
current_date = start_date.date()
end_date = datetime.utcnow().date()
# Create a dict for quick lookup
data_by_date = {row.date: row for row in rows}
while current_date <= end_date:
date_str = current_date.isoformat()
row = data_by_date.get(current_date)
trends.append({
'date': date_str,
'count': int(row.total_count) if row else 0,
'urgentCount': int(row.urgent_count) if row else 0,
'highCount': int(row.high_count) if row else 0,
'mediumCount': int(row.medium_count) if row else 0,
'lowCount': int(row.low_count) if row else 0,
})
current_date += timedelta(days=1)
return trends
async def get_average_response_time(
self,
tenant_id: UUID,
days: int = 7
) -> int:
"""Get average response time in minutes for acknowledged alerts"""
start_date = datetime.utcnow() - timedelta(days=days)
query = (
select(func.avg(EventInteraction.response_time_seconds))
.where(
and_(
EventInteraction.tenant_id == tenant_id,
EventInteraction.interaction_type == 'acknowledged',
EventInteraction.interacted_at >= start_date,
EventInteraction.response_time_seconds < 86400 # Less than 24 hours
)
)
)
result = await self.session.execute(query)
avg_seconds = result.scalar_one_or_none()
if avg_seconds is None:
return 0
# Convert to minutes
return round(avg_seconds / 60)
async def get_top_categories(
self,
tenant_id: UUID,
days: int = 7,
limit: int = 3
) -> List[Dict[str, Any]]:
"""Get top alert categories"""
start_date = datetime.utcnow() - timedelta(days=days)
query = (
select(
Alert.alert_type,
func.count(Alert.id).label('count')
)
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= start_date
)
)
.group_by(Alert.alert_type)
.order_by(func.count(Alert.id).desc())
.limit(limit)
)
result = await self.session.execute(query)
rows = result.all()
# Calculate total for percentages
total_query = (
select(func.count(Alert.id))
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= start_date
)
)
)
total_result = await self.session.execute(total_query)
total = total_result.scalar_one() or 1
categories = []
for row in rows:
percentage = round((row.count / total) * 100) if total > 0 else 0
categories.append({
'category': row.alert_type,
'count': row.count,
'percentage': percentage
})
return categories
async def get_resolution_stats(
self,
tenant_id: UUID,
days: int = 7
) -> Dict[str, Any]:
"""Get resolution statistics"""
start_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 >= start_date
)
)
)
total_result = await self.session.execute(total_query)
total_alerts = total_result.scalar_one() or 0
# Resolved alerts
resolved_query = (
select(func.count(Alert.id))
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= start_date,
Alert.status == AlertStatus.RESOLVED
)
)
)
resolved_result = await self.session.execute(resolved_query)
resolved_alerts = resolved_result.scalar_one() or 0
# Active alerts
active_query = (
select(func.count(Alert.id))
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= start_date,
Alert.status == AlertStatus.ACTIVE
)
)
)
active_result = await self.session.execute(active_query)
active_alerts = active_result.scalar_one() or 0
resolution_rate = round((resolved_alerts / total_alerts) * 100) if total_alerts > 0 else 0
return {
'totalAlerts': total_alerts,
'resolvedAlerts': resolved_alerts,
'activeAlerts': active_alerts,
'resolutionRate': resolution_rate
}
async def get_busiest_day(
self,
tenant_id: UUID,
days: int = 7
) -> str:
"""Get busiest day of week"""
start_date = datetime.utcnow() - timedelta(days=days)
query = (
select(
extract('dow', Alert.created_at).label('day_of_week'),
func.count(Alert.id).label('count')
)
.where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= start_date
)
)
.group_by(extract('dow', Alert.created_at))
.order_by(func.count(Alert.id).desc())
.limit(1)
)
result = await self.session.execute(query)
row = result.first()
if not row:
return 'N/A'
day_names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
return day_names[int(row.day_of_week)]
async def get_predicted_daily_average(
self,
tenant_id: UUID,
days: int = 7
) -> int:
"""Calculate predicted daily average based on trends"""
trends = await self.get_analytics_trends(tenant_id, days)
if not trends:
return 0
total_count = sum(trend['count'] for trend in trends)
return round(total_count / len(trends))
async def get_full_analytics(
self,
tenant_id: UUID,
days: int = 7
) -> Dict[str, Any]:
"""Get complete analytics data"""
trends = await self.get_analytics_trends(tenant_id, days)
avg_response_time = await self.get_average_response_time(tenant_id, days)
top_categories = await self.get_top_categories(tenant_id, days)
resolution_stats = await self.get_resolution_stats(tenant_id, days)
busiest_day = await self.get_busiest_day(tenant_id, days)
predicted_avg = await self.get_predicted_daily_average(tenant_id, days)
return {
'trends': trends,
'averageResponseTime': avg_response_time,
'topCategories': top_categories,
'totalAlerts': resolution_stats['totalAlerts'],
'resolvedAlerts': resolution_stats['resolvedAlerts'],
'activeAlerts': resolution_stats['activeAlerts'],
'resolutionRate': resolution_stats['resolutionRate'],
'predictedDailyAverage': predicted_avg,
'busiestDay': busiest_day
}
async def get_period_comparison(
self,
tenant_id: UUID,
current_days: int = 7,
previous_days: int = 7
) -> Dict[str, Any]:
"""
Compare current period metrics with previous period.
Used for week-over-week trend analysis in dashboard cards.
Args:
tenant_id: Tenant ID
current_days: Number of days in current period (default 7)
previous_days: Number of days in previous period (default 7)
Returns:
Dictionary with current/previous metrics and percentage changes
"""
from datetime import datetime, timedelta
now = datetime.utcnow()
current_start = now - timedelta(days=current_days)
previous_start = current_start - timedelta(days=previous_days)
previous_end = current_start
# Current period: AI handling rate (prevented issues / total)
current_total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= current_start,
Alert.created_at <= now
)
)
current_total_result = await self.session.execute(current_total_query)
current_total = current_total_result.scalar() or 0
current_prevented_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= current_start,
Alert.created_at <= now
)
)
current_prevented_result = await self.session.execute(current_prevented_query)
current_prevented = current_prevented_result.scalar() or 0
current_handling_rate = (
(current_prevented / current_total * 100)
if current_total > 0 else 0
)
# Previous period: AI handling rate
previous_total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= previous_start,
Alert.created_at < previous_end
)
)
previous_total_result = await self.session.execute(previous_total_query)
previous_total = previous_total_result.scalar() or 0
previous_prevented_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= previous_start,
Alert.created_at < previous_end
)
)
previous_prevented_result = await self.session.execute(previous_prevented_query)
previous_prevented = previous_prevented_result.scalar() or 0
previous_handling_rate = (
(previous_prevented / previous_total * 100)
if previous_total > 0 else 0
)
# Calculate percentage change
if previous_handling_rate > 0:
handling_rate_change = round(
((current_handling_rate - previous_handling_rate) / previous_handling_rate) * 100,
1
)
elif current_handling_rate > 0:
handling_rate_change = 100.0 # Went from 0% to something
else:
handling_rate_change = 0.0
# Alert count change
if previous_total > 0:
alert_count_change = round(
((current_total - previous_total) / previous_total) * 100,
1
)
elif current_total > 0:
alert_count_change = 100.0
else:
alert_count_change = 0.0
return {
'current_period': {
'days': current_days,
'total_alerts': current_total,
'prevented_issues': current_prevented,
'handling_rate_percentage': round(current_handling_rate, 1)
},
'previous_period': {
'days': previous_days,
'total_alerts': previous_total,
'prevented_issues': previous_prevented,
'handling_rate_percentage': round(previous_handling_rate, 1)
},
'changes': {
'handling_rate_change_percentage': handling_rate_change,
'alert_count_change_percentage': alert_count_change,
'trend_direction': 'up' if handling_rate_change > 0 else ('down' if handling_rate_change < 0 else 'stable')
}
}

View File

@@ -0,0 +1,306 @@
"""
Event repository for database operations.
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, desc
from sqlalchemy.dialects.postgresql import insert
import structlog
from app.models.events import Event
from app.schemas.events import EnrichedEvent, EventSummary, EventResponse, I18nContent, SmartAction
logger = structlog.get_logger()
class EventRepository:
"""Repository for event database operations"""
def __init__(self, session: AsyncSession):
self.session = session
async def create_event(self, enriched_event: EnrichedEvent) -> Event:
"""
Store enriched event in database.
Args:
enriched_event: Enriched event with all context
Returns:
Stored Event model
"""
# Convert enriched event to database model
event = Event(
id=enriched_event.id,
tenant_id=UUID(enriched_event.tenant_id),
event_class=enriched_event.event_class,
event_domain=enriched_event.event_domain,
event_type=enriched_event.event_type,
service=enriched_event.service,
# i18n content
i18n_title_key=enriched_event.i18n.title_key,
i18n_title_params=enriched_event.i18n.title_params,
i18n_message_key=enriched_event.i18n.message_key,
i18n_message_params=enriched_event.i18n.message_params,
# Priority
priority_score=enriched_event.priority_score,
priority_level=enriched_event.priority_level,
type_class=enriched_event.type_class,
# Enrichment contexts
orchestrator_context=enriched_event.orchestrator_context.dict() if enriched_event.orchestrator_context else None,
business_impact=enriched_event.business_impact.dict() if enriched_event.business_impact else None,
urgency=enriched_event.urgency.dict() if enriched_event.urgency else None,
user_agency=enriched_event.user_agency.dict() if enriched_event.user_agency else None,
trend_context=enriched_event.trend_context,
# Smart actions
smart_actions=[action.dict() for action in enriched_event.smart_actions],
# AI reasoning
ai_reasoning_summary_key=enriched_event.ai_reasoning_summary_key,
ai_reasoning_summary_params=enriched_event.ai_reasoning_summary_params,
ai_reasoning_details=enriched_event.ai_reasoning_details,
confidence_score=enriched_event.confidence_score,
# Entity links
entity_links=enriched_event.entity_links,
# Status
status=enriched_event.status,
# Metadata
event_metadata=enriched_event.event_metadata
)
self.session.add(event)
await self.session.commit()
await self.session.refresh(event)
logger.info("event_stored", event_id=event.id, event_type=event.event_type)
return event
async def get_events(
self,
tenant_id: UUID,
event_class: Optional[str] = None,
priority_level: Optional[List[str]] = None,
status: Optional[List[str]] = None,
event_domain: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Event]:
"""
Get filtered list of events.
Args:
tenant_id: Tenant UUID
event_class: Filter by event class (alert, notification, recommendation)
priority_level: Filter by priority levels
status: Filter by status values
event_domain: Filter by domain
limit: Max results
offset: Pagination offset
Returns:
List of Event models
"""
query = select(Event).where(Event.tenant_id == tenant_id)
# Apply filters
if event_class:
query = query.where(Event.event_class == event_class)
if priority_level:
query = query.where(Event.priority_level.in_(priority_level))
if status:
query = query.where(Event.status.in_(status))
if event_domain:
query = query.where(Event.event_domain == event_domain)
# Order by priority and creation time
query = query.order_by(
desc(Event.priority_score),
desc(Event.created_at)
)
# Pagination
query = query.limit(limit).offset(offset)
result = await self.session.execute(query)
events = result.scalars().all()
return list(events)
async def get_event_by_id(self, event_id: UUID) -> Optional[Event]:
"""Get single event by ID"""
query = select(Event).where(Event.id == event_id)
result = await self.session.execute(query)
return result.scalar_one_or_none()
async def get_summary(self, tenant_id: UUID) -> EventSummary:
"""
Get summary statistics for dashboard.
Args:
tenant_id: Tenant UUID
Returns:
EventSummary with counts and statistics
"""
# Count by status
status_query = select(
Event.status,
func.count(Event.id).label('count')
).where(
Event.tenant_id == tenant_id
).group_by(Event.status)
status_result = await self.session.execute(status_query)
status_counts = {row.status: row.count for row in status_result}
# Count by priority
priority_query = select(
Event.priority_level,
func.count(Event.id).label('count')
).where(
and_(
Event.tenant_id == tenant_id,
Event.status == "active"
)
).group_by(Event.priority_level)
priority_result = await self.session.execute(priority_query)
priority_counts = {row.priority_level: row.count for row in priority_result}
# Count by domain
domain_query = select(
Event.event_domain,
func.count(Event.id).label('count')
).where(
and_(
Event.tenant_id == tenant_id,
Event.status == "active"
)
).group_by(Event.event_domain)
domain_result = await self.session.execute(domain_query)
domain_counts = {row.event_domain: row.count for row in domain_result}
# Count by type class
type_class_query = select(
Event.type_class,
func.count(Event.id).label('count')
).where(
and_(
Event.tenant_id == tenant_id,
Event.status == "active"
)
).group_by(Event.type_class)
type_class_result = await self.session.execute(type_class_query)
type_class_counts = {row.type_class: row.count for row in type_class_result}
return EventSummary(
total_active=status_counts.get("active", 0),
total_acknowledged=status_counts.get("acknowledged", 0),
total_resolved=status_counts.get("resolved", 0),
by_priority=priority_counts,
by_domain=domain_counts,
by_type_class=type_class_counts,
critical_alerts=priority_counts.get("critical", 0),
important_alerts=priority_counts.get("important", 0)
)
async def acknowledge_event(self, event_id: UUID) -> Event:
"""Mark event as acknowledged"""
event = await self.get_event_by_id(event_id)
if not event:
raise ValueError(f"Event {event_id} not found")
event.status = "acknowledged"
event.acknowledged_at = datetime.now(timezone.utc)
await self.session.commit()
await self.session.refresh(event)
logger.info("event_acknowledged", event_id=event_id)
return event
async def resolve_event(self, event_id: UUID) -> Event:
"""Mark event as resolved"""
event = await self.get_event_by_id(event_id)
if not event:
raise ValueError(f"Event {event_id} not found")
event.status = "resolved"
event.resolved_at = datetime.now(timezone.utc)
await self.session.commit()
await self.session.refresh(event)
logger.info("event_resolved", event_id=event_id)
return event
async def dismiss_event(self, event_id: UUID) -> Event:
"""Mark event as dismissed"""
event = await self.get_event_by_id(event_id)
if not event:
raise ValueError(f"Event {event_id} not found")
event.status = "dismissed"
await self.session.commit()
await self.session.refresh(event)
logger.info("event_dismissed", event_id=event_id)
return event
def _event_to_response(self, event: Event) -> EventResponse:
"""Convert Event model to EventResponse"""
return EventResponse(
id=event.id,
tenant_id=event.tenant_id,
created_at=event.created_at,
event_class=event.event_class,
event_domain=event.event_domain,
event_type=event.event_type,
i18n=I18nContent(
title_key=event.i18n_title_key,
title_params=event.i18n_title_params,
message_key=event.i18n_message_key,
message_params=event.i18n_message_params
),
priority_score=event.priority_score,
priority_level=event.priority_level,
type_class=event.type_class,
smart_actions=[SmartAction(**action) for action in event.smart_actions],
status=event.status,
orchestrator_context=event.orchestrator_context,
business_impact=event.business_impact,
urgency=event.urgency,
user_agency=event.user_agency,
ai_reasoning_summary_key=event.ai_reasoning_summary_key,
ai_reasoning_summary_params=event.ai_reasoning_summary_params,
ai_reasoning_details=event.ai_reasoning_details,
confidence_score=event.confidence_score,
entity_links=event.entity_links,
event_metadata=event.event_metadata
)

View File

@@ -0,0 +1,180 @@
"""
Pydantic schemas for enriched events.
"""
from pydantic import BaseModel, Field
from typing import Dict, Any, List, Optional, Literal
from datetime import datetime
from uuid import UUID
class I18nContent(BaseModel):
"""i18n content structure"""
title_key: str
title_params: Dict[str, Any] = {}
message_key: str
message_params: Dict[str, Any] = {}
class SmartAction(BaseModel):
"""Smart action button"""
action_type: str
label_key: str
label_params: Dict[str, Any] = {}
variant: Literal["primary", "secondary", "danger", "ghost"]
disabled: bool = False
disabled_reason_key: Optional[str] = None
consequence_key: Optional[str] = None
url: Optional[str] = None
metadata: Dict[str, Any] = {}
class BusinessImpact(BaseModel):
"""Business impact context"""
financial_impact_eur: float = 0
affected_orders: int = 0
affected_customers: List[str] = []
production_delay_hours: float = 0
estimated_revenue_loss_eur: float = 0
customer_impact: Literal["low", "medium", "high"] = "low"
waste_risk_kg: float = 0
class Urgency(BaseModel):
"""Urgency context"""
hours_until_consequence: float = 24
can_wait_until_tomorrow: bool = True
deadline_utc: Optional[str] = None
peak_hour_relevant: bool = False
hours_pending: float = 0
class UserAgency(BaseModel):
"""User agency context"""
can_user_fix: bool = True
requires_external_party: bool = False
external_party_name: Optional[str] = None
external_party_contact: Optional[str] = None
blockers: List[str] = []
suggested_workaround: Optional[str] = None
class OrchestratorContext(BaseModel):
"""AI orchestrator context"""
already_addressed: bool = False
action_id: Optional[str] = None
action_type: Optional[str] = None
action_summary: Optional[str] = None
reasoning: Optional[str] = None
confidence: float = 0.8
class EnrichedEvent(BaseModel):
"""Complete enriched event with all context"""
# Core fields
id: str
tenant_id: str
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Classification
event_class: Literal["alert", "notification", "recommendation"]
event_domain: str
event_type: str
service: str
# i18n content
i18n: I18nContent
# Priority
priority_score: int = Field(ge=0, le=100)
priority_level: Literal["critical", "important", "standard", "info"]
type_class: str
# Enrichment contexts
orchestrator_context: Optional[OrchestratorContext] = None
business_impact: Optional[BusinessImpact] = None
urgency: Optional[Urgency] = None
user_agency: Optional[UserAgency] = None
trend_context: Optional[Dict[str, Any]] = None
# Smart actions
smart_actions: List[SmartAction] = []
# AI reasoning
ai_reasoning_summary_key: Optional[str] = None
ai_reasoning_summary_params: Optional[Dict[str, Any]] = None
ai_reasoning_details: Optional[Dict[str, Any]] = None
confidence_score: Optional[float] = None
# Entity references
entity_links: Dict[str, str] = {}
# Status
status: Literal["active", "acknowledged", "resolved", "dismissed"] = "active"
resolved_at: Optional[datetime] = None
acknowledged_at: Optional[datetime] = None
# Original metadata
event_metadata: Dict[str, Any] = {}
class Config:
from_attributes = True
class EventResponse(BaseModel):
"""Event response for API"""
id: UUID
tenant_id: UUID
created_at: datetime
event_class: str
event_domain: str
event_type: str
i18n: I18nContent
priority_score: int
priority_level: str
type_class: str
smart_actions: List[SmartAction]
status: str
# Optional enrichment contexts (only if present)
orchestrator_context: Optional[Dict[str, Any]] = None
business_impact: Optional[Dict[str, Any]] = None
urgency: Optional[Dict[str, Any]] = None
user_agency: Optional[Dict[str, Any]] = None
# AI reasoning
ai_reasoning_summary_key: Optional[str] = None
ai_reasoning_summary_params: Optional[Dict[str, Any]] = None
ai_reasoning_details: Optional[Dict[str, Any]] = None
confidence_score: Optional[float] = None
entity_links: Dict[str, str] = {}
event_metadata: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class EventSummary(BaseModel):
"""Summary statistics for dashboard"""
total_active: int
total_acknowledged: int
total_resolved: int
by_priority: Dict[str, int]
by_domain: Dict[str, int]
by_type_class: Dict[str, int]
critical_alerts: int
important_alerts: int
class EventFilter(BaseModel):
"""Filter criteria for event queries"""
tenant_id: UUID
event_class: Optional[str] = None
priority_level: Optional[List[str]] = None
status: Optional[List[str]] = None
event_domain: Optional[str] = None
limit: int = Field(default=50, le=100)
offset: int = 0

View File

@@ -1,6 +0,0 @@
# services/alert_processor/app/services/__init__.py
"""
Alert Processor Services Package
"""
__all__ = []

View File

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

View File

@@ -1,163 +0,0 @@
"""
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,221 @@
"""
Enrichment orchestrator service.
Coordinates the complete enrichment pipeline for events.
"""
from typing import Dict, Any
import structlog
from uuid import uuid4
from shared.schemas.events import MinimalEvent
from app.schemas.events import EnrichedEvent, I18nContent, BusinessImpact, Urgency, UserAgency, OrchestratorContext
from app.enrichment.message_generator import MessageGenerator
from app.enrichment.priority_scorer import PriorityScorer
from app.enrichment.orchestrator_client import OrchestratorClient
from app.enrichment.smart_actions import SmartActionGenerator
from app.enrichment.business_impact import BusinessImpactAnalyzer
from app.enrichment.urgency_analyzer import UrgencyAnalyzer
from app.enrichment.user_agency import UserAgencyAnalyzer
logger = structlog.get_logger()
class EnrichmentOrchestrator:
"""Coordinates the enrichment pipeline for events"""
def __init__(self):
self.message_gen = MessageGenerator()
self.priority_scorer = PriorityScorer()
self.orchestrator_client = OrchestratorClient()
self.action_gen = SmartActionGenerator()
self.impact_analyzer = BusinessImpactAnalyzer()
self.urgency_analyzer = UrgencyAnalyzer()
self.agency_analyzer = UserAgencyAnalyzer()
async def enrich_event(self, event: MinimalEvent) -> EnrichedEvent:
"""
Run complete enrichment pipeline.
Steps:
1. Generate i18n message keys and parameters
2. Query orchestrator for AI context
3. Analyze business impact
4. Assess urgency
5. Determine user agency
6. Calculate priority score (0-100)
7. Determine priority level
8. Generate smart actions
9. Determine type class
10. Build enriched event
Args:
event: Minimal event from service
Returns:
Enriched event with all context
"""
logger.info("enrichment_started", event_type=event.event_type, tenant_id=event.tenant_id)
# 1. Generate i18n message keys and parameters
i18n_dict = self.message_gen.generate_message(event.event_type, event.metadata, event.event_class)
i18n = I18nContent(**i18n_dict)
# 2. Query orchestrator for AI context (parallel with other enrichments)
orchestrator_context_dict = await self.orchestrator_client.get_context(
tenant_id=event.tenant_id,
event_type=event.event_type,
metadata=event.metadata
)
# Convert to OrchestratorContext if data exists
orchestrator_context = None
if orchestrator_context_dict:
orchestrator_context = OrchestratorContext(**orchestrator_context_dict)
# 3. Analyze business impact
business_impact_dict = self.impact_analyzer.analyze(
event_type=event.event_type,
metadata=event.metadata
)
business_impact = BusinessImpact(**business_impact_dict)
# 4. Assess urgency
urgency_dict = self.urgency_analyzer.analyze(
event_type=event.event_type,
metadata=event.metadata
)
urgency = Urgency(**urgency_dict)
# 5. Determine user agency
user_agency_dict = self.agency_analyzer.analyze(
event_type=event.event_type,
metadata=event.metadata,
orchestrator_context=orchestrator_context_dict
)
user_agency = UserAgency(**user_agency_dict)
# 6. Calculate priority score (0-100)
priority_score = self.priority_scorer.calculate_priority(
business_impact=business_impact_dict,
urgency=urgency_dict,
user_agency=user_agency_dict,
orchestrator_context=orchestrator_context_dict
)
# 7. Determine priority level
priority_level = self._get_priority_level(priority_score)
# 8. Generate smart actions
smart_actions = self.action_gen.generate_actions(
event_type=event.event_type,
metadata=event.metadata,
orchestrator_context=orchestrator_context_dict
)
# 9. Determine type class
type_class = self._determine_type_class(orchestrator_context_dict)
# 10. Extract AI reasoning from metadata (if present)
reasoning_data = event.metadata.get('reasoning_data')
ai_reasoning_details = None
confidence_score = None
if reasoning_data:
# Store the complete reasoning data structure
ai_reasoning_details = reasoning_data
# Extract confidence if available
if isinstance(reasoning_data, dict):
metadata_section = reasoning_data.get('metadata', {})
if isinstance(metadata_section, dict) and 'confidence' in metadata_section:
confidence_score = metadata_section.get('confidence')
# 11. Build enriched event
enriched = EnrichedEvent(
id=str(uuid4()),
tenant_id=event.tenant_id,
event_class=event.event_class,
event_domain=event.event_domain,
event_type=event.event_type,
service=event.service,
i18n=i18n,
priority_score=priority_score,
priority_level=priority_level,
type_class=type_class,
orchestrator_context=orchestrator_context,
business_impact=business_impact,
urgency=urgency,
user_agency=user_agency,
smart_actions=smart_actions,
ai_reasoning_details=ai_reasoning_details,
confidence_score=confidence_score,
entity_links=self._extract_entity_links(event.metadata),
status="active",
event_metadata=event.metadata
)
logger.info(
"enrichment_completed",
event_type=event.event_type,
priority_score=priority_score,
priority_level=priority_level,
type_class=type_class
)
return enriched
def _get_priority_level(self, score: int) -> str:
"""
Convert numeric score to priority level.
- 90-100: critical
- 70-89: important
- 50-69: standard
- 0-49: info
"""
if score >= 90:
return "critical"
elif score >= 70:
return "important"
elif score >= 50:
return "standard"
else:
return "info"
def _determine_type_class(self, orchestrator_context: dict) -> str:
"""
Determine type class based on orchestrator context.
- prevented_issue: AI already handled it
- action_needed: User action required
"""
if orchestrator_context and orchestrator_context.get("already_addressed"):
return "prevented_issue"
return "action_needed"
def _extract_entity_links(self, metadata: dict) -> Dict[str, str]:
"""
Extract entity references from metadata.
Maps metadata keys to entity types for frontend deep linking.
"""
links = {}
# Map metadata keys to entity types
entity_mappings = {
"po_id": "purchase_order",
"batch_id": "production_batch",
"ingredient_id": "ingredient",
"order_id": "order",
"supplier_id": "supplier",
"equipment_id": "equipment",
"sensor_id": "sensor"
}
for key, entity_type in entity_mappings.items():
if key in metadata:
links[entity_type] = str(metadata[key])
return links

View File

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

View File

@@ -0,0 +1,129 @@
"""
Server-Sent Events (SSE) service using Redis pub/sub.
"""
from typing import AsyncGenerator
import json
import structlog
from redis.asyncio import Redis
from app.core.config import settings
from app.models.events import Event
from shared.redis_utils import get_redis_client
logger = structlog.get_logger()
class SSEService:
"""
Manage real-time event streaming via Redis pub/sub.
Pattern: alerts:{tenant_id}
"""
def __init__(self, redis: Redis = None):
self._redis = redis # Use private attribute to allow lazy loading
self.prefix = settings.REDIS_SSE_PREFIX
@property
async def redis(self) -> Redis:
"""
Lazy load Redis client if not provided through dependency injection.
Uses the shared Redis utilities for consistency.
"""
if self._redis is None:
self._redis = await get_redis_client()
return self._redis
async def publish_event(self, event: Event) -> bool:
"""
Publish event to Redis for SSE streaming.
Args:
event: Event to publish
Returns:
True if published successfully
"""
try:
redis_client = await self.redis
# Build channel name
channel = f"{self.prefix}:{event.tenant_id}"
# Build message payload
payload = {
"id": str(event.id),
"tenant_id": str(event.tenant_id),
"event_class": event.event_class,
"event_domain": event.event_domain,
"event_type": event.event_type,
"priority_score": event.priority_score,
"priority_level": event.priority_level,
"type_class": event.type_class,
"status": event.status,
"created_at": event.created_at.isoformat(),
"i18n": {
"title_key": event.i18n_title_key,
"title_params": event.i18n_title_params,
"message_key": event.i18n_message_key,
"message_params": event.i18n_message_params
},
"smart_actions": event.smart_actions,
"entity_links": event.entity_links
}
# Publish to Redis
await redis_client.publish(channel, json.dumps(payload))
logger.debug(
"sse_event_published",
channel=channel,
event_type=event.event_type,
event_id=str(event.id)
)
return True
except Exception as e:
logger.error(
"sse_publish_failed",
error=str(e),
event_id=str(event.id)
)
return False
async def subscribe_to_tenant(
self,
tenant_id: str
) -> AsyncGenerator[str, None]:
"""
Subscribe to tenant's alert stream.
Args:
tenant_id: Tenant UUID
Yields:
JSON-encoded event messages
"""
redis_client = await self.redis
channel = f"{self.prefix}:{tenant_id}"
logger.info("sse_subscription_started", channel=channel)
# Subscribe to Redis channel
pubsub = redis_client.pubsub()
await pubsub.subscribe(channel)
try:
async for message in pubsub.listen():
if message["type"] == "message":
yield message["data"]
except Exception as e:
logger.error("sse_subscription_error", error=str(e), channel=channel)
raise
finally:
await pubsub.unsubscribe(channel)
await pubsub.close()
logger.info("sse_subscription_closed", channel=channel)

View File

@@ -1,196 +0,0 @@
# services/alert_processor/app/services/tenant_deletion_service.py
"""
Tenant Data Deletion Service for Alert Processor Service
Handles deletion of all alert-related data for a tenant
"""
from typing import Dict
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.postgresql import UUID
import structlog
from shared.services.tenant_deletion import (
BaseTenantDataDeletionService,
TenantDataDeletionResult
)
from app.models import Alert, AuditLog
logger = structlog.get_logger(__name__)
class AlertProcessorTenantDeletionService(BaseTenantDataDeletionService):
"""Service for deleting all alert-related data for a tenant"""
def __init__(self, db: AsyncSession):
self.db = db
self.service_name = "alert_processor"
async def get_tenant_data_preview(self, tenant_id: str) -> Dict[str, int]:
"""
Get counts of what would be deleted for a tenant (dry-run)
Args:
tenant_id: The tenant ID to preview deletion for
Returns:
Dictionary with entity names and their counts
"""
logger.info("alert_processor.tenant_deletion.preview", tenant_id=tenant_id)
preview = {}
try:
# Count alerts (CASCADE will delete alert_interactions)
alert_count = await self.db.scalar(
select(func.count(Alert.id)).where(
Alert.tenant_id == tenant_id
)
)
preview["alerts"] = alert_count or 0
# Note: EventInteraction has CASCADE delete, so counting manually
# Count alert interactions for informational purposes
from app.models.events import EventInteraction
interaction_count = await self.db.scalar(
select(func.count(EventInteraction.id)).where(
EventInteraction.tenant_id == tenant_id
)
)
preview["alert_interactions"] = interaction_count or 0
# Count audit logs
audit_count = await self.db.scalar(
select(func.count(AuditLog.id)).where(
AuditLog.tenant_id == tenant_id
)
)
preview["audit_logs"] = audit_count or 0
logger.info(
"alert_processor.tenant_deletion.preview_complete",
tenant_id=tenant_id,
preview=preview
)
except Exception as e:
logger.error(
"alert_processor.tenant_deletion.preview_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
raise
return preview
async def delete_tenant_data(self, tenant_id: str) -> TenantDataDeletionResult:
"""
Permanently delete all alert data for a tenant
Deletion order (respecting foreign key constraints):
1. EventInteraction (child of Alert with CASCADE, but deleted explicitly for tracking)
2. Alert (parent table)
3. AuditLog (independent)
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.
Args:
tenant_id: The tenant ID to delete data for
Returns:
TenantDataDeletionResult with deletion counts and any errors
"""
logger.info("alert_processor.tenant_deletion.started", tenant_id=tenant_id)
result = TenantDataDeletionResult(tenant_id=tenant_id, service_name=self.service_name)
try:
# 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(EventInteraction).where(
EventInteraction.tenant_id == tenant_id
)
)
result.deleted_counts["alert_interactions"] = interactions_result.rowcount
logger.info(
"alert_processor.tenant_deletion.interactions_deleted",
tenant_id=tenant_id,
count=interactions_result.rowcount
)
# Step 2: Delete alerts
logger.info("alert_processor.tenant_deletion.deleting_alerts", tenant_id=tenant_id)
alerts_result = await self.db.execute(
delete(Alert).where(
Alert.tenant_id == tenant_id
)
)
result.deleted_counts["alerts"] = alerts_result.rowcount
logger.info(
"alert_processor.tenant_deletion.alerts_deleted",
tenant_id=tenant_id,
count=alerts_result.rowcount
)
# Step 3: Delete audit logs
logger.info("alert_processor.tenant_deletion.deleting_audit_logs", tenant_id=tenant_id)
audit_result = await self.db.execute(
delete(AuditLog).where(
AuditLog.tenant_id == tenant_id
)
)
result.deleted_counts["audit_logs"] = audit_result.rowcount
logger.info(
"alert_processor.tenant_deletion.audit_logs_deleted",
tenant_id=tenant_id,
count=audit_result.rowcount
)
# Commit the transaction
await self.db.commit()
# Calculate total deleted
total_deleted = sum(result.deleted_counts.values())
logger.info(
"alert_processor.tenant_deletion.completed",
tenant_id=tenant_id,
total_deleted=total_deleted,
breakdown=result.deleted_counts
)
result.success = True
except Exception as e:
await self.db.rollback()
error_msg = f"Failed to delete alert data for tenant {tenant_id}: {str(e)}"
logger.error(
"alert_processor.tenant_deletion.failed",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
result.errors.append(error_msg)
result.success = False
return result
def get_alert_processor_tenant_deletion_service(
db: AsyncSession
) -> AlertProcessorTenantDeletionService:
"""
Factory function to create AlertProcessorTenantDeletionService instance
Args:
db: AsyncSession database session
Returns:
AlertProcessorTenantDeletionService instance
"""
return AlertProcessorTenantDeletionService(db)

View File

@@ -0,0 +1,454 @@
"""
Alert type definitions with i18n key mappings.
Each alert type maps to:
- title_key: i18n key for title (e.g., "alerts.critical_stock_shortage.title")
- title_params: parameter mappings from metadata to i18n params
- message_variants: different message keys based on context
- message_params: parameter mappings for message
When adding new alert types:
1. Add entry to ALERT_TEMPLATES
2. Ensure corresponding translations exist in frontend/src/locales/*/alerts.json
3. Document required metadata fields
"""
# Alert type templates
ALERT_TEMPLATES = {
# ==================== INVENTORY ALERTS ====================
"critical_stock_shortage": {
"title_key": "alerts.critical_stock_shortage.title",
"title_params": {
"ingredient_name": "ingredient_name"
},
"message_variants": {
"with_po_pending": "alerts.critical_stock_shortage.message_with_po_pending",
"with_po_created": "alerts.critical_stock_shortage.message_with_po_created",
"with_hours": "alerts.critical_stock_shortage.message_with_hours",
"with_date": "alerts.critical_stock_shortage.message_with_date",
"generic": "alerts.critical_stock_shortage.message_generic"
},
"message_params": {
"ingredient_name": "ingredient_name",
"current_stock_kg": "current_stock",
"required_stock_kg": "required_stock",
"hours_until": "hours_until",
"production_day_name": "production_date",
"po_id": "po_id",
"po_amount": "po_amount",
"delivery_day_name": "delivery_date"
}
},
"low_stock_warning": {
"title_key": "alerts.low_stock.title",
"title_params": {
"ingredient_name": "ingredient_name"
},
"message_variants": {
"with_po": "alerts.low_stock.message_with_po",
"generic": "alerts.low_stock.message_generic"
},
"message_params": {
"ingredient_name": "ingredient_name",
"current_stock_kg": "current_stock",
"minimum_stock_kg": "minimum_stock"
}
},
"overstock_warning": {
"title_key": "alerts.overstock_warning.title",
"title_params": {
"ingredient_name": "ingredient_name"
},
"message_variants": {
"generic": "alerts.overstock_warning.message"
},
"message_params": {
"ingredient_name": "ingredient_name",
"current_stock_kg": "current_stock",
"maximum_stock_kg": "maximum_stock",
"excess_amount_kg": "excess_amount"
}
},
"expired_products": {
"title_key": "alerts.expired_products.title",
"title_params": {
"count": "expired_count"
},
"message_variants": {
"with_names": "alerts.expired_products.message_with_names",
"generic": "alerts.expired_products.message_generic"
},
"message_params": {
"expired_count": "expired_count",
"product_names": "product_names",
"total_value_eur": "total_value"
}
},
"urgent_expiry": {
"title_key": "alerts.urgent_expiry.title",
"title_params": {
"ingredient_name": "ingredient_name"
},
"message_variants": {
"generic": "alerts.urgent_expiry.message"
},
"message_params": {
"ingredient_name": "ingredient_name",
"days_until_expiry": "days_until_expiry",
"quantity_kg": "quantity"
}
},
"temperature_breach": {
"title_key": "alerts.temperature_breach.title",
"title_params": {
"location": "location"
},
"message_variants": {
"generic": "alerts.temperature_breach.message"
},
"message_params": {
"location": "location",
"temperature": "temperature",
"max_threshold": "max_threshold",
"duration_minutes": "duration_minutes"
}
},
"stock_depleted_by_order": {
"title_key": "alerts.stock_depleted_by_order.title",
"title_params": {
"ingredient_name": "ingredient_name"
},
"message_variants": {
"with_supplier": "alerts.stock_depleted_by_order.message_with_supplier",
"generic": "alerts.stock_depleted_by_order.message_generic"
},
"message_params": {
"ingredient_name": "ingredient_name",
"shortage_kg": "shortage_amount",
"supplier_name": "supplier_name",
"supplier_contact": "supplier_contact"
}
},
# ==================== PRODUCTION ALERTS ====================
"production_delay": {
"title_key": "alerts.production_delay.title",
"title_params": {
"product_name": "product_name",
"batch_number": "batch_number"
},
"message_variants": {
"with_customers": "alerts.production_delay.message_with_customers",
"with_orders": "alerts.production_delay.message_with_orders",
"generic": "alerts.production_delay.message_generic"
},
"message_params": {
"product_name": "product_name",
"batch_number": "batch_number",
"delay_minutes": "delay_minutes",
"affected_orders": "affected_orders",
"customer_names": "customer_names"
}
},
"equipment_failure": {
"title_key": "alerts.equipment_failure.title",
"title_params": {
"equipment_name": "equipment_name"
},
"message_variants": {
"with_batches": "alerts.equipment_failure.message_with_batches",
"generic": "alerts.equipment_failure.message_generic"
},
"message_params": {
"equipment_name": "equipment_name",
"equipment_type": "equipment_type",
"affected_batches": "affected_batches"
}
},
"maintenance_required": {
"title_key": "alerts.maintenance_required.title",
"title_params": {
"equipment_name": "equipment_name"
},
"message_variants": {
"with_hours": "alerts.maintenance_required.message_with_hours",
"with_days": "alerts.maintenance_required.message_with_days",
"generic": "alerts.maintenance_required.message_generic"
},
"message_params": {
"equipment_name": "equipment_name",
"hours_overdue": "hours_overdue",
"days_overdue": "days_overdue"
}
},
"low_equipment_efficiency": {
"title_key": "alerts.low_equipment_efficiency.title",
"title_params": {
"equipment_name": "equipment_name"
},
"message_variants": {
"generic": "alerts.low_equipment_efficiency.message"
},
"message_params": {
"equipment_name": "equipment_name",
"efficiency_percentage": "efficiency_percentage",
"target_efficiency": "target_efficiency"
}
},
"capacity_overload": {
"title_key": "alerts.capacity_overload.title",
"title_params": {
"date": "planned_date"
},
"message_variants": {
"generic": "alerts.capacity_overload.message"
},
"message_params": {
"planned_date": "planned_date",
"capacity_percentage": "capacity_percentage",
"equipment_count": "equipment_count"
}
},
"quality_control_failure": {
"title_key": "alerts.quality_control_failure.title",
"title_params": {
"product_name": "product_name",
"batch_number": "batch_number"
},
"message_variants": {
"generic": "alerts.quality_control_failure.message"
},
"message_params": {
"product_name": "product_name",
"batch_number": "batch_number",
"check_type": "check_type",
"quality_score": "quality_score",
"defect_count": "defect_count"
}
},
# ==================== PROCUREMENT ALERTS ====================
"po_approval_needed": {
"title_key": "alerts.po_approval_needed.title",
"title_params": {
"po_number": "po_number"
},
"message_variants": {
"generic": "alerts.po_approval_needed.message"
},
"message_params": {
"supplier_name": "supplier_name",
"total_amount": "total_amount",
"currency": "currency",
"required_delivery_date": "required_delivery_date",
"items_count": "items_count"
}
},
"po_approval_escalation": {
"title_key": "alerts.po_approval_escalation.title",
"title_params": {
"po_number": "po_number"
},
"message_variants": {
"generic": "alerts.po_approval_escalation.message"
},
"message_params": {
"po_number": "po_number",
"supplier_name": "supplier_name",
"hours_pending": "hours_pending",
"total_amount": "total_amount"
}
},
"delivery_overdue": {
"title_key": "alerts.delivery_overdue.title",
"title_params": {
"po_number": "po_number"
},
"message_variants": {
"generic": "alerts.delivery_overdue.message"
},
"message_params": {
"po_number": "po_number",
"supplier_name": "supplier_name",
"days_overdue": "days_overdue",
"expected_date": "expected_date"
}
},
# ==================== SUPPLY CHAIN ALERTS ====================
"supplier_delay": {
"title_key": "alerts.supplier_delay.title",
"title_params": {
"supplier_name": "supplier_name"
},
"message_variants": {
"generic": "alerts.supplier_delay.message"
},
"message_params": {
"supplier_name": "supplier_name",
"po_count": "po_count",
"avg_delay_days": "avg_delay_days"
}
},
# ==================== DEMAND ALERTS ====================
"demand_surge_weekend": {
"title_key": "alerts.demand_surge_weekend.title",
"title_params": {},
"message_variants": {
"generic": "alerts.demand_surge_weekend.message"
},
"message_params": {
"product_name": "product_name",
"predicted_demand": "predicted_demand",
"current_stock": "current_stock"
}
},
"weather_impact_alert": {
"title_key": "alerts.weather_impact_alert.title",
"title_params": {},
"message_variants": {
"generic": "alerts.weather_impact_alert.message"
},
"message_params": {
"weather_condition": "weather_condition",
"impact_percentage": "impact_percentage",
"date": "date"
}
},
# ==================== PRODUCTION BATCH ALERTS ====================
"production_batch_start": {
"title_key": "alerts.production_batch_start.title",
"title_params": {
"product_name": "product_name"
},
"message_variants": {
"generic": "alerts.production_batch_start.message"
},
"message_params": {
"product_name": "product_name",
"batch_number": "batch_number",
"quantity_planned": "quantity_planned",
"unit": "unit",
"priority": "priority"
}
},
# ==================== GENERIC FALLBACK ====================
"generic": {
"title_key": "alerts.generic.title",
"title_params": {},
"message_variants": {
"generic": "alerts.generic.message"
},
"message_params": {
"event_type": "event_type"
}
}
}
# Notification templates (informational events)
NOTIFICATION_TEMPLATES = {
"po_approved": {
"title_key": "notifications.po_approved.title",
"title_params": {
"po_number": "po_number"
},
"message_variants": {
"generic": "notifications.po_approved.message"
},
"message_params": {
"supplier_name": "supplier_name",
"total_amount": "total_amount"
}
},
"batch_state_changed": {
"title_key": "notifications.batch_state_changed.title",
"title_params": {
"product_name": "product_name"
},
"message_variants": {
"generic": "notifications.batch_state_changed.message"
},
"message_params": {
"batch_number": "batch_number",
"new_status": "new_status",
"quantity": "quantity",
"unit": "unit"
}
},
"stock_received": {
"title_key": "notifications.stock_received.title",
"title_params": {
"ingredient_name": "ingredient_name"
},
"message_variants": {
"generic": "notifications.stock_received.message"
},
"message_params": {
"quantity_received": "quantity_received",
"unit": "unit",
"supplier_name": "supplier_name"
}
}
}
# Recommendation templates (optimization suggestions)
RECOMMENDATION_TEMPLATES = {
"inventory_optimization": {
"title_key": "recommendations.inventory_optimization.title",
"title_params": {
"ingredient_name": "ingredient_name"
},
"message_variants": {
"generic": "recommendations.inventory_optimization.message"
},
"message_params": {
"ingredient_name": "ingredient_name",
"current_max_kg": "current_max",
"suggested_max_kg": "suggested_max",
"recommendation_type": "recommendation_type"
}
},
"production_efficiency": {
"title_key": "recommendations.production_efficiency.title",
"title_params": {
"product_name": "product_name"
},
"message_variants": {
"generic": "recommendations.production_efficiency.message"
},
"message_params": {
"product_name": "product_name",
"potential_time_saved_minutes": "time_saved",
"suggestion": "suggestion"
}
}
}

View File

@@ -1,4 +1,4 @@
"""Alembic environment configuration for alert-processor service"""
"""Alembic environment configuration for alert_processor service"""
import asyncio
import os
@@ -20,7 +20,6 @@ if shared_path not in sys.path:
sys.path.insert(0, shared_path)
try:
from app.config import AlertProcessorConfig
from shared.database.base import Base
# Import all models to ensure they are registered with Base.metadata
@@ -35,12 +34,12 @@ except ImportError as e:
config = context.config
# Determine service name from file path
service_name = "alert-processor"
service_name_upper = "ALERT_PROCESSOR"
service_name = os.path.basename(os.path.dirname(os.path.dirname(__file__)))
service_name_upper = service_name.upper().replace('-', '_')
# Set database URL from environment variables with multiple fallback strategies
database_url = (
os.getenv('ALERT_PROCESSOR_DATABASE_URL') or # Service-specific
os.getenv(f'{service_name_upper}_DATABASE_URL') or # Service-specific
os.getenv('DATABASE_URL') # Generic fallback
)
@@ -56,18 +55,18 @@ if not database_url:
if all([postgres_host, postgres_db, postgres_user, postgres_password]):
database_url = f"postgresql+asyncpg://{postgres_user}:{postgres_password}@{postgres_host}:{postgres_port}/{postgres_db}"
else:
# Try service-specific environment variables (alert-processor specific pattern)
db_host = os.getenv('ALERT_PROCESSOR_DB_HOST', 'alert-processor-db-service')
db_port = os.getenv('ALERT_PROCESSOR_DB_PORT', '5432')
db_name = os.getenv('ALERT_PROCESSOR_DB_NAME', 'alert_processor_db')
db_user = os.getenv('ALERT_PROCESSOR_DB_USER', 'alert_processor_user')
db_password = os.getenv('ALERT_PROCESSOR_DB_PASSWORD')
# Try service-specific environment variables
db_host = os.getenv(f'{service_name_upper}_DB_HOST', f'{service_name}-db-service')
db_port = os.getenv(f'{service_name_upper}_DB_PORT', '5432')
db_name = os.getenv(f'{service_name_upper}_DB_NAME', f'{service_name.replace("-", "_")}_db')
db_user = os.getenv(f'{service_name_upper}_DB_USER', f'{service_name.replace("-", "_")}_user')
db_password = os.getenv(f'{service_name_upper}_DB_PASSWORD')
if db_password:
database_url = f"postgresql+asyncpg://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
if not database_url:
error_msg = "ERROR: No database URL configured for alert-processor service"
error_msg = f"ERROR: No database URL configured for {service_name} service"
print(error_msg)
raise Exception(error_msg)

View File

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

View File

@@ -0,0 +1,97 @@
"""
Clean unified events table schema.
Revision ID: 20251205_unified
Revises:
Create Date: 2025-12-05
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers
revision = '20251205_unified'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
"""
Create unified events table with JSONB enrichment contexts.
"""
# Create events table
op.create_table(
'events',
# Core fields
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
# Classification
sa.Column('event_class', sa.String(50), nullable=False),
sa.Column('event_domain', sa.String(50), nullable=False),
sa.Column('event_type', sa.String(100), nullable=False),
sa.Column('service', sa.String(50), nullable=False),
# i18n content (NO hardcoded title/message)
sa.Column('i18n_title_key', sa.String(200), nullable=False),
sa.Column('i18n_title_params', postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column('i18n_message_key', sa.String(200), nullable=False),
sa.Column('i18n_message_params', postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
# Priority
sa.Column('priority_score', sa.Integer, nullable=False, server_default='50'),
sa.Column('priority_level', sa.String(20), nullable=False),
sa.Column('type_class', sa.String(50), nullable=False),
# Enrichment contexts (JSONB)
sa.Column('orchestrator_context', postgresql.JSONB, nullable=True),
sa.Column('business_impact', postgresql.JSONB, nullable=True),
sa.Column('urgency', postgresql.JSONB, nullable=True),
sa.Column('user_agency', postgresql.JSONB, nullable=True),
sa.Column('trend_context', postgresql.JSONB, nullable=True),
# Smart actions
sa.Column('smart_actions', postgresql.JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
# AI reasoning
sa.Column('ai_reasoning_summary_key', sa.String(200), nullable=True),
sa.Column('ai_reasoning_summary_params', postgresql.JSONB, nullable=True),
sa.Column('ai_reasoning_details', postgresql.JSONB, nullable=True),
sa.Column('confidence_score', sa.Float, nullable=True),
# Entity references
sa.Column('entity_links', postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
# Status
sa.Column('status', sa.String(20), nullable=False, server_default='active'),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('acknowledged_at', sa.DateTime(timezone=True), nullable=True),
# Metadata
sa.Column('event_metadata', postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb"))
)
# Create indexes for efficient queries (matching SQLAlchemy model)
op.create_index('idx_events_tenant_status', 'events', ['tenant_id', 'status'])
op.create_index('idx_events_tenant_priority', 'events', ['tenant_id', 'priority_score'])
op.create_index('idx_events_tenant_class', 'events', ['tenant_id', 'event_class'])
op.create_index('idx_events_tenant_created', 'events', ['tenant_id', 'created_at'])
op.create_index('idx_events_type_class_status', 'events', ['type_class', 'status'])
def downgrade():
"""
Drop events table and all indexes.
"""
op.drop_index('idx_events_type_class_status', 'events')
op.drop_index('idx_events_tenant_created', 'events')
op.drop_index('idx_events_tenant_class', 'events')
op.drop_index('idx_events_tenant_priority', 'events')
op.drop_index('idx_events_tenant_status', 'events')
op.drop_table('events')

View File

@@ -1,18 +1,34 @@
fastapi==0.119.0
uvicorn[standard]==0.32.1
aio-pika==9.4.3
redis==6.4.0
asyncpg==0.30.0
sqlalchemy==2.0.44
alembic==1.17.0
psycopg2-binary==2.9.10
structlog==25.4.0
prometheus-client==0.23.1
pydantic-settings==2.7.1
pydantic==2.12.3
httpx==0.28.1
python-jose[cryptography]==3.3.0
cryptography==44.0.0
# Alert Processor Service v2.0 Dependencies
# FastAPI and server
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-multipart==0.0.6
email-validator==2.2.0
pytz==2024.2
# Database
sqlalchemy[asyncio]==2.0.23
asyncpg==0.29.0
alembic==1.12.1
psycopg2-binary==2.9.9
# RabbitMQ
aio-pika==9.3.0
# Redis
redis[hiredis]==5.0.1
# HTTP client
httpx==0.25.1
# Validation and settings
pydantic==2.5.0
pydantic-settings==2.1.0
# Structured logging
structlog==23.2.0
# Utilities
python-dateutil==2.8.2
# Authentication
python-jose[cryptography]==3.3.0

View File

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

View File

@@ -1,228 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Retail Alerts Seeding Script for Alert Processor Service
Creates stockout and low-stock alerts for child retail outlets
Usage:
python /app/scripts/demo/seed_demo_alerts_retail.py
Environment Variables Required:
ALERTS_DATABASE_URL - PostgreSQL connection string
"""
import asyncio
import uuid
import sys
import os
import random
from datetime import datetime, timezone, timedelta
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Add shared to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
import structlog
from shared.utils.demo_dates import BASE_REFERENCE_DATE
from app.models import Alert, AlertStatus, PriorityLevel, AlertTypeClass
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
# Fixed Demo Tenant IDs
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9")
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0")
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1")
# Product IDs
PRODUCT_IDS = {
"PRO-BAG-001": "20000000-0000-0000-0000-000000000001",
"PRO-CRO-001": "20000000-0000-0000-0000-000000000002",
"PRO-PUE-001": "20000000-0000-0000-0000-000000000003",
"PRO-NAP-001": "20000000-0000-0000-0000-000000000004",
}
RETAIL_TENANTS = [
(DEMO_TENANT_CHILD_1, "Madrid Centro"),
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia"),
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa")
]
ALERT_SCENARIOS = [
{
"alert_type": "low_stock",
"title": "Stock bajo detectado",
"message_template": "Stock bajo de {product} en {location}. Unidades restantes: {units}",
"priority_score": 75,
"priority_level": PriorityLevel.IMPORTANT,
"type_class": AlertTypeClass.ACTION_NEEDED,
"financial_impact": 150.0
},
{
"alert_type": "stockout_risk",
"title": "Riesgo de quiebre de stock",
"message_template": "Riesgo de quiebre de stock para {product} en {location}. Reposición urgente necesaria",
"priority_score": 85,
"priority_level": PriorityLevel.IMPORTANT,
"type_class": AlertTypeClass.ESCALATION,
"financial_impact": 300.0
},
{
"alert_type": "expiring_soon",
"title": "Productos próximos a vencer",
"message_template": "Productos {product} próximos a vencer en {location}. Validar calidad antes de venta",
"priority_score": 65,
"priority_level": PriorityLevel.STANDARD,
"type_class": AlertTypeClass.TREND_WARNING,
"financial_impact": 80.0
}
]
async def seed_alerts_for_retail_tenant(db: AsyncSession, tenant_id: uuid.UUID, tenant_name: str):
"""Seed alerts for a retail tenant"""
logger.info(f"Seeding alerts for: {tenant_name}", tenant_id=str(tenant_id))
created = 0
# Create 2-3 alerts per retail outlet
for i in range(random.randint(2, 3)):
scenario = random.choice(ALERT_SCENARIOS)
# Pick a random product
sku = random.choice(list(PRODUCT_IDS.keys()))
base_product_id = uuid.UUID(PRODUCT_IDS[sku])
tenant_int = int(tenant_id.hex, 16)
product_id = uuid.UUID(int=tenant_int ^ int(base_product_id.hex, 16))
# Random status - most are active, some acknowledged
status = AlertStatus.ACKNOWLEDGED if random.random() < 0.3 else AlertStatus.ACTIVE
# Generate message from template
message = scenario["message_template"].format(
product=sku,
location=tenant_name,
units=random.randint(5, 15)
)
alert = Alert(
id=uuid.uuid4(),
tenant_id=tenant_id,
item_type="alert",
event_domain="inventory",
alert_type=scenario["alert_type"],
service="inventory",
title=scenario["title"],
message=message,
type_class=scenario["type_class"],
status=status,
priority_score=scenario["priority_score"],
priority_level=scenario["priority_level"],
orchestrator_context={
"product_id": str(product_id),
"product_sku": sku,
"location": tenant_name,
"created_by": "inventory_monitoring_system"
},
business_impact={
"financial_impact": scenario["financial_impact"],
"currency": "EUR",
"units_affected": random.randint(10, 50),
"impact_description": f"Impacto estimado: €{scenario['financial_impact']:.2f}"
},
urgency_context={
"time_until_consequence": f"{random.randint(2, 12)} horas",
"consequence": "Pérdida de ventas o desperdicio de producto",
"detection_time": (BASE_REFERENCE_DATE - timedelta(hours=random.randint(1, 24))).isoformat()
},
user_agency={
"user_can_fix": True,
"requires_supplier": scenario["alert_type"] == "stockout_risk",
"suggested_actions": [
"Revisar stock físico",
"Contactar con Obrador para reposición urgente" if scenario["alert_type"] == "stockout_risk" else "Ajustar pedido próximo"
]
},
trend_context=None,
smart_actions=[
{
"action_type": "restock",
"description": "Contactar con Obrador para reposición" if scenario["alert_type"] == "stockout_risk" else "Incluir en próximo pedido",
"priority": "high" if scenario["alert_type"] == "stockout_risk" else "medium"
}
],
ai_reasoning_summary=f"Sistema detectó {scenario['alert_type']} para {sku} basado en niveles actuales de inventario",
confidence_score=0.85,
timing_decision="send_now",
placement=["dashboard", "notification_panel"] if scenario["type_class"] == AlertTypeClass.ESCALATION else ["dashboard"],
alert_metadata={
"product_sku": sku,
"detection_method": "automated_monitoring",
"threshold_triggered": "min_stock_level"
},
created_at=BASE_REFERENCE_DATE - timedelta(hours=random.randint(1, 24)),
updated_at=BASE_REFERENCE_DATE
)
db.add(alert)
created += 1
await db.commit()
logger.info(f"Created {created} alerts for {tenant_name}")
return {"tenant_id": str(tenant_id), "alerts_created": created}
async def seed_all(db: AsyncSession):
"""Seed all retail alerts"""
logger.info("=" * 80)
logger.info("🚨 Starting Demo Retail Alerts Seeding")
logger.info("=" * 80)
results = []
for tenant_id, tenant_name in RETAIL_TENANTS:
result = await seed_alerts_for_retail_tenant(db, tenant_id, f"{tenant_name} (Retail)")
results.append(result)
total = sum(r["alerts_created"] for r in results)
logger.info(f"✅ Total alerts created: {total}")
return {"total_alerts": total, "results": results}
async def main():
database_url = os.getenv("ALERTS_DATABASE_URL") or os.getenv("ALERT_PROCESSOR_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ DATABASE_URL not set")
return 1
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(database_url, echo=False, pool_pre_ping=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
await seed_all(session)
logger.info("🎉 Retail alerts seed completed!")
return 0
except Exception as e:
logger.error(f"❌ Seed failed: {e}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)