diff --git a/.gitignore b/.gitignore
index bb7024c5..dff37800 100644
--- a/.gitignore
+++ b/.gitignore
@@ -145,3 +145,8 @@ docs/.venv/
*.temp
tmp/
temp/
+
+# ----------------------
+# Runtime Data
+# ----------------------
+data/ai/bgp-briefs/
diff --git a/VERSION b/VERSION
index 4b8c9a55..7a8169a2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.24.4
+0.24.5
diff --git a/backend/app/api/v1/ai.py b/backend/app/api/v1/ai.py
index 490d356b..364db973 100644
--- a/backend/app/api/v1/ai.py
+++ b/backend/app/api/v1/ai.py
@@ -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)
diff --git a/backend/app/api/v1/bgp.py b/backend/app/api/v1/bgp.py
index 26067b1e..ff32dd83 100644
--- a/backend/app/api/v1/bgp.py
+++ b/backend/app/api/v1/bgp.py
@@ -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}")
diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py
index 27d1046e..3bcdac9c 100644
--- a/backend/app/schemas/ai.py
+++ b/backend/app/schemas/ai.py
@@ -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
diff --git a/backend/app/services/bgp_ai_brief.py b/backend/app/services/bgp_ai_brief.py
new file mode 100644
index 00000000..998b440e
--- /dev/null
+++ b/backend/app/services/bgp_ai_brief.py
@@ -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)),
+ },
+ )
diff --git a/backend/app/services/bgp_ai_brief_store.py b/backend/app/services/bgp_ai_brief_store.py
new file mode 100644
index 00000000..66eb2302
--- /dev/null
+++ b/backend/app/services/bgp_ai_brief_store.py
@@ -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 = ""
+_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,
+ )
diff --git a/backend/app/services/bgp_collectors.py b/backend/app/services/bgp_collectors.py
index 56a7a209..7cb96f75 100644
--- a/backend/app/services/bgp_collectors.py
+++ b/backend/app/services/bgp_collectors.py
@@ -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())]
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index cb3fc110..bb1d8f5f 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -7,6 +7,35 @@ This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1`
+## 0.24.5
+
+Released: 2026-04-10
+
+### Highlights
+
+- Turned the first BGP AI brief flow into a persistent operator-facing feature with saved Markdown history, tabbed review, and backend-driven fact assembly, while also cutting BGP page load cost by switching the hottest routes away from Python-side full-table scans.
+
+### Added
+
+- Added [backend/app/services/bgp_ai_brief.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_ai_brief.py), building evidence-first BGP brief prompts from incidents, anomalies, observations, and collector coverage instead of relying on manual Playground inputs.
+- Added [backend/app/services/bgp_ai_brief_store.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_ai_brief_store.py), persisting generated BGP AI briefs as Markdown files and exposing a stable record shape for history browsing.
+- Added [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx), giving the frontend a lightweight built-in Markdown renderer for saved AI brief content without introducing a new dependency.
+- Added [backend/app/api/v1/bgp.py](/home/ray/dev/linkong/planet/backend/app/api/v1/bgp.py) `GET /bgp/overview/summary`, consolidating the BGP overview summary hot path into a dedicated aggregate endpoint for the BGP workspace.
+
+### Improved
+
+- Improved [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py) and [backend/app/schemas/ai.py](/home/ray/dev/linkong/planet/backend/app/schemas/ai.py) by extending the AI API from one-off analysis calls to saved BGP brief history endpoints, latest-brief lookup, and persisted brief responses.
+- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) by moving `AI 简报` into the trailing tab, loading tab data lazily, loading brief history only on demand, and wiring saved brief selection plus regeneration into the operator workflow.
+- Improved [frontend/src/services/situational-awareness/http-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/http-gateway.ts), [frontend/src/services/situational-awareness/port.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/port.ts), [frontend/src/services/situational-awareness/mock-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/mock-gateway.ts), and [frontend/src/services/situational-awareness/types.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/types.ts) by splitting BGP summary, list, and brief-history fetches into more granular gateway methods and de-duplicating in-flight requests on the hot path.
+- Improved [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by styling the saved BGP brief workspace, Markdown output, history selector, and responsive compact state without re-breaking tab switching.
+- Improved [backend/app/services/bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) by replacing full-table ORM loading with database-side collector aggregation, latest-record lookup, and top-event summarization for the collector coverage hot path.
+
+### Fixed
+
+- Fixed the BGP tab rendering regression in [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) where forcing tab panes to `display: flex` accidentally kept hidden panes visible and made the first tab look permanently locked.
+- Fixed [backend/app/api/v1/bgp.py](/home/ray/dev/linkong/planet/backend/app/api/v1/bgp.py) so `events`, `anomalies`, and `incidents` no longer fetch whole tables into Python just to apply filtering, pagination, and counting.
+- Fixed the repository workflow around saved BGP brief artifacts by ignoring [data/ai/bgp-briefs/](/home/ray/dev/linkong/planet/data/ai/bgp-briefs/) in [.gitignore](/home/ray/dev/linkong/planet/.gitignore) instead of leaving runtime Markdown output to pollute git status during normal operator use.
+
## 0.24.4
Released: 2026-04-09
diff --git a/docs/version-history.md b/docs/version-history.md
index 6cd063ac..b02792d0 100644
--- a/docs/version-history.md
+++ b/docs/version-history.md
@@ -16,7 +16,7 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
-- `dev` 当前开发分支历史推导到:`0.24.4`
+- `dev` 当前开发分支历史推导到:`0.24.5`
## Timeline
@@ -77,6 +77,7 @@
| `0.24.2` | bugfix | `dev` | `pending` | restore public Earth entry, refresh Playground provider diagnostics correctly, fit help-card content, and split frontend bundles by route/vendor |
| `0.24.3` | bugfix | `dev` | `pending` | expand Playground diagnostics presets and result inspection, and make `planet.sh` rebuild changed AI Provider images with explicit Compose fallback reporting |
| `0.24.4` | bugfix | `dev` | `pending` | polish `planet.sh` AI Provider rebuild stage boundaries, hide raw Compose build logs on success, and add explicit image-build completion feedback |
+| `0.24.5` | bugfix | `dev` | `pending` | add persistent BGP AI briefs with Markdown history, lazy-load BGP tabs, and move BGP hot-path filtering and aggregation back into the database |
## Maintenance Commits Not Counted as Version Bumps
diff --git a/frontend/package.json b/frontend/package.json
index ff6012de..6f3ccf85 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
- "version": "0.24.4",
+ "version": "0.24.5",
"private": true,
"packageManager": "bun@1",
"dependencies": {
diff --git a/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx b/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx
new file mode 100644
index 00000000..3ed87218
--- /dev/null
+++ b/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx
@@ -0,0 +1,167 @@
+import type { ReactNode } from 'react'
+
+interface MarkdownRendererProps {
+ markdown: string
+ className?: string
+}
+
+function renderInlineMarkdown(text: string): ReactNode[] {
+ const result: ReactNode[] = []
+ const pattern = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g
+ let lastIndex = 0
+ let key = 0
+
+ for (const match of text.matchAll(pattern)) {
+ const matchedText = match[0]
+ const start = match.index ?? 0
+
+ if (start > lastIndex) {
+ result.push(text.slice(lastIndex, start))
+ }
+
+ if (matchedText.startsWith('[')) {
+ const linkMatch = matchedText.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
+ if (linkMatch) {
+ result.push(
+
+ {linkMatch[1]}
+ ,
+ )
+ key += 1
+ lastIndex = start + matchedText.length
+ continue
+ }
+ }
+
+ if (matchedText.startsWith('`')) {
+ result.push({matchedText.slice(1, -1)})
+ key += 1
+ lastIndex = start + matchedText.length
+ continue
+ }
+
+ if (matchedText.startsWith('**')) {
+ result.push({matchedText.slice(2, -2)})
+ key += 1
+ lastIndex = start + matchedText.length
+ continue
+ }
+
+ if (matchedText.startsWith('*')) {
+ result.push({matchedText.slice(1, -1)})
+ key += 1
+ lastIndex = start + matchedText.length
+ continue
+ }
+ }
+
+ if (lastIndex < text.length) {
+ result.push(text.slice(lastIndex))
+ }
+
+ return result
+}
+
+export default function MarkdownRenderer({ markdown, className }: MarkdownRendererProps) {
+ const lines = markdown.replace(/\r\n/g, '\n').split('\n')
+ const nodes: ReactNode[] = []
+ let index = 0
+
+ while (index < lines.length) {
+ const line = lines[index]
+ const trimmed = line.trim()
+
+ if (!trimmed) {
+ index += 1
+ continue
+ }
+
+ if (trimmed.startsWith('```')) {
+ const codeLines: string[] = []
+ index += 1
+ while (index < lines.length && !lines[index].trim().startsWith('```')) {
+ codeLines.push(lines[index])
+ index += 1
+ }
+ if (index < lines.length) {
+ index += 1
+ }
+ nodes.push(
+
+ {codeLines.join('\n')}
+ ,
+ )
+ continue
+ }
+
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
+ if (headingMatch) {
+ const level = headingMatch[1].length
+ const content = renderInlineMarkdown(headingMatch[2])
+ if (level === 1) nodes.push({content}
) + index += 1 + continue + } + + if (trimmed.startsWith('> ')) { + const quoteLines: string[] = [] + while (index < lines.length && lines[index].trim().startsWith('> ')) { + quoteLines.push(lines[index].trim().slice(2)) + index += 1 + } + nodes.push({quoteLines.join(' ')}) + continue + } + + const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/) + if (unorderedMatch) { + const items: string[] = [] + while (index < lines.length) { + const itemMatch = lines[index].trim().match(/^[-*]\s+(.+)$/) + if (!itemMatch) break + items.push(itemMatch[1]) + index += 1 + } + nodes.push( +
{renderInlineMarkdown(paragraphLines.join(' '))}
) + } + + return