"""Turns a DetectedChange that crosses the company's alert threshold into an Alert, generates its summary via Task F (LLM), and dispatches it to every enabled notification destination that also meets its own severity threshold - recording one NotificationDelivery per attempt. Two independent thresholds by design: MonitorConfiguration.severity_threshold gates whether an Alert is created at all; NotificationDestination.minimum_severity then gates whether *this* destination gets notified about it (spec section 6I/22). """ from __future__ import annotations import uuid from datetime import UTC, datetime from sqlalchemy.ext.asyncio import AsyncSession from app.analysis.llm.base import LLMProvider from app.core.config import Settings from app.core.errors import NotFoundError from app.models.alert import Alert from app.models.company import Company from app.models.detected_change import DetectedChange from app.models.enums import ( SEVERITY_ORDER, NotificationDeliveryStatus, NotificationType, SeverityLevel, ) from app.models.notification_delivery import NotificationDelivery from app.notifications.base import DeliveryResult, NotificationMessage from app.notifications.factory import get_notification_provider from app.notifications.message_builder import build_alert_message from app.prompts.alert_summarization import summarize_alert from app.repositories.alert_repository import AlertRepository from app.repositories.notification_delivery_repository import NotificationDeliveryRepository from app.repositories.notification_destination_repository import NotificationDestinationRepository def _meets_threshold(severity: SeverityLevel, threshold: SeverityLevel) -> bool: """SEVERITY_ORDER[0] is most severe, so meeting a threshold means being at least as severe - i.e. an index <= the threshold's index.""" return SEVERITY_ORDER.index(severity) <= SEVERITY_ORDER.index(threshold) async def create_alert_for_change( db: AsyncSession, settings: Settings, llm: LLMProvider, change: DetectedChange, company: Company ) -> Alert | None: config = company.monitor_configuration if config is None or not _meets_threshold(change.severity, config.severity_threshold): return None evidence_snippets = [ *change.raw_diff.get("text_added_lines", [])[:5], *change.raw_diff.get("structured_added", [])[:5], ] summary = await summarize_alert( llm, company_name=company.name, change_type=change.change_type.value, change_summary=change.summary, severity=change.severity.value, confidence=change.confidence_score, evidence_snippets=evidence_snippets, ) alert = Alert( company_id=company.id, detected_change_id=change.id, user_id=company.user_id, title=summary.title, summary=summary.summary, why_it_matters=summary.why_it_matters, severity=change.severity, confidence=change.confidence_score, ) await AlertRepository(db).create(alert) destinations = await NotificationDestinationRepository(db).list_for_company(company.id) delivery_repo = NotificationDeliveryRepository(db) for destination in destinations: if not destination.enabled: continue if not _meets_threshold(change.severity, destination.minimum_severity): continue if destination.type == NotificationType.SMS and not settings.notification_sms_enabled: continue provider = get_notification_provider(destination.type, settings) message = build_alert_message( destination.type, destination.destination_value, company, alert, settings ) delivery = NotificationDelivery( alert_id=alert.id, destination_id=destination.id, provider=provider.provider_name ) await delivery_repo.create(delivery) result = await provider.send(message) delivery.attempt_count = 1 delivery.last_attempt = datetime.now(UTC) delivery.status = ( NotificationDeliveryStatus.SENT if result.success else NotificationDeliveryStatus.FAILED ) delivery.external_message_id = result.external_message_id delivery.error_message = result.error await db.commit() await db.refresh(alert) return alert async def list_alerts( db: AsyncSession, user_id: uuid.UUID, *, company_id: uuid.UUID | None = None, severity: SeverityLevel | None = None, read: bool | None = None, resolved: bool | None = None, ) -> list[Alert]: return await AlertRepository(db).list_for_user( user_id, company_id=company_id, severity=severity, read=read, resolved=resolved ) async def get_alert(db: AsyncSession, user_id: uuid.UUID, alert_id: uuid.UUID) -> Alert: alert = await AlertRepository(db).get_for_user(alert_id, user_id) if alert is None: raise NotFoundError("Alert not found") return alert async def mark_read(db: AsyncSession, user_id: uuid.UUID, alert_id: uuid.UUID) -> Alert: alert = await get_alert(db, user_id, alert_id) alert.read = True await db.commit() await db.refresh(alert) return alert async def mark_resolved(db: AsyncSession, user_id: uuid.UUID, alert_id: uuid.UUID) -> Alert: alert = await get_alert(db, user_id, alert_id) alert.resolved = True await db.commit() await db.refresh(alert) return alert async def update_alert( db: AsyncSession, user_id: uuid.UUID, alert_id: uuid.UUID, *, read: bool | None, resolved: bool | None, ) -> Alert: alert = await get_alert(db, user_id, alert_id) if read is not None: alert.read = read if resolved is not None: alert.resolved = resolved await db.commit() await db.refresh(alert) return alert async def get_alert_with_deliveries( db: AsyncSession, user_id: uuid.UUID, alert_id: uuid.UUID ) -> tuple[Alert, list[NotificationDelivery]]: alert = await get_alert(db, user_id, alert_id) deliveries = await NotificationDeliveryRepository(db).list_for_alert(alert.id) return alert, deliveries async def send_test_notification( db: AsyncSession, settings: Settings, user_id: uuid.UUID, destination_id: uuid.UUID ): destination = await NotificationDestinationRepository(db).get_for_user(destination_id, user_id) if destination is None: raise NotFoundError("Notification destination not found") if destination.type == NotificationType.SMS and not settings.notification_sms_enabled: return DeliveryResult( success=False, error="SMS delivery is currently disabled (NOTIFICATION_SMS_ENABLED=false) - no " "message was sent.", ) provider = get_notification_provider(destination.type, settings) if destination.type == NotificationType.SMS: message = NotificationMessage( destination_value=destination.destination_value, subject="CI Agent test", body_text="CI Agent test SMS: your notification destination is configured correctly.", ) else: message = NotificationMessage( destination_value=destination.destination_value, subject="CI Agent test notification", body_text="This is a test notification from CI Agent. Your destination is configured correctly.", body_html="

This is a test notification from CI Agent. Your destination is configured correctly.

", ) return await provider.send(message)