Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""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="<p>This is a test notification from <strong>CI Agent</strong>. Your destination is configured correctly.</p>",
|
||||
)
|
||||
return await provider.send(message)
|
||||
Reference in New Issue
Block a user