fix: ship persistent bgp ai briefs and optimize bgp queries

This commit is contained in:
rayd1o
2026-04-10 00:33:01 +08:00
parent ed898aef9c
commit 83839b8b11
21 changed files with 1996 additions and 557 deletions

View File

@@ -1,15 +1,27 @@
from uuid import uuid4
from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.user import User
from app.schemas.ai import (
AIProviderStatusResponse,
BGPBriefRequest,
BGPBriefRecordResponse,
BGPBriefRecordSummary,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.bgp_ai_brief import build_bgp_brief_request
from app.services.bgp_ai_brief_store import (
get_bgp_brief_record,
get_latest_bgp_brief_record,
list_bgp_brief_records,
save_bgp_brief_record,
)
router = APIRouter()
@@ -37,3 +49,53 @@ async def analyze_situational_awareness(
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
return await provider_client.analyze(payload, request_id=request_id)
@router.get("/bgp/briefs", response_model=list[BGPBriefRecordSummary])
async def list_saved_bgp_briefs(
current_user: User = Depends(get_current_user),
):
return list_bgp_brief_records()
@router.get("/bgp/briefs/latest", response_model=BGPBriefRecordResponse | None)
async def get_latest_saved_bgp_brief(
current_user: User = Depends(get_current_user),
):
return get_latest_bgp_brief_record()
@router.get("/bgp/briefs/{brief_id}", response_model=BGPBriefRecordResponse)
async def get_saved_bgp_brief(
brief_id: str,
current_user: User = Depends(get_current_user),
):
record = get_bgp_brief_record(brief_id)
if record is None:
raise HTTPException(status_code=404, detail="BGP brief not found")
return record
@router.post("/bgp/brief", response_model=BGPBriefRecordResponse)
async def analyze_bgp_brief(
payload: BGPBriefRequest,
request: Request,
response: Response,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
db: AsyncSession = Depends(get_db),
):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
brief_request = await build_bgp_brief_request(
db,
incident_limit=payload.incident_limit,
anomaly_limit=payload.anomaly_limit,
collector_limit=payload.collector_limit,
)
brief_request.preferred_model = payload.preferred_model
brief_request.thinking = payload.thinking
analysis = await provider_client.analyze(brief_request, request_id=request_id)
return save_bgp_brief_record(analysis, request_id=request_id)

View File

@@ -22,16 +22,161 @@ def _parse_dt(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _event_filters(
*,
prefix: Optional[str],
origin_asn: Optional[int],
peer_asn: Optional[int],
collector: Optional[str],
event_type: Optional[str],
source: Optional[str],
time_from: Optional[datetime],
time_to: Optional[datetime],
):
filters = [BGPObservation.source.in_(BGP_SOURCES)]
if source:
filters.append(BGPObservation.source == source)
if prefix:
filters.append(BGPObservation.prefix == prefix)
if origin_asn is not None:
filters.append(BGPObservation.origin_asn == origin_asn)
if peer_asn is not None:
filters.append(BGPObservation.peer_asn == peer_asn)
if collector:
filters.append(BGPObservation.collector == collector)
if event_type:
filters.append(BGPObservation.event_type == event_type)
if time_from:
filters.append(BGPObservation.observed_at >= time_from)
if time_to:
filters.append(BGPObservation.observed_at <= time_to)
return filters
def _matches_time(value: Optional[datetime], time_from: Optional[datetime], time_to: Optional[datetime]) -> bool:
if value is None:
return False
if time_from and value < time_from:
return False
if time_to and value > time_to:
return False
return True
def _anomaly_filters(
*,
severity: Optional[str],
anomaly_type: Optional[str],
status: Optional[str],
prefix: Optional[str],
origin_asn: Optional[int],
time_from: Optional[datetime],
time_to: Optional[datetime],
):
filters = []
if severity:
filters.append(BGPAnomaly.severity == severity)
if anomaly_type:
filters.append(BGPAnomaly.anomaly_type == anomaly_type)
if status:
filters.append(BGPAnomaly.status == status)
if prefix:
filters.append(BGPAnomaly.prefix == prefix)
if origin_asn is not None:
filters.append(BGPAnomaly.origin_asn == origin_asn)
if time_from:
filters.append(BGPAnomaly.created_at >= time_from)
if time_to:
filters.append(BGPAnomaly.created_at <= time_to)
return filters
def _incident_filters(
*,
severity: Optional[str],
incident_type: Optional[str],
status: Optional[str],
):
filters = []
if severity:
filters.append(BGPIncident.severity == severity)
if incident_type:
filters.append(BGPIncident.incident_type == incident_type)
if status:
filters.append(BGPIncident.status == status)
return filters
async def _build_event_summary_payload(db: AsyncSession) -> dict:
base_filters = [BGPObservation.source.in_(BGP_SOURCES)]
total_result = await db.execute(
select(func.count(BGPObservation.id)).where(*base_filters)
)
collectors_result = await db.execute(
select(func.count(func.distinct(BGPObservation.collector))).where(
*base_filters, BGPObservation.collector.isnot(None)
)
)
prefixes_result = await db.execute(
select(func.count(func.distinct(BGPObservation.prefix))).where(
*base_filters, BGPObservation.prefix.isnot(None)
)
)
type_result = await db.execute(
select(BGPObservation.event_type, func.count(BGPObservation.id))
.where(*base_filters)
.group_by(BGPObservation.event_type)
)
return {
"total": total_result.scalar() or 0,
"collector_count": collectors_result.scalar() or 0,
"prefix_count": prefixes_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
}
async def _build_anomaly_summary_payload(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
type_result = await db.execute(
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.anomaly_type)
.order_by(func.count(BGPAnomaly.id).desc())
)
severity_result = await db.execute(
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.severity)
.order_by(func.count(BGPAnomaly.id).desc())
)
status_result = await db.execute(
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.status)
.order_by(func.count(BGPAnomaly.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
async def _build_incident_summary_payload(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(BGPIncident.id)))
type_result = await db.execute(
select(BGPIncident.incident_type, func.count(BGPIncident.id))
.group_by(BGPIncident.incident_type)
.order_by(func.count(BGPIncident.id).desc())
)
severity_result = await db.execute(
select(BGPIncident.severity, func.count(BGPIncident.id))
.group_by(BGPIncident.severity)
.order_by(func.count(BGPIncident.id).desc())
)
status_result = await db.execute(
select(BGPIncident.status, func.count(BGPIncident.id))
.group_by(BGPIncident.status)
.order_by(func.count(BGPIncident.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
@router.get("/events")
@@ -49,41 +194,36 @@ async def list_bgp_events(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(BGPObservation)
.where(BGPObservation.source.in_(BGP_SOURCES))
.order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
)
if source:
stmt = stmt.where(BGPObservation.source == source)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
filtered = []
for record in records:
if prefix and record.prefix != prefix:
continue
if origin_asn is not None and record.origin_asn != origin_asn:
continue
if peer_asn is not None and record.peer_asn != peer_asn:
continue
if collector and record.collector != collector:
continue
if event_type and record.event_type != event_type:
continue
if (dt_from or dt_to) and not _matches_time(record.observed_at, dt_from, dt_to):
continue
filtered.append(record)
filters = _event_filters(
prefix=prefix,
origin_asn=origin_asn,
peer_asn=peer_asn,
collector=collector,
event_type=event_type,
source=source,
time_from=dt_from,
time_to=dt_to,
)
offset = (page - 1) * page_size
count_result = await db.execute(
select(func.count(BGPObservation.id)).where(*filters)
)
data_result = await db.execute(
select(BGPObservation)
.where(*filters)
.order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
.offset(offset)
.limit(page_size)
)
records = data_result.scalars().all()
return {
"total": len(filtered),
"total": count_result.scalar() or 0,
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in filtered[offset : offset + page_size]],
"data": [record.to_dict() for record in records],
}
@@ -92,21 +232,7 @@ async def get_bgp_event_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES)))
records = result.scalars().all()
collectors = sorted({record.collector for record in records if record.collector})
prefixes = sorted({record.prefix for record in records if record.prefix})
by_type: dict[str, int] = {}
for record in records:
by_type[record.event_type] = by_type.get(record.event_type, 0) + 1
return {
"total": len(records),
"collector_count": len(collectors),
"prefix_count": len(prefixes),
"by_type": by_type,
}
return await _build_event_summary_payload(db)
@router.get("/collectors")
@@ -138,6 +264,32 @@ async def get_bgp_collector_summary(
}
@router.get("/overview/summary")
async def get_bgp_overview_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
event_summary = await _build_event_summary_payload(db)
anomaly_summary = await _build_anomaly_summary_payload(db)
incident_summary = await _build_incident_summary_payload(db)
collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
active_collectors = [item for item in collectors if item["observation_count"] > 0]
return {
"incidentSummary": incident_summary,
"anomalySummary": anomaly_summary,
"eventSummary": event_summary,
"collectorSummary": {
"total": len(collectors),
"active_collectors": len(active_collectors),
"observed_prefixes": sum(item["prefix_count"] for item in active_collectors),
"observed_origins": sum(item["origin_asn_count"] for item in active_collectors),
"recent_24h_events": sum(item["recent_24h_observation_count"] for item in active_collectors),
"recent_7d_events": sum(item["recent_7d_observation_count"] for item in active_collectors),
},
}
@router.get("/events/{event_id}")
async def get_bgp_event(
event_id: int,
@@ -164,31 +316,35 @@ async def list_bgp_anomalies(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
if anomaly_type:
stmt = stmt.where(BGPAnomaly.anomaly_type == anomaly_type)
if status:
stmt = stmt.where(BGPAnomaly.status == status)
if prefix:
stmt = stmt.where(BGPAnomaly.prefix == prefix)
if origin_asn is not None:
stmt = stmt.where(BGPAnomaly.origin_asn == origin_asn)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
if dt_from or dt_to:
records = [record for record in records if _matches_time(record.created_at, dt_from, dt_to)]
filters = _anomaly_filters(
severity=severity,
anomaly_type=anomaly_type,
status=status,
prefix=prefix,
origin_asn=origin_asn,
time_from=dt_from,
time_to=dt_to,
)
offset = (page - 1) * page_size
total_result = await db.execute(
select(func.count(BGPAnomaly.id)).where(*filters)
)
data_result = await db.execute(
select(BGPAnomaly)
.where(*filters)
.order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
.offset(offset)
.limit(page_size)
)
records = data_result.scalars().all()
return {
"total": len(records),
"total": total_result.scalar() or 0,
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in records[offset : offset + page_size]],
"data": [record.to_dict() for record in records],
}
@@ -197,29 +353,7 @@ async def get_bgp_anomaly_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
type_result = await db.execute(
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.anomaly_type)
.order_by(func.count(BGPAnomaly.id).desc())
)
severity_result = await db.execute(
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.severity)
.order_by(func.count(BGPAnomaly.id).desc())
)
status_result = await db.execute(
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.status)
.order_by(func.count(BGPAnomaly.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
return await _build_anomaly_summary_payload(db)
@router.get("/anomalies/{anomaly_id}")
@@ -244,22 +378,29 @@ async def list_bgp_incidents(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
if severity:
stmt = stmt.where(BGPIncident.severity == severity)
if incident_type:
stmt = stmt.where(BGPIncident.incident_type == incident_type)
if status:
stmt = stmt.where(BGPIncident.status == status)
result = await db.execute(stmt)
records = result.scalars().all()
filters = _incident_filters(
severity=severity,
incident_type=incident_type,
status=status,
)
offset = (page - 1) * page_size
total_result = await db.execute(
select(func.count(BGPIncident.id)).where(*filters)
)
data_result = await db.execute(
select(BGPIncident)
.where(*filters)
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
.offset(offset)
.limit(page_size)
)
records = data_result.scalars().all()
return {
"total": len(records),
"total": total_result.scalar() or 0,
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in records[offset : offset + page_size]],
"data": [record.to_dict() for record in records],
}
@@ -268,29 +409,7 @@ async def get_bgp_incident_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPIncident.id)))
type_result = await db.execute(
select(BGPIncident.incident_type, func.count(BGPIncident.id))
.group_by(BGPIncident.incident_type)
.order_by(func.count(BGPIncident.id).desc())
)
severity_result = await db.execute(
select(BGPIncident.severity, func.count(BGPIncident.id))
.group_by(BGPIncident.severity)
.order_by(func.count(BGPIncident.id).desc())
)
status_result = await db.execute(
select(BGPIncident.status, func.count(BGPIncident.id))
.group_by(BGPIncident.status)
.order_by(func.count(BGPIncident.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
return await _build_incident_summary_payload(db)
@router.get("/incidents/{incident_id}")

View File

@@ -21,6 +21,14 @@ class SituationalAnalysisRequest(BaseModel):
thinking: dict[str, Any] | None = None
class BGPBriefRequest(BaseModel):
incident_limit: int = Field(default=5, ge=1, le=10)
anomaly_limit: int = Field(default=6, ge=1, le=12)
collector_limit: int = Field(default=5, ge=1, le=10)
preferred_model: str | None = Field(default=None, max_length=200)
thinking: dict[str, Any] | None = None
class SituationalAnalysisResponse(BaseModel):
provider: str
model: str
@@ -31,6 +39,19 @@ class SituationalAnalysisResponse(BaseModel):
raw_response: dict[str, Any] = Field(default_factory=dict)
class BGPBriefRecordSummary(BaseModel):
id: str
title: str
provider: str
model: str
request_id: str | None = None
generated_at: str
class BGPBriefRecordResponse(BGPBriefRecordSummary):
content_markdown: str
class AIProviderStatusResponse(BaseModel):
provider: str
api: str | None = None

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from collections import Counter
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.bgp import BGP_SOURCES
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.bgp_observation import BGPObservation
from app.schemas.ai import SituationalAnalysisRequest
from app.services.bgp_collectors import build_bgp_collector_coverage
def _format_counter(counter: dict[str, int], empty_text: str = "") -> str:
if not counter:
return empty_text
return "".join(f"{key} {value}" for key, value in counter.items())
def _severity_rank(value: str | None) -> int:
order = {
"critical": 0,
"high": 1,
"medium": 2,
"low": 3,
"info": 4,
}
return order.get((value or "").lower(), 99)
async def build_bgp_brief_request(
db: AsyncSession,
*,
incident_limit: int = 5,
anomaly_limit: int = 6,
collector_limit: int = 5,
) -> SituationalAnalysisRequest:
incidents_result = await db.execute(
select(BGPIncident)
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
.limit(max(incident_limit, 1))
)
anomalies_result = await db.execute(
select(BGPAnomaly)
.order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
.limit(max(anomaly_limit, 1))
)
observations_result = await db.execute(
select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES))
)
incident_count_result = await db.execute(select(func.count(BGPIncident.id)))
anomaly_count_result = await db.execute(select(func.count(BGPAnomaly.id)))
incidents = incidents_result.scalars().all()
anomalies = anomalies_result.scalars().all()
observations = observations_result.scalars().all()
collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
total_incidents = incident_count_result.scalar() or 0
total_anomalies = anomaly_count_result.scalar() or 0
total_observations = len(observations)
active_collectors = [item for item in collectors if item["observation_count"] > 0]
incident_status_counts = Counter((item.status or "unknown") for item in incidents)
incident_severity_counts = Counter((item.severity or "unknown") for item in incidents)
incident_type_counts = Counter((item.incident_type or "unknown") for item in incidents)
anomaly_type_counts = Counter((item.anomaly_type or "unknown") for item in anomalies)
event_type_counts = Counter((item.event_type or "unknown") for item in observations)
top_collectors = sorted(
active_collectors,
key=lambda item: (
-int(item["recent_24h_observation_count"]),
-int(item["observation_count"]),
str(item["collector"]),
),
)[: max(collector_limit, 1)]
observations_lines: list[str] = [
f"当前共有 {total_incidents} 起 BGP incidents、{total_anomalies} 条 anomalies、{total_observations} 条原始观测事件。",
f"活跃观测站 {len(active_collectors)} 个;近 24 小时事件数合计 {sum(int(item['recent_24h_observation_count']) for item in active_collectors)}",
f"最近 incidents 严重度分布:{_format_counter(dict(sorted(incident_severity_counts.items(), key=lambda item: _severity_rank(item[0]))))}",
f"最近 incidents 状态分布:{_format_counter(dict(incident_status_counts))}",
f"最近 incidents 类型分布:{_format_counter(dict(incident_type_counts.most_common(5)))}",
f"最近 anomalies 类型分布:{_format_counter(dict(anomaly_type_counts.most_common(6)))}",
f"观测事件类型分布:{_format_counter(dict(event_type_counts.most_common(6)))}",
]
if incidents:
observations_lines.append(
"最近 incident 摘要:" + "".join(
[
f"{item.incident_type} / {item.severity} / {item.status}"
f" / 前缀 {', '.join(item.affected_prefixes[:2]) if item.affected_prefixes else '-'}"
f" / 观测站 {len(item.affected_collectors or [])}"
for item in incidents
]
)
)
if anomalies:
observations_lines.append(
"最近 anomaly 摘要:" + "".join(
[
f"{item.anomaly_type} / {item.severity}"
f" / 前缀 {item.prefix or '-'}"
f" / ASN {item.new_origin_asn or item.origin_asn or '-'}"
for item in anomalies
]
)
)
if top_collectors:
observations_lines.append(
"重点观测站:" + "".join(
[
f"{item['collector']} ({', '.join([part for part in [item.get('city'), item.get('country')] if part]) or '未知位置'})"
f" / 近24h {item['recent_24h_observation_count']}"
f" / 前缀 {item['prefix_count']}"
for item in top_collectors
]
)
)
return SituationalAnalysisRequest(
title="BGP 态势 AI 简报",
objective="基于当前 BGP incidents、anomalies、原始观测事件与观测站覆盖情况生成一份面向操作员的简明态势简报突出当前风险、证据和优先动作。",
observations=observations_lines,
constraints=[
"明确区分事实、推断与建议。",
"优先指出需要立即关注的高严重度 incident 或异常模式。",
"结论应服务值班排障,不要写成泛泛的模型演示文案。",
"如果证据不足,要明确指出缺失数据。",
],
context={
"source": "bgp-overview",
"incident_total": total_incidents,
"anomaly_total": total_anomalies,
"observation_total": total_observations,
"active_collectors": len(active_collectors),
"top_incident_types": dict(incident_type_counts.most_common(5)),
"top_anomaly_types": dict(anomaly_type_counts.most_common(6)),
"top_event_types": dict(event_type_counts.most_common(6)),
},
)

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from app.core.config import ROOT_DIR
from app.schemas.ai import BGPBriefRecordResponse, BGPBriefRecordSummary, SituationalAnalysisResponse
_BRIEF_STORAGE_DIR = ROOT_DIR / "data" / "ai" / "bgp-briefs"
_METADATA_PREFIX = "<!-- planet-bgp-brief-meta "
_METADATA_SUFFIX = " -->"
_BRIEF_TITLE = "BGP AI 简报"
@dataclass(slots=True)
class _StoredBrief:
id: str
title: str
provider: str
model: str
request_id: str | None
generated_at: str
content_markdown: str
path: Path
def _ensure_storage_dir() -> Path:
_BRIEF_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
return _BRIEF_STORAGE_DIR
def _build_metadata_line(metadata: dict[str, str | None]) -> str:
return f"{_METADATA_PREFIX}{json.dumps(metadata, ensure_ascii=False)}{_METADATA_SUFFIX}"
def _parse_brief_file(path: Path) -> _StoredBrief | None:
try:
raw_text = path.read_text(encoding="utf-8")
except OSError:
return None
first_line, separator, remainder = raw_text.partition("\n")
if not separator or not first_line.startswith(_METADATA_PREFIX) or not first_line.endswith(_METADATA_SUFFIX):
return None
metadata_payload = first_line[len(_METADATA_PREFIX) : -len(_METADATA_SUFFIX)]
try:
metadata = json.loads(metadata_payload)
except json.JSONDecodeError:
return None
return _StoredBrief(
id=str(metadata.get("id") or path.stem),
title=str(metadata.get("title") or _BRIEF_TITLE),
provider=str(metadata.get("provider") or "-"),
model=str(metadata.get("model") or "-"),
request_id=metadata.get("request_id"),
generated_at=str(metadata.get("generated_at") or datetime.fromtimestamp(path.stat().st_mtime, UTC).isoformat()),
content_markdown=remainder.lstrip("\n"),
path=path,
)
def list_bgp_brief_records(limit: int = 50) -> list[BGPBriefRecordSummary]:
storage_dir = _ensure_storage_dir()
records: list[_StoredBrief] = []
for path in storage_dir.glob("*.md"):
parsed = _parse_brief_file(path)
if parsed is not None:
records.append(parsed)
records.sort(key=lambda item: item.generated_at, reverse=True)
return [
BGPBriefRecordSummary(
id=item.id,
title=item.title,
provider=item.provider,
model=item.model,
request_id=item.request_id,
generated_at=item.generated_at,
)
for item in records[: max(limit, 1)]
]
def get_bgp_brief_record(brief_id: str) -> BGPBriefRecordResponse | None:
path = _ensure_storage_dir() / f"{brief_id}.md"
parsed = _parse_brief_file(path)
if parsed is None:
return None
return BGPBriefRecordResponse(
id=parsed.id,
title=parsed.title,
provider=parsed.provider,
model=parsed.model,
request_id=parsed.request_id,
generated_at=parsed.generated_at,
content_markdown=parsed.content_markdown,
)
def get_latest_bgp_brief_record() -> BGPBriefRecordResponse | None:
summaries = list_bgp_brief_records(limit=1)
if not summaries:
return None
return get_bgp_brief_record(summaries[0].id)
def save_bgp_brief_record(
analysis: SituationalAnalysisResponse,
*,
request_id: str | None,
generated_at: datetime | None = None,
) -> BGPBriefRecordResponse:
created_at = generated_at or datetime.now(UTC)
brief_id = f"{created_at.strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
path = _ensure_storage_dir() / f"{brief_id}.md"
metadata = {
"id": brief_id,
"title": _BRIEF_TITLE,
"provider": analysis.provider,
"model": analysis.model,
"request_id": request_id,
"generated_at": created_at.isoformat(),
}
markdown_text = f"{_build_metadata_line(metadata)}\n\n{analysis.content.rstrip()}\n"
path.write_text(markdown_text, encoding="utf-8")
return BGPBriefRecordResponse(
id=brief_id,
title=_BRIEF_TITLE,
provider=analysis.provider,
model=analysis.model,
request_id=request_id,
generated_at=created_at.isoformat(),
content_markdown=analysis.content,
)

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy import case, distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
@@ -14,6 +14,16 @@ from app.models.bgp_observation import BGPObservation
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
def _collector_base_filters(source_filter: tuple[str, ...] | None) -> list[Any]:
filters: list[Any] = [
BGPObservation.collector.isnot(None),
func.length(func.btrim(BGPObservation.collector)) > 0,
]
if source_filter:
filters.append(BGPObservation.source.in_(source_filter))
return filters
async def build_bgp_collector_coverage(
db: AsyncSession,
*,
@@ -24,88 +34,148 @@ async def build_bgp_collector_coverage(
recent_24h_threshold = now - timedelta(hours=24)
recent_7d_threshold = now - timedelta(days=7)
stmt = select(BGPObservation).order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
if source_filter:
stmt = stmt.where(BGPObservation.source.in_(source_filter))
filters = _collector_base_filters(source_filter)
country_expr = func.nullif(BGPObservation.collector_geo["country"].astext, "")
city_expr = func.nullif(BGPObservation.collector_geo["city"].astext, "")
result = await db.execute(stmt)
records = list(result.scalars().all())
aggregate_stmt = (
select(
BGPObservation.collector.label("collector"),
func.count(BGPObservation.id).label("observation_count"),
func.count(distinct(BGPObservation.prefix)).label("prefix_count"),
func.count(distinct(BGPObservation.origin_asn)).label("origin_asn_count"),
func.count(distinct(BGPObservation.peer_asn)).label("peer_asn_count"),
func.sum(case((BGPObservation.observed_at >= recent_15m_threshold, 1), else_=0)).label("recent_15m_observation_count"),
func.sum(case((BGPObservation.observed_at >= recent_24h_threshold, 1), else_=0)).label("recent_24h_observation_count"),
func.sum(case((BGPObservation.observed_at >= recent_7d_threshold, 1), else_=0)).label("recent_7d_observation_count"),
func.count(distinct(case((BGPObservation.observed_at >= recent_15m_threshold, BGPObservation.prefix), else_=None))).label("recent_15m_prefix_count"),
func.count(distinct(case((BGPObservation.observed_at >= recent_24h_threshold, BGPObservation.prefix), else_=None))).label("recent_24h_prefix_count"),
func.count(distinct(case((BGPObservation.observed_at >= recent_7d_threshold, BGPObservation.prefix), else_=None))).label("recent_7d_prefix_count"),
func.max(BGPObservation.observed_at).label("latest_observed_at"),
)
.where(*filters)
.group_by(BGPObservation.collector)
)
aggregate_rows = (await db.execute(aggregate_stmt)).all()
latest_subquery = (
select(
BGPObservation.collector.label("collector"),
BGPObservation.event_type.label("latest_event_type"),
country_expr.label("country"),
city_expr.label("city"),
func.row_number()
.over(
partition_by=BGPObservation.collector,
order_by=(BGPObservation.observed_at.desc(), BGPObservation.id.desc()),
)
.label("rn"),
)
.where(*filters)
.subquery()
)
latest_rows = (
await db.execute(
select(
latest_subquery.c.collector,
latest_subquery.c.latest_event_type,
latest_subquery.c.country,
latest_subquery.c.city,
).where(latest_subquery.c.rn == 1)
)
).all()
event_counts_subquery = (
select(
BGPObservation.collector.label("collector"),
BGPObservation.event_type.label("event_type"),
func.count(BGPObservation.id).label("count"),
func.row_number()
.over(
partition_by=BGPObservation.collector,
order_by=(func.count(BGPObservation.id).desc(), BGPObservation.event_type.asc()),
)
.label("rn"),
)
.where(*filters)
.group_by(BGPObservation.collector, BGPObservation.event_type)
.subquery()
)
top_event_rows = (
await db.execute(
select(
event_counts_subquery.c.collector,
event_counts_subquery.c.event_type,
event_counts_subquery.c.count,
).where(event_counts_subquery.c.rn <= 3)
)
).all()
scope_rows = (
await db.execute(
select(
BGPObservation.collector.label("collector"),
country_expr.label("country"),
city_expr.label("city"),
)
.where(*filters)
.distinct()
)
).all()
latest_by_collector = {
row.collector: {
"latest_event_type": row.latest_event_type,
"country": row.country,
"city": row.city,
}
for row in latest_rows
}
scope_by_collector: dict[str, dict[str, set[str]]] = defaultdict(lambda: {"countries": set(), "cities": set()})
for row in scope_rows:
if row.country:
scope_by_collector[row.collector]["countries"].add(row.country)
if row.city:
scope_by_collector[row.collector]["cities"].add(row.city)
top_events_by_collector: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in top_event_rows:
top_events_by_collector[row.collector].append(
{"event_type": row.event_type, "count": row.count}
)
by_collector: dict[str, dict[str, Any]] = {}
for record in records:
collector = str(record.collector or "").strip()
if not collector:
continue
for row in aggregate_rows:
collector = row.collector
latest = latest_by_collector.get(collector, {})
fallback_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
scope = scope_by_collector.get(collector, {"countries": set(), "cities": set()})
coverage = by_collector.get(collector)
if coverage is None:
location = record.collector_geo or RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
coverage = {
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
"observation_count": 0,
"prefixes": set(),
"origin_asns": set(),
"peer_asns": set(),
"event_types": defaultdict(int),
"countries": set(),
"cities": set(),
"recent_15m_observation_count": 0,
"recent_24h_observation_count": 0,
"recent_7d_observation_count": 0,
"recent_15m_prefixes": set(),
"recent_24h_prefixes": set(),
"recent_7d_prefixes": set(),
"latest_observed_at": None,
"latest_event_type": None,
}
by_collector[collector] = coverage
coverage["observation_count"] += 1
if record.prefix:
coverage["prefixes"].add(record.prefix)
if record.origin_asn is not None:
coverage["origin_asns"].add(record.origin_asn)
if record.peer_asn is not None:
coverage["peer_asns"].add(record.peer_asn)
if record.event_type:
coverage["event_types"][record.event_type] += 1
observed_at = record.observed_at
if observed_at is not None:
aware_observed_at = (
observed_at.astimezone(UTC)
if observed_at.tzinfo
else observed_at.replace(tzinfo=UTC)
)
if aware_observed_at >= recent_15m_threshold:
coverage["recent_15m_observation_count"] += 1
if record.prefix:
coverage["recent_15m_prefixes"].add(record.prefix)
if aware_observed_at >= recent_24h_threshold:
coverage["recent_24h_observation_count"] += 1
if record.prefix:
coverage["recent_24h_prefixes"].add(record.prefix)
if aware_observed_at >= recent_7d_threshold:
coverage["recent_7d_observation_count"] += 1
if record.prefix:
coverage["recent_7d_prefixes"].add(record.prefix)
geo = record.collector_geo or {}
if geo.get("country"):
coverage["countries"].add(geo["country"])
if geo.get("city"):
coverage["cities"].add(geo["city"])
current_latest = coverage["latest_observed_at"]
if current_latest is None or (
record.observed_at is not None and record.observed_at > current_latest
):
coverage["latest_observed_at"] = record.observed_at
coverage["latest_event_type"] = record.event_type
by_collector[collector] = {
"collector": collector,
"city": latest.get("city") or fallback_location.get("city"),
"country": latest.get("country") or fallback_location.get("country"),
"latitude": fallback_location.get("latitude"),
"longitude": fallback_location.get("longitude"),
"observation_count": row.observation_count or 0,
"prefix_count": row.prefix_count or 0,
"origin_asn_count": row.origin_asn_count or 0,
"peer_asn_count": row.peer_asn_count or 0,
"recent_15m_observation_count": row.recent_15m_observation_count or 0,
"recent_24h_observation_count": row.recent_24h_observation_count or 0,
"recent_7d_observation_count": row.recent_7d_observation_count or 0,
"recent_15m_prefix_count": row.recent_15m_prefix_count or 0,
"recent_24h_prefix_count": row.recent_24h_prefix_count or 0,
"recent_7d_prefix_count": row.recent_7d_prefix_count or 0,
"top_event_types": top_events_by_collector.get(collector, []),
"latest_observed_at": to_iso8601_utc(row.latest_observed_at),
"latest_event_type": latest.get("latest_event_type"),
"baseline_scope": {
"countries": sorted(scope["countries"]),
"cities": sorted(scope["cities"]),
},
}
for collector, location in RIPE_RIS_COLLECTOR_COORDS.items():
if collector in by_collector:
@@ -117,57 +187,22 @@ async def build_bgp_collector_coverage(
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
"observation_count": 0,
"prefixes": set(),
"origin_asns": set(),
"peer_asns": set(),
"event_types": defaultdict(int),
"countries": {location.get("country")} if location.get("country") else set(),
"cities": {location.get("city")} if location.get("city") else set(),
"prefix_count": 0,
"origin_asn_count": 0,
"peer_asn_count": 0,
"recent_15m_observation_count": 0,
"recent_24h_observation_count": 0,
"recent_7d_observation_count": 0,
"recent_15m_prefixes": set(),
"recent_24h_prefixes": set(),
"recent_7d_prefixes": set(),
"recent_15m_prefix_count": 0,
"recent_24h_prefix_count": 0,
"recent_7d_prefix_count": 0,
"top_event_types": [],
"latest_observed_at": None,
"latest_event_type": None,
"baseline_scope": {
"countries": [location["country"]] if location.get("country") else [],
"cities": [location["city"]] if location.get("city") else [],
},
}
results: list[dict[str, Any]] = []
for collector in sorted(by_collector.keys()):
item = by_collector[collector]
top_event_types = sorted(
item["event_types"].items(),
key=lambda pair: (-pair[1], pair[0]),
)
results.append(
{
"collector": item["collector"],
"city": item["city"],
"country": item["country"],
"latitude": item["latitude"],
"longitude": item["longitude"],
"observation_count": item["observation_count"],
"prefix_count": len(item["prefixes"]),
"origin_asn_count": len(item["origin_asns"]),
"peer_asn_count": len(item["peer_asns"]),
"recent_15m_observation_count": item["recent_15m_observation_count"],
"recent_24h_observation_count": item["recent_24h_observation_count"],
"recent_7d_observation_count": item["recent_7d_observation_count"],
"recent_15m_prefix_count": len(item["recent_15m_prefixes"]),
"recent_24h_prefix_count": len(item["recent_24h_prefixes"]),
"recent_7d_prefix_count": len(item["recent_7d_prefixes"]),
"top_event_types": [
{"event_type": event_type, "count": count}
for event_type, count in top_event_types[:3]
],
"latest_observed_at": to_iso8601_utc(item["latest_observed_at"]),
"latest_event_type": item["latest_event_type"],
"baseline_scope": {
"countries": sorted(country for country in item["countries"] if country),
"cities": sorted(city for city in item["cities"] if city),
},
}
)
return results
return [by_collector[collector] for collector in sorted(by_collector.keys())]