"""Incident aggregation helpers for BGP anomalies.""" from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.collected_data_fields import get_record_field from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.models.collected_data import CollectedData from app.services.cable_graph import haversine_distance def _severity_rank(value: str | None) -> int: mapping = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0} return mapping.get(str(value or "").lower(), 0) def _pick_severity(values: list[str]) -> str: ordered = sorted(values, key=_severity_rank, reverse=True) return ordered[0] if ordered else "medium" def _collector_regions_from_anomaly(anomaly: BGPAnomaly) -> list[dict]: evidence = anomaly.evidence or {} regions = evidence.get("impacted_regions") or [] if regions: return regions collected = [] for item in evidence.get("events") or []: collector = item.get("collector") location = item.get("collector_location") or {} if collector or location: collected.append( { "collector": collector, "country": location.get("country"), "city": location.get("city"), "latitude": location.get("latitude"), "longitude": location.get("longitude"), } ) return collected def _dedupe_collected_records(records: list[CollectedData]) -> list[CollectedData]: latest_by_key: dict[str, CollectedData] = {} for record in records: dedupe_key = str(record.source_id or record.entity_key or record.name or record.id) existing = latest_by_key.get(dedupe_key) if existing is None or (record.id or 0) > (existing.id or 0): latest_by_key[dedupe_key] = record return list(latest_by_key.values()) async def infer_related_infrastructure( db: AsyncSession, affected_regions: list[dict], *, max_matches: int = 6, max_distance_km: float = 450.0, ) -> dict[str, list[dict[str, Any]]]: valid_regions = [ region for region in affected_regions if isinstance(region, dict) and isinstance(region.get("latitude"), (int, float)) and isinstance(region.get("longitude"), (int, float)) ] if not valid_regions: return {"related_cables": [], "related_ixps": []} landing_result = await db.execute( select(CollectedData).where(CollectedData.source == "arcgis_landing_points") ) relation_result = await db.execute( select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation") ) cable_result = await db.execute( select(CollectedData).where(CollectedData.source == "arcgis_cables") ) landing_records = _dedupe_collected_records(list(landing_result.scalars().all())) relation_records = _dedupe_collected_records(list(relation_result.scalars().all())) cable_records = _dedupe_collected_records(list(cable_result.scalars().all())) city_to_cable_ids: dict[int, list[int]] = {} for relation in relation_records: metadata = relation.extra_data or {} city_id = metadata.get("city_id") cable_id = metadata.get("cable_id") if city_id is None or cable_id is None: continue city_key = int(city_id) cable_key = int(cable_id) city_to_cable_ids.setdefault(city_key, []) if cable_key not in city_to_cable_ids[city_key]: city_to_cable_ids[city_key].append(cable_key) cable_id_to_name: dict[int, str] = {} for cable in cable_records: metadata = cable.extra_data or {} cable_id = metadata.get("cable_id") if cable_id is None or not cable.name: continue cable_id_to_name[int(cable_id)] = cable.name matches: list[dict[str, Any]] = [] seen_match_keys: set[tuple[Any, ...]] = set() for region in valid_regions: region_coords = (float(region["longitude"]), float(region["latitude"])) for landing in landing_records: try: latitude = get_record_field(landing, "latitude") longitude = get_record_field(landing, "longitude") landing_lat = float(latitude) if latitude is not None else None landing_lon = float(longitude) if longitude is not None else None except (TypeError, ValueError): landing_lat = None landing_lon = None if landing_lat is None or landing_lon is None: continue distance_km = haversine_distance(region_coords, (landing_lon, landing_lat)) if distance_km > max_distance_km: continue landing_meta = landing.extra_data or {} city_id = landing_meta.get("city_id") cable_names = [] if city_id is not None: for cable_id in city_to_cable_ids.get(int(city_id), []): cable_name = cable_id_to_name.get(int(cable_id)) if cable_name and cable_name not in cable_names: cable_names.append(cable_name) match = { "landing_point": landing.name or "Unknown", "city": get_record_field(landing, "city"), "country": get_record_field(landing, "country"), "distance_km": round(distance_km, 1), "collector": region.get("collector"), "cable_names": cable_names, } match_key = ( match["landing_point"], match["city"], match["country"], ) if match_key in seen_match_keys: continue seen_match_keys.add(match_key) matches.append(match) matches.sort( key=lambda item: ( item.get("distance_km", 999999), str(item.get("landing_point") or ""), ) ) matches = matches[:max_matches] related_ixps = [] seen_ixp_keys: set[tuple[str, str]] = set() for item in matches: city = str(item.get("city") or "").strip() country = str(item.get("country") or "").strip() if not city and not country: continue key = (city, country) if key in seen_ixp_keys: continue seen_ixp_keys.add(key) related_ixps.append( { "name": ", ".join(part for part in [city, country] if part), "type": "regional_exchange_hint", } ) return { "related_cables": matches, "related_ixps": related_ixps, } async def create_bgp_incidents_for_anomalies( db: AsyncSession, *, source: str, snapshot_id: int | None, task_id: int | None, anomalies: list[BGPAnomaly], ) -> int: if not anomalies: return 0 grouped: dict[str, list[BGPAnomaly]] = {} for anomaly in anomalies: incident_key = f"{anomaly.anomaly_type}:{anomaly.prefix or 'unknown'}:{anomaly.new_origin_asn or anomaly.origin_asn or 'na'}" grouped.setdefault(incident_key, []).append(anomaly) existing_result = await db.execute( select(BGPIncident).where(BGPIncident.incident_key.in_(sorted(grouped.keys()))) ) existing_incidents = { incident.incident_key: incident for incident in existing_result.scalars().all() } created = 0 for incident_key, items in grouped.items(): items = sorted(items, key=lambda item: item.created_at or item.started_at or datetime.now(UTC)) primary = items[0] prefixes = sorted({item.prefix for item in items if item.prefix}) asns = sorted( { asn for item in items for asn in [item.origin_asn, item.new_origin_asn] if asn is not None } ) collectors = sorted( { collector for item in items for collector in (item.peer_scope or []) if collector } ) regions: list[dict] = [] seen_regions: set[tuple] = set() for item in items: for region in _collector_regions_from_anomaly(item): region_key = ( region.get("collector"), region.get("country"), region.get("city"), ) if region_key in seen_regions: continue seen_regions.add(region_key) regions.append(region) if not collectors: collectors = sorted( { region.get("collector") for region in regions if region.get("collector") } ) evidence_refs = [item.entity_key for item in items if item.entity_key] severity = _pick_severity([item.severity for item in items]) confidence = max((item.confidence or 0.0) for item in items) title = f"{primary.anomaly_type.replace('_', ' ').title()} incident on {primary.prefix or 'unknown prefix'}" summary = ( f"{len(items)} anomaly signal(s) grouped into one {primary.anomaly_type} incident, " f"affecting {len(prefixes) or 1} prefix scope(s) across {len(collectors)} collector(s)." ) related_infrastructure = await infer_related_infrastructure(db, regions) existing = existing_incidents.get(incident_key) if existing is not None: existing.snapshot_id = snapshot_id existing.task_id = task_id existing.source = source existing.incident_type = primary.anomaly_type existing.title = title existing.summary = summary existing.severity = severity existing.status = "active" existing.confidence = confidence existing.started_at = primary.started_at or existing.started_at or datetime.now(UTC) existing.ended_at = None existing.affected_prefixes = prefixes existing.affected_asns = asns existing.affected_collectors = collectors existing.affected_regions = regions existing.related_cables = related_infrastructure["related_cables"] existing.related_ixps = related_infrastructure["related_ixps"] existing.evidence_refs = evidence_refs continue db.add( BGPIncident( snapshot_id=snapshot_id, task_id=task_id, source=source, incident_key=incident_key, incident_type=primary.anomaly_type, title=title, summary=summary, severity=severity, status="active", confidence=confidence, started_at=primary.started_at or datetime.now(UTC), affected_prefixes=prefixes, affected_asns=asns, affected_collectors=collectors, affected_regions=regions, related_cables=related_infrastructure["related_cables"], related_ixps=related_infrastructure["related_ixps"], evidence_refs=evidence_refs, ) ) created += 1 if created or existing_incidents: await db.commit() return created