feat: ship persistent ai playground and alerts foundation

This commit is contained in:
linkong
2026-04-10 15:57:34 +08:00
parent 60f5ff9bab
commit 89a71e6f29
31 changed files with 4754 additions and 669 deletions

View File

@@ -332,6 +332,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md)
- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md)
## 前端页面布局规范

View File

@@ -8,12 +8,25 @@ from app.db.session import get_db
from app.models.user import User
from app.schemas.ai import (
AIProviderStatusResponse,
AlertBriefRequest,
AlertBriefResponse,
BGPBriefRequest,
BGPBriefRecordResponse,
BGPBriefRecordSummary,
PlaygroundMessageActionResponse,
PlaygroundMessageCreateRequest,
PlaygroundMessageEditRequest,
PlaygroundMessageResendRequest,
PlaygroundMessageStopRequest,
PlaygroundSessionResponse,
PlaygroundSessionUpsertRequest,
PlaygroundThreadResponse,
SituationalAlertBriefRequest,
SituationalAlertBriefResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
from app.services.alert_ai_brief import build_alert_brief_request
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 (
@@ -22,6 +35,18 @@ from app.services.bgp_ai_brief_store import (
list_bgp_brief_records,
save_bgp_brief_record,
)
from app.services.playground_session_store import (
get_playground_session,
upsert_playground_session,
)
from app.services.playground_chat_service import (
create_turn,
edit_user_message,
get_thread,
resend_turn,
stop_message,
)
from app.services.situational_alert_ai_brief import build_situational_alert_brief_request
router = APIRouter()
@@ -51,6 +76,101 @@ async def analyze_situational_awareness(
return await provider_client.analyze(payload, request_id=request_id)
@router.get("/playground/thread", response_model=PlaygroundThreadResponse | None)
async def get_playground_thread(
session_key: str = "default",
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await get_thread(
db,
user_id=current_user.id,
session_key=session_key,
)
@router.get("/playground/session", response_model=PlaygroundSessionResponse | None)
async def get_saved_playground_session(
session_key: str = "default",
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await get_playground_session(
db,
user_id=current_user.id,
session_key=session_key,
)
@router.put("/playground/session", response_model=PlaygroundSessionResponse)
async def save_playground_session(
payload: PlaygroundSessionUpsertRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await upsert_playground_session(
db,
user_id=current_user.id,
payload=payload,
)
@router.post("/playground/messages", response_model=PlaygroundMessageActionResponse)
async def create_playground_message(
payload: PlaygroundMessageCreateRequest,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
db: AsyncSession = Depends(get_db),
):
return await create_turn(
db,
user_id=current_user.id,
payload=payload,
provider_client=provider_client,
)
@router.post("/playground/messages/stop", response_model=PlaygroundMessageActionResponse)
async def stop_playground_message(
payload: PlaygroundMessageStopRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await stop_message(
db,
user_id=current_user.id,
payload=payload,
)
@router.post("/playground/messages/resend", response_model=PlaygroundMessageActionResponse)
async def resend_playground_message(
payload: PlaygroundMessageResendRequest,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
db: AsyncSession = Depends(get_db),
):
return await resend_turn(
db,
user_id=current_user.id,
payload=payload,
provider_client=provider_client,
)
@router.post("/playground/messages/edit", response_model=PlaygroundMessageActionResponse)
async def edit_playground_message(
payload: PlaygroundMessageEditRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await edit_user_message(
db,
user_id=current_user.id,
payload=payload,
)
@router.get("/bgp/briefs", response_model=list[BGPBriefRecordSummary])
async def list_saved_bgp_briefs(
current_user: User = Depends(get_current_user),
@@ -88,7 +208,7 @@ async def analyze_bgp_brief(
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(
brief_request, facts, context = await build_bgp_brief_request(
db,
incident_limit=payload.incident_limit,
anomaly_limit=payload.anomaly_limit,
@@ -98,4 +218,64 @@ async def analyze_bgp_brief(
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)
return save_bgp_brief_record(
analysis,
request_id=request_id,
facts=facts,
context=context,
)
@router.post("/alerts/brief", response_model=AlertBriefResponse)
async def analyze_alert_brief(
payload: AlertBriefRequest,
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, facts, context = await build_alert_brief_request(
db,
alert_limit=payload.alert_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 AlertBriefResponse(
**analysis.model_dump(),
title=brief_request.title,
objective=brief_request.objective,
facts=facts,
context=context,
)
@router.post("/situational-alerts/brief", response_model=SituationalAlertBriefResponse)
async def analyze_situational_alert_brief(
payload: SituationalAlertBriefRequest,
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, facts, context = await build_situational_alert_brief_request(db)
brief_request.preferred_model = payload.preferred_model
brief_request.thinking = payload.thinking
analysis = await provider_client.analyze(brief_request, request_id=request_id)
return SituationalAlertBriefResponse(
**analysis.model_dump(),
title=brief_request.title,
objective=brief_request.objective,
facts=facts,
context=context,
)

View File

@@ -1,7 +1,7 @@
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func, case
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,6 +9,7 @@ from app.db.session import get_db
from app.models.user import User
from app.core.security import get_current_user
from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.schemas.alert import AlertResolutionRequest
router = APIRouter()
@@ -77,7 +78,7 @@ async def acknowledge_alert(
@router.post("/{alert_id}/resolve")
async def resolve_alert(
alert_id: int,
resolution: str,
payload: AlertResolutionRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -85,12 +86,12 @@ async def resolve_alert(
alert = result.scalar_one_or_none()
if not alert:
return {"error": "Alert not found"}
raise HTTPException(status_code=404, detail="Alert not found")
alert.status = AlertStatus.RESOLVED
alert.resolved_by = current_user.id
alert.resolved_at = datetime.now(UTC)
alert.resolution_notes = resolution
alert.resolution_notes = payload.resolution
await db.commit()
return {"message": "Alert resolved", "alert": alert.to_dict()}

View File

@@ -2,7 +2,7 @@
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select, func, text
from sqlalchemy import case, select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db

View File

@@ -95,6 +95,8 @@ async def init_db():
import app.models.bgp_observation # noqa: F401
import app.models.collected_data # noqa: F401
import app.models.system_setting # noqa: F401
import app.models.playground_session # noqa: F401
import app.models.playground_message # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -9,6 +9,8 @@ from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.bgp_observation import BGPObservation
from app.models.system_setting import SystemSetting
from app.models.playground_session import PlaygroundSession
from app.models.playground_message import PlaygroundMessage
__all__ = [
"User",

View File

@@ -0,0 +1,40 @@
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from app.db.session import Base
class PlaygroundMessage(Base):
__tablename__ = "playground_messages"
id = Column(Integer, primary_key=True, autoincrement=True)
public_id = Column(String(64), unique=True, index=True, nullable=False)
session_id = Column(Integer, ForeignKey("playground_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
parent_message_id = Column(Integer, ForeignKey("playground_messages.id", ondelete="SET NULL"), nullable=True)
role = Column(String(20), nullable=False)
kind = Column(String(20), nullable=False, default="message")
status = Column(String(20), nullable=False, default="done")
title = Column(String(255), nullable=True)
content = Column(Text, nullable=False, default="")
thinking_content = Column(Text, nullable=False, default="")
meta = Column(JSON, nullable=False, default=list)
provider = Column(String(100), nullable=True)
model = Column(String(200), nullable=True)
request_id = Column(String(100), nullable=True)
raw_response = Column(JSON, nullable=False, default=dict)
content_blocks = Column(JSON, nullable=False, default=list)
text_blocks = Column(JSON, nullable=False, default=list)
thinking_blocks = Column(JSON, nullable=False, default=list)
sort_order = Column(Integer, nullable=False, default=0, index=True)
is_visible = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self):
return f"<PlaygroundMessage public_id={self.public_id} role={self.role} status={self.status}>"

View File

@@ -0,0 +1,27 @@
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.sql import func
from app.db.session import Base
class PlaygroundSession(Base):
__tablename__ = "playground_sessions"
__table_args__ = (
UniqueConstraint("user_id", "session_key", name="uq_playground_sessions_user_session_key"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
session_key = Column(String(100), nullable=False, default="default")
title = Column(String(200), nullable=False, default="Playground 会话")
state = Column(JSON, nullable=False, default={})
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self):
return f"<PlaygroundSession user_id={self.user_id} session_key={self.session_key}>"

View File

@@ -29,6 +29,17 @@ class BGPBriefRequest(BaseModel):
thinking: dict[str, Any] | None = None
class AlertBriefRequest(BaseModel):
alert_limit: int = Field(default=8, ge=1, le=20)
preferred_model: str | None = Field(default=None, max_length=200)
thinking: dict[str, Any] | None = None
class SituationalAlertBriefRequest(BaseModel):
preferred_model: str | None = Field(default=None, max_length=200)
thinking: dict[str, Any] | None = None
class SituationalAnalysisResponse(BaseModel):
provider: str
model: str
@@ -50,6 +61,22 @@ class BGPBriefRecordSummary(BaseModel):
class BGPBriefRecordResponse(BGPBriefRecordSummary):
content_markdown: str
facts: list[str] = Field(default_factory=list)
context: dict[str, Any] = Field(default_factory=dict)
class AlertBriefResponse(SituationalAnalysisResponse):
title: str
objective: str
facts: list[str] = Field(default_factory=list)
context: dict[str, Any] = Field(default_factory=dict)
class SituationalAlertBriefResponse(SituationalAnalysisResponse):
title: str
objective: str
facts: list[str] = Field(default_factory=list)
context: dict[str, Any] = Field(default_factory=dict)
class AIProviderStatusResponse(BaseModel):
@@ -59,3 +86,90 @@ class AIProviderStatusResponse(BaseModel):
configured: bool
model: str | None = None
base_url: str | None = None
class PlaygroundSessionState(BaseModel):
messages: list[dict[str, Any]] = Field(default_factory=list)
selectedPresetKey: str = Field(default="bgp-brief", max_length=100)
title: str = Field(default="", max_length=200)
objective: str = Field(default="", max_length=1000)
constraints: str = Field(default="")
inputValue: str = Field(default="")
analysis: dict[str, Any] | None = None
latestAnalysisMessageId: str | None = Field(default=None, max_length=200)
analysisMeta: dict[str, Any] = Field(default_factory=dict)
helpExpanded: bool = True
class PlaygroundSessionUpsertRequest(BaseModel):
session_key: str = Field(default="default", min_length=1, max_length=100)
title: str | None = Field(default=None, max_length=200)
state: PlaygroundSessionState
class PlaygroundMessageRecord(BaseModel):
id: str
role: str
kind: str = "message"
status: str = "done"
title: str | None = None
content: str = ""
thinking_content: str = ""
meta: list[str] = Field(default_factory=list)
markdown: bool = True
provider: str | None = None
model: str | None = None
request_id: str | None = None
raw_response: dict[str, Any] = Field(default_factory=dict)
content_blocks: list[dict[str, Any]] = Field(default_factory=list)
text_blocks: list[str] = Field(default_factory=list)
thinking_blocks: list[str] = Field(default_factory=list)
parent_message_id: str | None = None
created_at: str
updated_at: str
class PlaygroundSessionResponse(BaseModel):
id: str
session_key: str
title: str
state: PlaygroundSessionState
created_at: str
updated_at: str
class PlaygroundThreadResponse(BaseModel):
session: PlaygroundSessionResponse
messages: list[PlaygroundMessageRecord] = Field(default_factory=list)
class PlaygroundMessageCreateRequest(BaseModel):
session_key: str = Field(default="default", min_length=1, max_length=100)
title: str = Field(..., min_length=1, max_length=200)
objective: str = Field(..., min_length=1, max_length=1000)
constraints: str = Field(default="")
input: str = Field(..., min_length=1)
selected_preset_key: str = Field(default="bgp-brief", max_length=100)
help_expanded: bool = True
class PlaygroundMessageActionResponse(BaseModel):
session: PlaygroundSessionResponse
messages: list[PlaygroundMessageRecord] = Field(default_factory=list)
active_message_id: str | None = None
class PlaygroundMessageStopRequest(BaseModel):
session_key: str = Field(default="default", min_length=1, max_length=100)
message_id: str = Field(..., min_length=1, max_length=64)
class PlaygroundMessageResendRequest(BaseModel):
session_key: str = Field(default="default", min_length=1, max_length=100)
user_message_id: str = Field(..., min_length=1, max_length=64)
class PlaygroundMessageEditRequest(BaseModel):
session_key: str = Field(default="default", min_length=1, max_length=100)
user_message_id: str = Field(..., min_length=1, max_length=64)
content: str = Field(..., min_length=1)

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel, Field
class AlertResolutionRequest(BaseModel):
resolution: str = Field(..., min_length=1, max_length=1000)

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
from collections import Counter
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest
def _format_counter(counter: Counter[str], empty_text: str = "") -> str:
if not counter:
return empty_text
return "".join(f"{key} {value}" for key, value in counter.items())
async def build_alert_brief_request(
db: AsyncSession,
*,
alert_limit: int = 8,
) -> tuple[SituationalAnalysisRequest, list[str], dict[str, Any]]:
recent_alerts_result = await db.execute(
select(Alert)
.order_by(Alert.created_at.desc(), Alert.id.desc())
.limit(max(alert_limit, 1))
)
total_result = await db.execute(select(func.count(Alert.id)))
active_result = await db.execute(select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACTIVE))
acknowledged_result = await db.execute(
select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACKNOWLEDGED)
)
resolved_result = await db.execute(select(func.count(Alert.id)).where(Alert.status == AlertStatus.RESOLVED))
recent_alerts = recent_alerts_result.scalars().all()
total_alerts = total_result.scalar() or 0
active_alerts = active_result.scalar() or 0
acknowledged_alerts = acknowledged_result.scalar() or 0
resolved_alerts = resolved_result.scalar() or 0
severity_counts = Counter((item.severity.value if item.severity else "unknown") for item in recent_alerts)
status_counts = Counter((item.status.value if item.status else "unknown") for item in recent_alerts)
datasource_counts = Counter((item.datasource_name or "未命名数据源") for item in recent_alerts)
active_datasource_counts = Counter(
(item.datasource_name or "未命名数据源")
for item in recent_alerts
if item.status == AlertStatus.ACTIVE
)
facts = [
f"告警总量 {total_alerts} 条,其中 active {active_alerts} 条、acknowledged {acknowledged_alerts} 条、resolved {resolved_alerts} 条。",
f"最近告警严重度分布:{_format_counter(severity_counts)}",
f"最近告警状态分布:{_format_counter(status_counts)}",
f"最近告警数据源分布:{_format_counter(Counter(dict(datasource_counts.most_common(6))))}",
]
if active_datasource_counts:
facts.append(
"当前待处理告警主要集中在:"
+ _format_counter(Counter(dict(active_datasource_counts.most_common(5))))
+ ""
)
if recent_alerts:
facts.append(
"最近告警摘录:"
+ "".join(
[
f"{item.datasource_name or '未命名数据源'} / {item.severity.value if item.severity else '-'} / {item.status.value if item.status else '-'} / {item.message or '-'}"
for item in recent_alerts[:6]
]
)
)
context = {
"source": "alerts",
"total_alerts": total_alerts,
"active_alerts": active_alerts,
"acknowledged_alerts": acknowledged_alerts,
"resolved_alerts": resolved_alerts,
"severity_distribution": dict(severity_counts),
"status_distribution": dict(status_counts),
"top_datasources": dict(datasource_counts.most_common(6)),
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
}
return (
SituationalAnalysisRequest(
title="告警态势 AI 简报",
objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。",
observations=facts,
constraints=[
"明确区分事实、推断与建议。",
"优先指出仍处于 active 状态且高严重度的告警簇。",
"不要把 acknowledged 或 resolved 告警误判成当前仍在扩大。",
"如果证据不足,请明确指出缺失的上下文。",
],
context=context,
),
facts,
context,
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from collections import Counter
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +12,7 @@ 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
from app.services.bgp_enrichment import lookup_prefix_geography
def _format_counter(counter: dict[str, int], empty_text: str = "") -> str:
@@ -30,13 +32,59 @@ def _severity_rank(value: str | None) -> int:
return order.get((value or "").lower(), 99)
def _normalize_geo_key(country: str | None, city: str | None) -> str:
if city and country:
return f"{city}, {country}"
return city or country or "未知区域"
def _top_counter_items(counter: Counter[str], limit: int = 5) -> dict[str, int]:
return {name: count for name, count in counter.most_common(limit) if name}
def _collect_incident_regions(incidents: list[BGPIncident]) -> Counter[str]:
counter: Counter[str] = Counter()
for item in incidents:
for region in item.affected_regions or []:
if not isinstance(region, dict):
continue
counter[_normalize_geo_key(region.get("country"), region.get("city"))] += 1
return counter
def _collect_collector_regions(collectors: list[dict[str, Any]]) -> Counter[str]:
counter: Counter[str] = Counter()
for item in collectors:
counter[_normalize_geo_key(item.get("country"), item.get("city"))] += int(item.get("recent_24h_observation_count") or 0)
return counter
def _format_geo_evidence(prefix_geographies: dict[str, dict[str, Any]], limit: int = 6) -> str:
if not prefix_geographies:
return "没有命中 prefix geography 证据。"
rows = []
for prefix, item in list(prefix_geographies.items())[:limit]:
region = _normalize_geo_key(item.get("country"), item.get("city"))
source = item.get("source") or item.get("geography_mode") or "unknown"
as_hint = item.get("asn")
as_name = item.get("as_name")
as_text = ""
if as_hint:
as_text = f" / ASN AS{as_hint}"
if as_name:
as_text += f" ({as_name})"
rows.append(f"{prefix} -> {region} / 来源 {source}{as_text}")
return "".join(rows)
async def build_bgp_brief_request(
db: AsyncSession,
*,
incident_limit: int = 5,
anomaly_limit: int = 6,
collector_limit: int = 5,
) -> SituationalAnalysisRequest:
) -> tuple[SituationalAnalysisRequest, list[str], dict[str, int | str | dict[str, int]]]:
incidents_result = await db.execute(
select(BGPIncident)
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
@@ -68,6 +116,7 @@ async def build_bgp_brief_request(
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)
incident_region_counts = _collect_incident_regions(incidents)
top_collectors = sorted(
active_collectors,
@@ -77,6 +126,29 @@ async def build_bgp_brief_request(
str(item["collector"]),
),
)[: max(collector_limit, 1)]
collector_region_counts = _collect_collector_regions(top_collectors)
prefix_candidates = sorted(
{
prefix
for item in incidents
for prefix in (item.affected_prefixes or [])
if prefix
}
| {item.prefix for item in anomalies if item.prefix}
)
prefix_geographies = await lookup_prefix_geography(db, prefix_candidates) if prefix_candidates else {}
geography_region_counts = Counter(
_normalize_geo_key(item.get("country"), item.get("city"))
for item in prefix_geographies.values()
if item.get("country") or item.get("city")
)
hotspot_region_counts = geography_region_counts + incident_region_counts
collector_bias_regions = [
region
for region, count in collector_region_counts.most_common(3)
if count > hotspot_region_counts.get(region, 0)
]
observations_lines: list[str] = [
f"当前共有 {total_incidents} 起 BGP incidents、{total_anomalies} 条 anomalies、{total_observations} 条原始观测事件。",
@@ -88,6 +160,27 @@ async def build_bgp_brief_request(
f"观测事件类型分布:{_format_counter(dict(event_type_counts.most_common(6)))}",
]
if hotspot_region_counts:
observations_lines.append(
"区域热点事实层:"
+ _format_counter(_top_counter_items(hotspot_region_counts, limit=5), empty_text="无明显区域聚集")
+ ""
)
if prefix_geographies:
observations_lines.append("Prefix geography 证据:" + _format_geo_evidence(prefix_geographies))
if collector_bias_regions:
observations_lines.append(
"观测偏差提示:重点观测站最近 24h 活跃度更集中在 "
+ "".join(collector_bias_regions)
+ ",这些区域的事件升温结论需要结合 prefix geography 与 affected regions 交叉验证。"
)
elif top_collectors:
observations_lines.append(
"观测偏差提示:当前未发现明显高于区域热点事实层的单一观测站集中区域,但仍需区分 collector coverage 与真实区域风险。"
)
if incidents:
observations_lines.append(
"最近 incident 摘要:" + "".join(
@@ -124,24 +217,43 @@ async def build_bgp_brief_request(
)
)
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)),
"region_hotspots": _top_counter_items(hotspot_region_counts, limit=6),
"incident_regions": _top_counter_items(incident_region_counts, limit=6),
"collector_bias_regions": collector_bias_regions,
"prefix_geography_sources": dict(
Counter(str(item.get("source") or "unknown") for item in prefix_geographies.values()).most_common(5)
),
"prefix_geography_sample": {
prefix: {
"country": item.get("country"),
"city": item.get("city"),
"source": item.get("source"),
"asn": item.get("asn"),
"as_name": item.get("as_name"),
}
for prefix, item in list(prefix_geographies.items())[:8]
},
}
return SituationalAnalysisRequest(
title="BGP 态势 AI 简报",
objective="基于当前 BGP incidents、anomalies、原始观测事件观测站覆盖情况,生成一份面向操作员的简明态势简报,突出当前风险、证据和优先动作。",
objective="基于当前 BGP incidents、anomalies、原始观测事件观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
observations=observations_lines,
constraints=[
"明确区分事实、推断与建议。",
"优先指出需要立即关注的高严重度 incident 或异常模式。",
"需要单独指出哪些区域结论来自 prefix geography / affected regions哪些可能受 collector coverage 偏差影响。",
"结论应服务值班排障,不要写成泛泛的模型演示文案。",
"如果证据不足,要明确指出缺失数据。",
],
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)),
},
)
context=context,
), observations_lines, context

View File

@@ -4,6 +4,7 @@ import json
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from app.core.config import ROOT_DIR
@@ -25,6 +26,8 @@ class _StoredBrief:
request_id: str | None
generated_at: str
content_markdown: str
facts: list[str]
context: dict[str, Any]
path: Path
@@ -33,7 +36,7 @@ def _ensure_storage_dir() -> Path:
return _BRIEF_STORAGE_DIR
def _build_metadata_line(metadata: dict[str, str | None]) -> str:
def _build_metadata_line(metadata: dict[str, Any]) -> str:
return f"{_METADATA_PREFIX}{json.dumps(metadata, ensure_ascii=False)}{_METADATA_SUFFIX}"
@@ -62,6 +65,8 @@ def _parse_brief_file(path: Path) -> _StoredBrief | None:
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"),
facts=list(metadata.get("facts") or []),
context=dict(metadata.get("context") or {}),
path=path,
)
@@ -104,6 +109,8 @@ def get_bgp_brief_record(brief_id: str) -> BGPBriefRecordResponse | None:
request_id=parsed.request_id,
generated_at=parsed.generated_at,
content_markdown=parsed.content_markdown,
facts=parsed.facts,
context=parsed.context,
)
@@ -118,6 +125,8 @@ def save_bgp_brief_record(
analysis: SituationalAnalysisResponse,
*,
request_id: str | None,
facts: list[str] | None = None,
context: dict[str, Any] | None = None,
generated_at: datetime | None = None,
) -> BGPBriefRecordResponse:
created_at = generated_at or datetime.now(UTC)
@@ -131,6 +140,8 @@ def save_bgp_brief_record(
"model": analysis.model,
"request_id": request_id,
"generated_at": created_at.isoformat(),
"facts": facts or [],
"context": context or {},
}
markdown_text = f"{_build_metadata_line(metadata)}\n\n{analysis.content.rstrip()}\n"
@@ -144,4 +155,6 @@ def save_bgp_brief_record(
request_id=request_id,
generated_at=created_at.isoformat(),
content_markdown=analysis.content,
facts=facts or [],
context=context or {},
)

View File

@@ -231,6 +231,13 @@ async def _lookup_prefix_geography(
return results
async def lookup_prefix_geography(
db: AsyncSession,
prefix_values: list[str],
) -> dict[str, dict[str, Any]]:
return await _lookup_prefix_geography(db, prefix_values)
async def enrich_bgp_events_for_batch(
db: AsyncSession,
*,

View File

@@ -0,0 +1,678 @@
from __future__ import annotations
import asyncio
from collections.abc import Sequence
from datetime import UTC, datetime
from time import perf_counter
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import async_session_factory
from app.models.playground_message import PlaygroundMessage
from app.models.playground_session import PlaygroundSession
from app.schemas.ai import (
PlaygroundMessageEditRequest,
PlaygroundMessageActionResponse,
PlaygroundMessageCreateRequest,
PlaygroundMessageRecord,
PlaygroundMessageResendRequest,
PlaygroundMessageStopRequest,
PlaygroundSessionResponse,
PlaygroundSessionState,
PlaygroundSessionUpsertRequest,
PlaygroundThreadResponse,
SituationalAnalysisRequest,
)
from app.services.ai_client import AIProviderClient
from app.services.playground_session_store import _to_response as session_to_response
from app.services.playground_session_store import upsert_playground_session
STREAM_CHUNK_SIZE = 24
STREAM_INTERVAL_SECONDS = 0.08
THINKING_PREVIEW_SECONDS = 2.6
class _ActiveRun:
def __init__(self, task: asyncio.Task[None]) -> None:
self.task = task
self.stop_requested = asyncio.Event()
_ACTIVE_RUNS: dict[str, _ActiveRun] = {}
def _message_to_record(message: PlaygroundMessage, parent_public_id: str | None = None) -> PlaygroundMessageRecord:
return PlaygroundMessageRecord(
id=message.public_id,
role=message.role,
kind=message.kind,
status=message.status,
title=message.title,
content=message.content or "",
thinking_content=message.thinking_content or "",
meta=list(message.meta or []),
markdown=message.role != "system",
provider=message.provider,
model=message.model,
request_id=message.request_id,
raw_response=dict(message.raw_response or {}),
content_blocks=list(message.content_blocks or []),
text_blocks=list(message.text_blocks or []),
thinking_blocks=list(message.thinking_blocks or []),
parent_message_id=parent_public_id,
created_at=message.created_at.isoformat(),
updated_at=message.updated_at.isoformat(),
)
async def _ensure_session(
db: AsyncSession,
*,
user_id: int,
session_key: str,
title: str,
state: PlaygroundSessionState | None = None,
) -> PlaygroundSession:
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == session_key,
)
)
session = result.scalar_one_or_none()
if session is not None:
if title:
session.title = title[:200]
if state is not None:
session.state = state.model_dump(mode="json")
await db.flush()
await db.refresh(session)
return session
payload = PlaygroundSessionUpsertRequest(
session_key=session_key,
title=title[:200],
state=state or PlaygroundSessionState(title=title[:200]),
)
await upsert_playground_session(db, user_id=user_id, payload=payload)
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == session_key,
)
)
return result.scalar_one()
async def _list_visible_messages(
db: AsyncSession,
*,
session_id: int,
) -> list[PlaygroundMessage]:
result = await db.execute(
select(PlaygroundMessage)
.where(
PlaygroundMessage.session_id == session_id,
PlaygroundMessage.is_visible.is_(True),
)
.order_by(PlaygroundMessage.sort_order.asc(), PlaygroundMessage.id.asc())
)
return list(result.scalars().all())
async def _build_thread_response(
db: AsyncSession,
*,
session: PlaygroundSession,
) -> PlaygroundThreadResponse:
messages = await _list_visible_messages(db, session_id=session.id)
id_map = {item.id: item.public_id for item in messages}
return PlaygroundThreadResponse(
session=session_to_response(session),
messages=[_message_to_record(item, id_map.get(item.parent_message_id)) for item in messages],
)
async def get_thread(
db: AsyncSession,
*,
user_id: int,
session_key: str,
) -> PlaygroundThreadResponse | None:
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == session_key,
)
)
session = result.scalar_one_or_none()
if session is None:
return None
return await _build_thread_response(db, session=session)
def _collect_constraints(raw_constraints: str) -> list[str]:
return [item.strip() for item in raw_constraints.split("\n") if item.strip()]
async def _next_sort_order(db: AsyncSession, session_id: int) -> int:
result = await db.execute(
select(func.max(PlaygroundMessage.sort_order)).where(PlaygroundMessage.session_id == session_id)
)
current = result.scalar_one_or_none()
return int(current or 0)
async def _set_session_state(
db: AsyncSession,
*,
session: PlaygroundSession,
payload: PlaygroundMessageCreateRequest,
) -> PlaygroundSession:
session.state = PlaygroundSessionState(
messages=[],
selectedPresetKey=payload.selected_preset_key,
title=payload.title,
objective=payload.objective,
constraints=payload.constraints,
inputValue="",
analysis=None,
latestAnalysisMessageId=None,
analysisMeta={},
helpExpanded=payload.help_expanded,
).model_dump(mode="json")
session.title = payload.title[:200]
await db.flush()
await db.refresh(session)
return session
async def create_turn(
db: AsyncSession,
*,
user_id: int,
payload: PlaygroundMessageCreateRequest,
provider_client: AIProviderClient,
) -> PlaygroundMessageActionResponse:
session = await _ensure_session(
db,
user_id=user_id,
session_key=payload.session_key,
title=payload.title,
state=PlaygroundSessionState(
selectedPresetKey=payload.selected_preset_key,
title=payload.title,
objective=payload.objective,
constraints=payload.constraints,
inputValue="",
helpExpanded=payload.help_expanded,
),
)
session = await _set_session_state(db, session=session, payload=payload)
base_order = await _next_sort_order(db, session.id)
user_message = PlaygroundMessage(
public_id=uuid4().hex,
session_id=session.id,
user_id=user_id,
role="user",
kind="message",
status="done",
title=payload.selected_preset_key,
content=payload.input,
meta=[payload.title],
sort_order=base_order + 10,
)
assistant_message = PlaygroundMessage(
public_id=uuid4().hex,
session_id=session.id,
user_id=user_id,
parent_message_id=None,
role="assistant",
kind="thinking",
status="pending",
title="AI 回应",
content="",
thinking_content="",
meta=[],
sort_order=base_order + 20,
)
db.add(user_message)
await db.flush()
assistant_message.parent_message_id = user_message.id
db.add(assistant_message)
await db.flush()
await db.refresh(user_message)
await db.refresh(assistant_message)
await db.commit()
await db.refresh(session)
await db.refresh(user_message)
await db.refresh(assistant_message)
task = asyncio.create_task(
_run_assistant_message(
user_id=user_id,
session_id=session.id,
session_key=payload.session_key,
user_message_id=user_message.id,
assistant_message_id=assistant_message.id,
payload=payload,
provider_client=provider_client,
)
)
_ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task)
thread = await _build_thread_response(db, session=session)
return PlaygroundMessageActionResponse(
session=thread.session,
messages=thread.messages,
active_message_id=assistant_message.public_id,
)
async def _create_assistant_retry_turn(
db: AsyncSession,
*,
user_id: int,
session: PlaygroundSession,
user_message: PlaygroundMessage,
payload: PlaygroundMessageCreateRequest,
provider_client: AIProviderClient,
) -> PlaygroundMessageActionResponse:
base_order = await _next_sort_order(db, session.id)
assistant_message = PlaygroundMessage(
public_id=uuid4().hex,
session_id=session.id,
user_id=user_id,
parent_message_id=user_message.id,
role="assistant",
kind="thinking",
status="pending",
title="AI 回应",
content="",
thinking_content="",
meta=[],
sort_order=base_order + 10,
)
db.add(assistant_message)
await db.flush()
await db.commit()
await db.refresh(session)
await db.refresh(assistant_message)
task = asyncio.create_task(
_run_assistant_message(
user_id=user_id,
session_id=session.id,
session_key=payload.session_key,
user_message_id=user_message.id,
assistant_message_id=assistant_message.id,
payload=payload,
provider_client=provider_client,
)
)
_ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task)
thread = await _build_thread_response(db, session=session)
return PlaygroundMessageActionResponse(
session=thread.session,
messages=thread.messages,
active_message_id=assistant_message.public_id,
)
async def stop_message(
db: AsyncSession,
*,
user_id: int,
payload: PlaygroundMessageStopRequest,
) -> PlaygroundMessageActionResponse:
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == payload.session_key,
)
)
session = result.scalar_one_or_none()
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found")
result = await db.execute(
select(PlaygroundMessage).where(
PlaygroundMessage.user_id == user_id,
PlaygroundMessage.public_id == payload.message_id,
PlaygroundMessage.is_visible.is_(True),
)
)
message = result.scalar_one_or_none()
if message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground message not found")
if message.status not in {"pending", "thinking", "answering"}:
thread = await _build_thread_response(db, session=session)
return PlaygroundMessageActionResponse(
session=thread.session,
messages=thread.messages,
active_message_id=None,
)
active_run = _ACTIVE_RUNS.get(message.public_id)
if active_run is not None:
active_run.stop_requested.set()
active_run.task.cancel()
message.status = "stopped"
if "已手动停止生成" not in (message.meta or []):
message.meta = [*(message.meta or []), "已手动停止生成"]
await db.flush()
await db.commit()
await db.refresh(message)
thread = await _build_thread_response(db, session=session)
return PlaygroundMessageActionResponse(
session=thread.session,
messages=thread.messages,
active_message_id=None,
)
async def resend_turn(
db: AsyncSession,
*,
user_id: int,
payload: PlaygroundMessageResendRequest,
provider_client: AIProviderClient,
) -> PlaygroundMessageActionResponse:
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == payload.session_key,
)
)
session = result.scalar_one_or_none()
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found")
result = await db.execute(
select(PlaygroundMessage).where(
PlaygroundMessage.user_id == user_id,
PlaygroundMessage.public_id == payload.user_message_id,
PlaygroundMessage.role == "user",
PlaygroundMessage.is_visible.is_(True),
)
)
user_message = result.scalar_one_or_none()
if user_message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found")
later_messages = await db.execute(
select(PlaygroundMessage).where(
PlaygroundMessage.session_id == session.id,
PlaygroundMessage.sort_order > user_message.sort_order,
PlaygroundMessage.is_visible.is_(True),
)
)
for item in later_messages.scalars().all():
item.is_visible = False
if item.status in {"pending", "thinking", "answering"}:
active_run = _ACTIVE_RUNS.get(item.public_id)
if active_run is not None:
active_run.stop_requested.set()
active_run.task.cancel()
await db.flush()
await db.commit()
session_state = PlaygroundSessionState.model_validate(session.state or {})
create_payload = PlaygroundMessageCreateRequest(
session_key=payload.session_key,
title=session_state.title or session.title,
objective=session_state.objective or "继续当前对话",
constraints=session_state.constraints or "",
input=user_message.content,
selected_preset_key=session_state.selectedPresetKey or "bgp-brief",
help_expanded=session_state.helpExpanded,
)
return await _create_assistant_retry_turn(
db,
user_id=user_id,
session=session,
user_message=user_message,
payload=create_payload,
provider_client=provider_client,
)
async def edit_user_message(
db: AsyncSession,
*,
user_id: int,
payload: PlaygroundMessageEditRequest,
) -> PlaygroundMessageActionResponse:
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == payload.session_key,
)
)
session = result.scalar_one_or_none()
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found")
result = await db.execute(
select(PlaygroundMessage).where(
PlaygroundMessage.user_id == user_id,
PlaygroundMessage.public_id == payload.user_message_id,
PlaygroundMessage.role == "user",
PlaygroundMessage.is_visible.is_(True),
)
)
user_message = result.scalar_one_or_none()
if user_message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found")
user_message.content = payload.content.strip()
await db.flush()
await db.commit()
await db.refresh(user_message)
thread = await _build_thread_response(db, session=session)
return PlaygroundMessageActionResponse(
session=thread.session,
messages=thread.messages,
active_message_id=None,
)
async def _append_meta_if_missing(db: AsyncSession, message_id: int, meta_line: str) -> None:
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == message_id))
message = result.scalar_one_or_none()
if message is None:
return
if meta_line not in (message.meta or []):
message.meta = [*(message.meta or []), meta_line]
await db.flush()
async def _should_stop(message_public_id: str) -> bool:
active_run = _ACTIVE_RUNS.get(message_public_id)
return active_run.stop_requested.is_set() if active_run is not None else False
async def _mark_message_state(
db: AsyncSession,
*,
message_id: int,
**updates,
) -> PlaygroundMessage:
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == message_id))
message = result.scalar_one()
for key, value in updates.items():
setattr(message, key, value)
await db.flush()
await db.refresh(message)
return message
def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_user_message_id: int) -> list[dict]:
history: list[dict] = []
for item in messages:
if item.id >= current_user_message_id:
break
if item.role == "system":
continue
history.append(
{
"role": item.role,
"kind": item.kind or "message",
"title": item.title,
"content": item.content or "",
}
)
return history[-8:]
async def _run_assistant_message(
*,
user_id: int,
session_id: int,
session_key: str,
user_message_id: int,
assistant_message_id: int,
payload: PlaygroundMessageCreateRequest,
provider_client: AIProviderClient,
) -> None:
request_id = str(uuid4())
started_at = perf_counter()
assistant_public_id: str | None = None
try:
async with async_session_factory() as db:
session = await db.get(PlaygroundSession, session_id)
user_message = await db.get(PlaygroundMessage, user_message_id)
assistant_message = await db.get(PlaygroundMessage, assistant_message_id)
if session is None or user_message is None or assistant_message is None:
return
assistant_public_id = assistant_message.public_id
visible_messages = await _list_visible_messages(db, session_id=session_id)
conversation_history = _build_conversation_history(visible_messages, user_message_id)
request_payload = SituationalAnalysisRequest(
title=payload.title,
objective=payload.objective,
observations=[item.strip() for item in payload.input.split("\n") if item.strip()],
constraints=_collect_constraints(payload.constraints),
context={
"source": "playground",
"preset": payload.selected_preset_key,
"conversation_history": conversation_history,
"history_size": len(conversation_history),
},
thinking={"type": "enabled"},
)
analysis = await provider_client.analyze(request_payload, request_id=request_id)
async with async_session_factory() as db:
assistant_message = await _mark_message_state(
db,
message_id=assistant_message_id,
status="thinking" if analysis.thinking_blocks else "answering",
title=f"{analysis.provider} / {analysis.model}",
provider=analysis.provider,
model=analysis.model,
request_id=request_id,
raw_response=analysis.raw_response,
content_blocks=[item.model_dump(mode="json") for item in analysis.content_blocks],
text_blocks=analysis.text_blocks,
thinking_blocks=analysis.thinking_blocks,
thinking_content="\n\n".join(analysis.thinking_blocks).strip(),
)
session = await db.get(PlaygroundSession, session_id)
if session is not None:
session_state = PlaygroundSessionState.model_validate(session.state or {})
session.state = session_state.model_copy(
update={
"latestAnalysisMessageId": assistant_message.public_id,
"analysis": analysis.model_dump(mode="json"),
}
).model_dump(mode="json")
await db.flush()
await db.commit()
if assistant_public_id and analysis.thinking_blocks:
await asyncio.sleep(THINKING_PREVIEW_SECONDS)
if await _should_stop(assistant_public_id):
return
content = analysis.content or ""
cursor = 0
while cursor < len(content):
if assistant_public_id and await _should_stop(assistant_public_id):
return
cursor = min(len(content), cursor + STREAM_CHUNK_SIZE)
async with async_session_factory() as db:
await _mark_message_state(
db,
message_id=assistant_message_id,
status="answering",
content=content[:cursor],
)
await db.commit()
await asyncio.sleep(STREAM_INTERVAL_SECONDS)
duration_ms = round((perf_counter() - started_at) * 1000)
async with async_session_factory() as db:
assistant_message = await _mark_message_state(
db,
message_id=assistant_message_id,
status="done",
content=content,
meta=[
f"Request ID: {request_id}",
f"耗时: {duration_ms} ms",
f"完成时间: {datetime.now(UTC).astimezone().isoformat()}",
],
)
session = await db.get(PlaygroundSession, session_id)
if session is not None:
session_state = PlaygroundSessionState.model_validate(session.state or {})
session.state = session_state.model_copy(
update={
"latestAnalysisMessageId": assistant_message.public_id,
"analysis": analysis.model_dump(mode="json"),
"analysisMeta": {
"requestId": request_id,
"durationMs": duration_ms,
"completedAt": datetime.now().isoformat(),
},
}
).model_dump(mode="json")
await db.flush()
await db.commit()
except asyncio.CancelledError:
async with async_session_factory() as db:
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
message = result.scalar_one_or_none()
if message is not None and message.status in {"pending", "thinking", "answering"}:
message.status = "stopped"
if "已手动停止生成" not in (message.meta or []):
message.meta = [*(message.meta or []), "已手动停止生成"]
await db.flush()
await db.commit()
raise
except Exception as exc:
async with async_session_factory() as db:
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
message = result.scalar_one_or_none()
if message is not None:
message.status = "error"
message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。"
message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"]
await db.flush()
await db.commit()
finally:
if assistant_public_id:
_ACTIVE_RUNS.pop(assistant_public_id, None)

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.playground_session import PlaygroundSession
from app.schemas.ai import (
PlaygroundSessionResponse,
PlaygroundSessionState,
PlaygroundSessionUpsertRequest,
)
def _to_response(record: PlaygroundSession) -> PlaygroundSessionResponse:
return PlaygroundSessionResponse(
id=str(record.id),
session_key=record.session_key,
title=record.title,
state=PlaygroundSessionState.model_validate(record.state or {}),
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
)
async def get_playground_session(
db: AsyncSession,
*,
user_id: int,
session_key: str = "default",
) -> PlaygroundSessionResponse | None:
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == session_key,
)
)
record = result.scalar_one_or_none()
if record is None:
return None
return _to_response(record)
async def upsert_playground_session(
db: AsyncSession,
*,
user_id: int,
payload: PlaygroundSessionUpsertRequest,
) -> PlaygroundSessionResponse:
result = await db.execute(
select(PlaygroundSession).where(
PlaygroundSession.user_id == user_id,
PlaygroundSession.session_key == payload.session_key,
)
)
record = result.scalar_one_or_none()
title = (payload.title or payload.state.title or "Playground 会话").strip()[:200] or "Playground 会话"
if record is None:
record = PlaygroundSession(
user_id=user_id,
session_key=payload.session_key,
title=title,
state=payload.state.model_dump(mode="json"),
)
db.add(record)
else:
record.title = title
record.state = payload.state.model_dump(mode="json")
await db.flush()
await db.refresh(record)
return _to_response(record)

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
from collections import Counter
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.schemas.ai import SituationalAnalysisRequest
from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record
def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "") -> str:
if not pairs:
return empty_text
return "".join(f"{key} {value}" for key, value in pairs if key)
async def build_situational_alert_brief_request(
db: AsyncSession,
) -> tuple[SituationalAnalysisRequest, list[str], dict[str, Any]]:
total_alerts_result = await db.execute(select(func.count(Alert.id)))
active_alerts_result = await db.execute(
select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACTIVE)
)
alert_severity_result = await db.execute(
select(Alert.severity, func.count(Alert.id))
.where(Alert.status == AlertStatus.ACTIVE)
.group_by(Alert.severity)
)
alert_source_result = await db.execute(
select(Alert.datasource_name, func.count(Alert.id))
.where(Alert.status == AlertStatus.ACTIVE)
.group_by(Alert.datasource_name)
.order_by(func.count(Alert.id).desc())
.limit(6)
)
recent_alerts_result = await db.execute(
select(Alert)
.order_by(Alert.created_at.desc(), Alert.id.desc())
.limit(6)
)
total_incidents_result = await db.execute(select(func.count(BGPIncident.id)))
active_incidents_result = await db.execute(
select(func.count(BGPIncident.id)).where(BGPIncident.status == "active")
)
bgp_severity_result = await db.execute(
select(BGPIncident.severity, func.count(BGPIncident.id))
.where(BGPIncident.status == "active")
.group_by(BGPIncident.severity)
)
bgp_region_counter: Counter[str] = Counter()
recent_incidents_result = await db.execute(
select(BGPIncident)
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
.limit(5)
)
total_anomalies_result = await db.execute(select(func.count(BGPAnomaly.id)))
active_anomalies_result = await db.execute(
select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active")
)
anomaly_type_result = await db.execute(
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
.where(BGPAnomaly.status == "active")
.group_by(BGPAnomaly.anomaly_type)
.order_by(func.count(BGPAnomaly.id).desc())
.limit(6)
)
recent_incidents = recent_incidents_result.scalars().all()
for incident in recent_incidents:
for region in incident.affected_regions or []:
if not isinstance(region, dict):
continue
label = ", ".join(part for part in [region.get("city"), region.get("country")] if part) or "未知区域"
bgp_region_counter[label] += 1
latest_bgp_brief = get_latest_bgp_brief_record()
active_alert_severities = [
(item[0].value if isinstance(item[0], AlertSeverity) else str(item[0]), item[1])
for item in alert_severity_result.fetchall()
if item[0]
]
active_bgp_severities = [
(str(item[0]), item[1])
for item in bgp_severity_result.fetchall()
if item[0]
]
active_anomaly_types = [(str(item[0]), item[1]) for item in anomaly_type_result.fetchall() if item[0]]
active_alert_sources = [
(str(item[0] or "未命名数据源"), item[1])
for item in alert_source_result.fetchall()
]
facts = [
(
f"系统告警侧:总告警 {total_alerts_result.scalar() or 0}active {active_alerts_result.scalar() or 0} 条;"
f"活跃告警严重度分布为 {_format_pairs(active_alert_severities)}"
),
(
f"BGP态势侧累计 incidents {total_incidents_result.scalar() or 0}active incidents {active_incidents_result.scalar() or 0} 条;"
f"活跃 incidents 严重度分布为 {_format_pairs(active_bgp_severities)}"
),
(
f"BGP异常侧累计 anomalies {total_anomalies_result.scalar() or 0}active anomalies {active_anomalies_result.scalar() or 0} 条;"
f"活跃 anomaly 类型分布为 {_format_pairs(active_anomaly_types)}"
),
]
if active_alert_sources:
facts.append(f"当前系统告警主要集中在:{_format_pairs(active_alert_sources)}")
if bgp_region_counter:
facts.append(f"BGP近期高风险区域线索{_format_pairs(bgp_region_counter.most_common(5))}")
recent_alerts = recent_alerts_result.scalars().all()
if recent_alerts:
facts.append(
"最近系统告警摘录:"
+ "".join(
[
f"{alert.datasource_name or '未命名数据源'} / {alert.severity.value if alert.severity else '-'} / {alert.status.value if alert.status else '-'} / {alert.message or '-'}"
for alert in recent_alerts
]
)
)
if recent_incidents:
facts.append(
"最近BGP事件摘录"
+ "".join(
[
f"{incident.incident_type} / {incident.severity} / {incident.status} / {incident.summary}"
for incident in recent_incidents
]
)
)
if latest_bgp_brief:
facts.append(
f"最近一份 BGP AI 简报生成于 {latest_bgp_brief.generated_at},模型 {latest_bgp_brief.model},可作为当前态势的补充说明。"
)
context = {
"source": "situational-alerts",
"active_system_alerts": active_alerts_result.scalar() or 0,
"active_system_alert_severities": dict(active_alert_severities),
"top_system_alert_sources": dict(active_alert_sources),
"active_bgp_incidents": active_incidents_result.scalar() or 0,
"active_bgp_incident_severities": dict(active_bgp_severities),
"active_bgp_anomalies": active_anomalies_result.scalar() or 0,
"active_bgp_anomaly_types": dict(active_anomaly_types),
"bgp_hot_regions": dict(bgp_region_counter.most_common(5)),
"latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None,
"latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None,
}
request = SituationalAnalysisRequest(
title="态势告警 AI 简报",
objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。",
observations=facts,
constraints=[
"明确区分事实、推断与建议。",
"优先指出仍在 active 状态的系统告警与 BGP 风险是否存在联动。",
"不要把单一数据源的局部异常夸大成全局态势。",
"如果证据不足,请明确写出仍缺哪些模块或区域信息。",
],
context=context,
)
return request, facts, context

View File

@@ -10,7 +10,12 @@ from app.core.config import settings
from app.core.security import create_access_token
from app.db.session import get_db
from app.models.user import User
from app.schemas.ai import AIProviderStatusResponse, SituationalAnalysisResponse
from app.schemas.ai import (
AIProviderStatusResponse,
PlaygroundSessionResponse,
PlaygroundSessionState,
SituationalAnalysisResponse,
)
@pytest.fixture
@@ -258,5 +263,294 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
assert "content_blocks" in data
assert "text_blocks" in data
assert "thinking_blocks" in data
@pytest.mark.asyncio
async def test_get_playground_session_with_auth(auth_headers):
"""Test playground session restore endpoint."""
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
async def override_get_db():
yield AsyncMock()
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
get_db: override_get_db,
}
transport = ASGITransport(app=app)
try:
with patch(
"app.api.v1.ai.get_playground_session",
new=AsyncMock(
return_value=PlaygroundSessionResponse(
id="1",
session_key="default",
title="Playground 会话",
state=PlaygroundSessionState(
messages=[{"id": "msg-1", "role": "user", "content": "hello"}],
title="测试标题",
objective="测试目标",
),
created_at="2026-04-10T00:00:00+00:00",
updated_at="2026-04-10T00:00:00+00:00",
)
),
):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/ai/playground/session", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["session_key"] == "default"
assert data["state"]["messages"][0]["content"] == "hello"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_save_playground_session_with_auth(auth_headers):
"""Test playground session save endpoint."""
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
async def override_get_db():
yield AsyncMock()
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
get_db: override_get_db,
}
transport = ASGITransport(app=app)
try:
with patch(
"app.api.v1.ai.upsert_playground_session",
new=AsyncMock(
return_value=PlaygroundSessionResponse(
id="1",
session_key="default",
title="测试标题",
state=PlaygroundSessionState(
messages=[{"id": "msg-1", "role": "user", "content": "hello"}],
title="测试标题",
objective="测试目标",
),
created_at="2026-04-10T00:00:00+00:00",
updated_at="2026-04-10T00:00:00+00:00",
)
),
):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put(
"/api/v1/ai/playground/session",
headers=auth_headers,
json={
"session_key": "default",
"title": "测试标题",
"state": {
"messages": [{"id": "msg-1", "role": "user", "content": "hello"}],
"selectedPresetKey": "bgp-brief",
"title": "测试标题",
"objective": "测试目标",
"constraints": "",
"inputValue": "",
"analysis": None,
"latestAnalysisMessageId": None,
"analysisMeta": {},
"helpExpanded": True,
},
},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "测试标题"
assert data["state"]["objective"] == "测试目标"
finally:
app.dependency_overrides.clear()
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_ai_bgp_brief_endpoint_persists_fact_snapshot(auth_headers):
class _FakeAIProviderClient:
async def analyze(self, _payload, request_id=None):
return SituationalAnalysisResponse(
provider="minimax",
model="MiniMax-M2.5",
content="# BGP AI 简报\n\n事实摘要:测试",
content_blocks=[],
text_blocks=["# BGP AI 简报\n\n事实摘要:测试"],
thinking_blocks=[],
raw_response={"id": "mock-bgp-brief"},
)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
async def override_get_db():
yield AsyncMock()
async def _fake_build_bgp_brief_request(_db, **_kwargs):
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
title="BGP 态势 AI 简报",
objective="生成值班简报",
observations=["事实A", "事实B"],
constraints=["不要编造"],
context={"incident_total": 2, "active_collectors": 3},
)
return request_payload, ["事实A", "事实B"], {"incident_total": 2, "active_collectors": 3}
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
get_db: override_get_db,
}
transport = ASGITransport(app=app)
try:
with patch("app.api.v1.ai.build_bgp_brief_request", side_effect=_fake_build_bgp_brief_request):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/ai/bgp/brief", headers=auth_headers, json={})
assert response.status_code == 200
data = response.json()
assert data["facts"] == ["事实A", "事实B"]
assert data["context"]["incident_total"] == 2
assert data["content_markdown"]
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_ai_alert_brief_endpoint_with_auth(auth_headers):
class _FakeAIProviderClient:
async def analyze(self, _payload, request_id=None):
return SituationalAnalysisResponse(
provider="minimax",
model="MiniMax-M2.7",
content="事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。",
content_blocks=[],
text_blocks=["事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。"],
thinking_blocks=[],
raw_response={"id": "mock-alert-brief"},
)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
async def override_get_db():
yield AsyncMock()
async def _fake_build_alert_brief_request(_db, **_kwargs):
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
title="告警态势 AI 简报",
objective="输出告警简报",
observations=["告警事实A", "告警事实B"],
constraints=["不要编造"],
context={"active_alerts": 3, "top_datasources": {"bgp": 2}},
)
return request_payload, ["告警事实A", "告警事实B"], {"active_alerts": 3, "top_datasources": {"bgp": 2}}
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
get_db: override_get_db,
}
transport = ASGITransport(app=app)
try:
with patch("app.api.v1.ai.build_alert_brief_request", side_effect=_fake_build_alert_brief_request):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/ai/alerts/brief", headers=auth_headers, json={})
assert response.status_code == 200
data = response.json()
assert data["title"] == "告警态势 AI 简报"
assert data["facts"] == ["告警事实A", "告警事实B"]
assert data["context"]["active_alerts"] == 3
assert data["content"]
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_ai_situational_alert_brief_endpoint_with_auth(auth_headers):
class _FakeAIProviderClient:
async def analyze(self, _payload, request_id=None):
return SituationalAnalysisResponse(
provider="minimax",
model="MiniMax-M2.7",
content="事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。",
content_blocks=[],
text_blocks=["事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。"],
thinking_blocks=[],
raw_response={"id": "mock-situational-brief"},
)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
async def override_get_db():
yield AsyncMock()
async def _fake_build_situational_alert_brief_request(_db):
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
title="态势告警 AI 简报",
objective="输出态势告警简报",
observations=["态势事实A", "态势事实B"],
constraints=["不要编造"],
context={"active_system_alerts": 2, "active_bgp_incidents": 1},
)
return request_payload, ["态势事实A", "态势事实B"], {"active_system_alerts": 2, "active_bgp_incidents": 1}
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
get_db: override_get_db,
}
transport = ASGITransport(app=app)
try:
with patch("app.api.v1.ai.build_situational_alert_brief_request", side_effect=_fake_build_situational_alert_brief_request):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/ai/situational-alerts/brief", headers=auth_headers, json={})
assert response.status_code == 200
data = response.json()
assert data["title"] == "态势告警 AI 简报"
assert data["facts"] == ["态势事实A", "态势事实B"]
assert data["context"]["active_system_alerts"] == 2
assert data["content"]
finally:
app.dependency_overrides.clear()

View File

@@ -0,0 +1,309 @@
# Situational Awareness Foundation Plan
## 定位
当前这套 AI 能力应被视为 `态势感知服务底座`,而不是完整的态势感知产品。
也就是说,现阶段的目标不是:
- 做一个“什么都能分析”的万能 AI 页面
- 让模型在证据不足时替代人工研判
- 过早把页面做成完整指挥大屏
现阶段真正要做的是:
- 先把 `model gateway / backend facade / evidence injection / page-specific brief` 这几层边界搭稳
- 让系统能够在已有证据上稳定地产出“可读、可回看、可扩展”的摘要
- 为后续更强的数据联动、agent 推理和 assessment 结构化输出预留好接口与数据模型
## 当前现实约束
### 1. 数据维度不足
目前系统能提供的主要证据仍集中在:
- BGP incidents / anomalies / events
- collector coverage
- datasource health / platform alerts
- prefix geography 的部分归属信息
当前明显还缺:
- 流量异常与业务指标
- 电商、支付、物流等业务侧指标
- 更丰富的资产、链路、区域、行业画像
- 外部舆情、公告、运营商状态、基础设施事件等背景信息
这意味着:
- 模型现在可以做“基于现有证据的摘要与归纳”
- 但还不能可靠地做“跨维度因果研判”
### 2. 维度之间联动还弱
目前不同模块之间更多是“并列展示”,还不是“强关联分析”:
- 系统告警和 BGP 事件还没有统一事件模型
- collector bias 与真实区域热度还没有完全剥离
- datasource health 与 BGP 风险、业务影响之间还没有稳定映射
这意味着:
- 当前更适合做 `brief / overview / operator notes`
- 还不适合过度承诺“自动态势判断”
### 3. 结构化 assessment 还未成为主输出
虽然已经有 BGP brief、系统告警 brief、态势告警 brief但目前主输出仍偏向
- 文本摘要
- facts/context 附带证据
后续真正要服务态势感知,需要更稳定的结构化输出,例如:
- summary
- key risks
- evidence
- confidence
- recommendations
- missing data
## 当前基座已经具备的能力
### 1. AI 调用边界已经明确
- `aiprovider` 负责模型协议与 provider 兼容
- `backend` 负责业务 API、证据整合和鉴权
- `frontend` 负责页面入口与结果展示
### 2. 页面级 AI 入口已经开始成型
当前已经有或正在收口的入口:
- `Playground`
- 用于链路验证与 provider 诊断
- `BGP AI 简报`
- 用于 BGP 事实摘要和区域风险归纳
- `Alerts`
- 用于系统告警、BGP 告警、态势告警三类入口
### 3. 证据优先的方向已经建立
已经不再只依赖人工在 Playground 中手填 prompt系统开始具备
- 从真实业务数据生成事实输入
- 保存 facts/context 快照
- 回看 AI 输出时同时回看证据
这一步非常关键,因为它决定后面能否从“玩具 demo”走向“有运维价值的系统”。
## 近期收尾建议
这些事情都属于“底座收口”,值得做,但不应该再继续重产品包装。
### 1. 统一 Alerts 页面
已采用:
- 一个 `Alerts` 页面
- 三个 tab
- `系统告警`
- `BGP 告警`
- `态势告警`
收尾重点:
- 保持 tab 的文案、摘要卡和 AI 简报交互一致
- 不额外扩展成多个独立二级页面
### 2. 保持 Playground 为测试台
原则:
- Playground 只承担链路验证、provider 状态诊断、请求结果观察
- 不继续堆“万能业务分析器”式交互
### 3. 把 brief 能力当服务能力而不是页面特效
页面现在能看到按钮和结果,这很好,但更重要的是:
- 后端接口稳定
- facts/context 可追踪
- 输出结构后续可升级
### 4. 导航结构先收口,不继续平铺一级菜单
随着后续能力扩展,系统很可能继续新增:
- 海缆
- 算力中心
- 战争信息
- 电商分析
- 其他专题观测页
如果继续把这些入口全部平铺在左侧一级菜单中,会带来两个问题:
- 一级菜单过长,用户难以判断先进入哪个上下文
- `观测页 / 告警页 / 研判页 / 运维页` 的职责边界会被混在一起
因此近期应明确采用分组导航,而不是继续扩展平铺菜单。
推荐的导航分组如下:
- `总览`
- 仪表盘
- Earth
- `专题观测`
- BGP 观测
- 采集数据
- 后续可扩展:海缆、算力中心、战争信息、电商分析
- `告警与研判`
- Alerts
- `运维与配置`
- 数据源
- AI Playground
- 用户管理
- 系统配置
这套结构的含义是:
- `专题观测` 页面负责看某个维度本身
- `Alerts` 负责跨模块风险与值班工作台
- `Playground` 保持为测试台,不挤占业务导航语义
短期收尾时,应优先重组现有入口,而不是继续增加新的一级菜单。
## 后续路线
## Phase 1服务底座稳固
目标:
- 不追求“更炫的 AI 页面”
- 先把当前接口、证据、存储和页面入口收稳
工作项:
- 统一页面级 AI 入口模式
- 统一 brief response schema
- 保证 facts/context 在前后端都可回看
- 继续清理 mock 和临时分支逻辑
完成标准:
- 每个 AI 入口都是真实链路
- 每个 AI 结果都能追溯到证据输入
## Phase 2Evidence-first Assessment
目标:
- 从“文本摘要”升级成“结构化 assessment”
工作项:
- 为 brief/assessment 定义统一 schema
- 固化:
- summary
- key_risks
- evidence
- confidence
- recommendations
- missing_data
- 页面以结构化区块展示,而不只是大段文本
完成标准:
- AI 输出可持久化、可比较、可审计
## Phase 3多维证据接入
目标:
- 让“态势感知”真正拥有更多维度,而不是只靠 BGP 与系统告警
优先接入方向:
- datasource health findings
- 流量或业务指标
- 区域/资产/链路映射
- 外部事件与公告
- 业务垂直数据,例如电商分析相关指标
完成标准:
- AI 能基于多个维度做交叉说明
- 不再只围绕单一模块自说自话
## Phase 4Correlation Layer
目标:
- 不同来源的信号不再只是并列,而是形成统一的事件关联
工作项:
- 统一 signal/finding 模型
- 跨模块事件聚合
- 证据来源权重
- collector bias 与真实热度分离
完成标准:
- 系统能回答“这些异常是不是同一件事”
- 系统能回答“哪些结论只是观测偏差”
## Phase 5Agent-assisted Situational Awareness
目标:
- 在证据足够的前提下,再让 agent 负责更复杂的推理与建议
工作项:
- 复用现有 agent runtime 规划
- 引入 web search / docs fetch / repair proposal 等能力
- 但始终坚持:
- evidence first
- proposal before action
- no silent mutation of defaults
完成标准:
- agent 成为证据驱动的分析层
- 而不是一个“万能猜测层”
## 设计原则
### 1. 先底座,后产品化
先把服务链路和证据模型做好,再做更大的页面表达。
### 2. 先证据,后判断
事实输入应先稳定,再让模型做归纳。
### 3. 先专用 brief后统一态势层
先让各业务页有各自可信的 AI 入口,再考虑统一态势页。
### 4. 先 proposal后自动动作
涉及修复、覆盖、写配置、调任务的动作,都应经过 proposal 和审计。
## 当前建议结论
对现在这个项目,最合理的定位是:
- `Playground` 是测试台
- `BGP / Alerts` 是第一批业务 AI 入口
- `aiprovider + backend AI facade + evidence snapshots` 是核心服务底座
现阶段不需要追求“已经具备完整态势感知能力”。
现阶段真正的成功标准是:
- 这套底座可用
- 可回看
- 可扩展
- 不自欺欺人

View File

@@ -1,9 +1,14 @@
import { Suspense, lazy } from 'react'
import { Spin } from 'antd'
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/auth'
import Login from './pages/Login/Login'
const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts'))
const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts'))
const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts'))
const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'))
const Users = lazy(() => import('./pages/Users/Users'))
const DataSources = lazy(() => import('./pages/DataSources/DataSources'))
@@ -37,6 +42,10 @@ function App() {
<Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />
<Route path="/alerts" element={<Navigate to="/alerts/system" replace />} />
<Route path="/alerts/system" element={<SystemAlerts />} />
<Route path="/alerts/bgp" element={<BGPAlerts />} />
<Route path="/alerts/situational" element={<SituationalAlerts />} />
<Route path="/bgp" element={<BGP />} />
<Route path="/playground" element={<Playground />} />
<Route path="/settings" element={<Settings />} />

View File

@@ -1,6 +1,7 @@
import { ReactNode, useState } from 'react'
import { ReactNode, useMemo, useState } from 'react'
import { Layout, Menu, Typography, Button, Space } from 'antd'
import {
AlertOutlined,
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
@@ -10,8 +11,13 @@ import {
RobotOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
GlobalOutlined,
AppstoreOutlined,
ToolOutlined,
InboxOutlined,
} from '@ant-design/icons'
import { useLocation, useNavigate } from 'react-router-dom'
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
import { useAuthStore } from '../../stores/auth'
import packageJson from '../../../package.json'
@@ -27,19 +33,64 @@ function AppLayout({ children }: AppLayoutProps) {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false)
const [openKeys, setOpenKeys] = useState<string[]>(['collection'])
const showBanner = true
const appVersion = `v${packageJson.version}`
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/datasources', icon: <DatabaseOutlined />, label: '数据源' },
{ key: '/data', icon: <BarChartOutlined />, label: '采集数据' },
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP观测' },
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
const menuItems: ItemType<MenuItemType>[] = [
{
key: 'overview',
icon: <DashboardOutlined />,
label: '总览',
children: [
{ key: '/admin', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/earth', icon: <GlobalOutlined />, label: 'Earth' },
],
},
{
key: 'collection',
icon: <InboxOutlined />,
label: '采集与数据',
children: [
{ key: '/datasources', icon: <DatabaseOutlined />, label: '数据源' },
{ key: '/data', icon: <BarChartOutlined />, label: '采集数据' },
],
},
{
key: 'observability',
icon: <AppstoreOutlined />,
label: '专题观测',
children: [
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP观测' },
],
},
{
key: 'alerts',
icon: <AlertOutlined />,
label: '告警与研判',
children: [
{ key: '/alerts/system', icon: <AlertOutlined />, label: '系统告警' },
{ key: '/alerts/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP 告警' },
{ key: '/alerts/situational', icon: <GlobalOutlined />, label: '态势告警' },
],
},
{
key: 'ops',
icon: <ToolOutlined />,
label: '运维与配置',
children: [
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
],
},
]
const selectedKey = useMemo(() => {
if (location.pathname === '/') return '/earth'
return location.pathname
}, [location.pathname])
return (
<Layout className="dashboard-layout">
<Sider
@@ -47,7 +98,12 @@ function AppLayout({ children }: AppLayoutProps) {
collapsedWidth={72}
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
onCollapse={(nextCollapsed) => {
setCollapsed(nextCollapsed)
if (nextCollapsed) {
setOpenKeys([])
}
}}
className="dashboard-sider"
>
<div className="dashboard-sider-inner">
@@ -69,10 +125,14 @@ function AppLayout({ children }: AppLayoutProps) {
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
selectedKeys={[selectedKey]}
openKeys={collapsed ? [] : openKeys}
items={menuItems}
onOpenChange={(keys) => {
setOpenKeys(keys as string[])
}}
onClick={({ key }) => {
if (key !== location.pathname) {
if (key !== selectedKey) {
navigate(key)
}
}}

View File

@@ -190,6 +190,11 @@ body {
min-height: 0;
}
.playground-page {
height: 100%;
min-height: 0;
}
.playground-page__grid {
flex: 1 1 auto;
min-height: 0;
@@ -198,11 +203,15 @@ body {
}
.playground-page__body {
flex: 1 1 auto;
min-height: 0;
display: flex;
overflow: hidden;
}
.playground-shell {
flex: 1 1 auto;
height: 100%;
min-height: 0;
display: flex;
gap: 12px;
@@ -303,6 +312,7 @@ body {
.playground-card--workspace {
flex: 1 1 auto;
height: 100%;
}
.playground-card--workspace .ant-card-body,
@@ -310,6 +320,405 @@ body {
overflow: hidden;
}
.playground-chat {
flex: 1 1 auto;
min-height: 0;
}
.playground-chat .ant-card-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px 18px 18px;
min-height: 0;
overflow: hidden;
}
.playground-chat__messages {
flex: 1 1 auto;
min-height: 0;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
overscroll-behavior: contain;
}
.playground-chat__messages-shell {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.playground-chat__scroll-bottom.ant-btn {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 12px;
width: 38px;
height: 38px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(255, 255, 255, 0.94);
color: #0f172a;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(8px);
}
.playground-chat__scroll-bottom.ant-btn:hover,
.playground-chat__scroll-bottom.ant-btn:focus {
background: #ffffff !important;
color: #0f766e !important;
border-color: rgba(15, 118, 110, 0.2) !important;
}
.playground-chat__composer {
flex: 0 0 auto;
border-top: 1px solid rgba(148, 163, 184, 0.18);
padding-top: 14px;
}
.playground-chat__input-wrap {
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98));
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
padding: 12px;
}
.playground-chat__input.ant-input {
border: 0;
box-shadow: none;
padding: 6px 2px 2px;
resize: none;
background: transparent;
margin-top: 10px;
}
.playground-chat__input.ant-input:focus,
.playground-chat__input.ant-input-focused {
box-shadow: none;
}
.playground-chat__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(148, 163, 184, 0.16);
}
.playground-chat__hints {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.playground-chat__send-button.ant-btn {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #0f172a;
color: #f8fafc;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16);
}
.playground-chat__send-button.ant-btn:hover,
.playground-chat__send-button.ant-btn:focus {
background: #111827 !important;
color: #ffffff !important;
border-color: transparent !important;
}
.playground-chat__send-button--stop.ant-btn {
background: #7f1d1d;
}
.playground-chat__send-button--stop.ant-btn:hover,
.playground-chat__send-button--stop.ant-btn:focus {
background: #991b1b !important;
}
.playground-message {
display: flex;
align-items: flex-end;
gap: 10px;
}
.playground-message--system,
.playground-message--assistant {
justify-content: flex-start;
}
.playground-message--user {
flex-direction: row-reverse;
}
.playground-message__avatar {
flex: 0 0 auto;
}
.playground-message__body {
display: flex;
flex-direction: column;
min-width: 0;
max-width: min(100%, 760px);
}
.playground-message__body--editing {
width: min(100%, 760px);
flex: 0 1 min(100%, 760px);
}
.playground-message__avatar-inner {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14);
}
.playground-message__avatar-inner--assistant,
.playground-message__avatar-inner--system {
background: linear-gradient(135deg, #0f766e, #14b8a6) !important;
color: #f8fafc !important;
}
.playground-message__avatar-inner--user {
background: linear-gradient(135deg, #0f172a, #334155) !important;
color: #f8fafc !important;
}
.playground-message__bubble {
width: 100%;
border-radius: 18px;
padding: 14px 16px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(148, 163, 184, 0.18);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98));
}
.playground-message--assistant .playground-message__bubble {
background:
linear-gradient(180deg, rgba(232, 244, 255, 0.95), rgba(240, 249, 255, 0.98));
border-color: rgba(56, 189, 248, 0.26);
}
.playground-message--system .playground-message__bubble {
background:
linear-gradient(180deg, rgba(255, 250, 235, 0.98), rgba(254, 249, 195, 0.72));
border-color: rgba(245, 158, 11, 0.24);
}
.playground-message--user .playground-message__bubble {
background:
linear-gradient(180deg, rgba(15, 118, 110, 0.94), rgba(13, 148, 136, 0.96));
border-color: rgba(15, 118, 110, 0.28);
color: #f8fafc;
}
.playground-message__bubble--loading {
display: inline-flex;
align-items: center;
gap: 10px;
}
.playground-message__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.playground-message__head .ant-tag {
margin-inline-end: 0;
}
.playground-message__phase {
margin-bottom: 8px;
}
.playground-message__phase .ant-typography {
font-size: 12px;
color: #64748b;
}
.playground-message__thinking {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px dashed rgba(148, 163, 184, 0.35);
background: rgba(255, 255, 255, 0.45);
}
.playground-message__thinking .ant-typography {
display: block;
margin-bottom: 6px;
font-size: 12px;
}
.playground-message--user .playground-message__head .ant-typography,
.playground-message--user .playground-message__content {
color: #f8fafc;
}
.playground-message__content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 1.65;
}
.playground-message__editor {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.playground-message__editor-input,
.playground-message__editor-input.ant-input,
.playground-message__editor-input.ant-input-affix-wrapper,
.playground-message__editor .ant-input-textarea,
.playground-message__editor .ant-input-textarea-show-count {
display: block;
width: 100%;
min-width: 0;
}
.playground-message__editor-input.ant-input,
.playground-message__editor .ant-input-textarea textarea.ant-input {
border-radius: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.94);
color: #0f172a;
width: 100% !important;
min-width: 0;
}
.playground-message__editor-actions {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.playground-message__editor-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
justify-content: flex-start;
flex: 1 1 auto;
}
.playground-message__editor-meta .ant-tag {
margin-inline-end: 0;
}
.playground-message__editor-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
margin-left: auto;
}
.playground-message__editor-buttons .ant-btn {
border-radius: 999px;
padding-inline: 12px;
font-size: 12px;
line-height: 1;
min-width: 56px;
}
.playground-message__editor-buttons .ant-btn-default {
border-color: rgba(148, 163, 184, 0.3);
color: #475569;
background: rgba(255, 255, 255, 0.82);
}
.playground-message__editor-buttons .ant-btn-default:hover,
.playground-message__editor-buttons .ant-btn-default:focus {
color: #0f172a !important;
border-color: rgba(100, 116, 139, 0.34) !important;
background: rgba(255, 255, 255, 0.92) !important;
}
.playground-message__editor-buttons .ant-btn-primary {
border-color: transparent;
background: linear-gradient(135deg, #0f172a, #334155);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
}
.playground-message__editor-buttons .ant-btn-primary:hover,
.playground-message__editor-buttons .ant-btn-primary:focus {
background: linear-gradient(135deg, #020617, #1e293b) !important;
border-color: transparent !important;
}
.playground-message__markdown {
font-size: 13px;
line-height: 1.7;
}
.playground-message__markdown .markdown-renderer {
color: inherit;
}
.playground-message__markdown .markdown-renderer > :last-child {
margin-bottom: 0;
}
.playground-message__markdown .markdown-renderer code {
background: rgba(15, 23, 42, 0.08);
}
.playground-message--assistant .playground-message__markdown .markdown-renderer blockquote {
background: rgba(255, 255, 255, 0.42);
}
.playground-message__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.playground-message__detail-action {
margin-top: 8px;
}
.playground-message__detail-action .ant-btn {
padding-inline: 0;
}
.playground-message__actions {
display: flex;
align-items: center;
gap: 2px;
margin-top: 6px;
padding-inline: 6px;
}
.playground-message__actions .ant-btn {
color: #64748b;
}
.playground-message__actions .ant-btn:hover {
color: #0f172a !important;
background: rgba(148, 163, 184, 0.12) !important;
}
.playground-tabs,
.playground-tabs .ant-tabs-content-holder,
.playground-tabs .ant-tabs-content,
@@ -397,6 +806,30 @@ body {
gap: 8px;
}
.playground-preset-strip--chat {
gap: 8px;
margin-bottom: 14px;
}
.playground-preset-strip__actions .ant-tag-checkable {
margin-inline-end: 0;
margin-bottom: 4px;
border-radius: 999px;
padding: 4px 12px;
}
.playground-preset-strip__actions .ant-tag-checkable-checked {
background: linear-gradient(135deg, #0f766e, #14b8a6);
color: #f8fafc;
border-color: transparent;
}
.playground-preset-strip__actions .ant-tag-checkable:not(.ant-tag-checkable-checked) {
background: rgba(15, 118, 110, 0.08);
border-color: rgba(15, 118, 110, 0.16);
color: #0f766e;
}
.playground-form__actions {
display: flex;
gap: 12px;
@@ -534,6 +967,11 @@ body {
gap: 12px;
}
.playground-service-modal__body {
display: grid;
gap: 12px;
}
.playground-note.ant-alert {
padding: 10px 12px;
}
@@ -621,6 +1059,21 @@ body {
gap: 12px;
}
.playground-chat__messages::-webkit-scrollbar {
width: 8px;
}
.playground-chat__messages::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.playground-chat__messages::-webkit-scrollbar-track {
background: transparent;
}
.playground-result__blocks-head {
display: flex;
align-items: center;
@@ -638,6 +1091,52 @@ body {
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-result__blocks-scroll--raw {
max-height: 240px;
}
.playground-result-modal .ant-modal-content {
overflow: hidden;
}
.playground-result-modal__body {
display: flex;
flex-direction: column;
gap: 14px;
height: min(76vh, 760px);
min-height: 0;
}
.playground-result-modal__head {
flex: 0 0 auto;
display: grid;
gap: 12px;
}
.playground-result-modal__content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding-right: 6px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-result-modal__content::-webkit-scrollbar {
width: 8px;
}
.playground-result-modal__content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.playground-result-modal__content::-webkit-scrollbar-track {
background: transparent;
}
@media (max-width: 1200px) {
.playground-page__header {
align-items: flex-start;
@@ -652,9 +1151,7 @@ body {
}
.playground-shell__sidebar {
flex: 0 0 auto;
min-width: 0;
max-width: none;
display: none;
}
.playground-result__meta,
@@ -662,6 +1159,23 @@ body {
align-items: stretch;
flex-direction: column;
}
.playground-chat__actions {
align-items: center;
flex-direction: row;
justify-content: space-between;
gap: 10px;
}
.playground-chat__hints {
flex: 1 1 auto;
min-width: 0;
}
.playground-chat__send-button.ant-btn {
margin-left: auto;
flex: 0 0 auto;
}
}
@@ -1072,7 +1586,71 @@ body {
line-height: 1.5;
}
.alerts-page__body {
overflow: hidden;
}
.alerts-page__tabs,
.alerts-page__tabs .ant-tabs-content-holder,
.alerts-page__tabs .ant-tabs-content,
.alerts-page__tabs .ant-tabs-tabpane {
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
.alerts-page__tabs {
display: flex;
flex-direction: column;
}
.alerts-page__tabs .ant-tabs-nav {
flex: 0 0 auto;
margin-bottom: 8px;
}
.alerts-page__tabs .ant-tabs-content-holder {
flex: 1 1 auto;
overflow: hidden;
}
.alerts-page__tabs .ant-tabs-tabpane {
display: flex;
flex-direction: column;
overflow: hidden;
}
.alerts-tab-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.alerts-tab-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.alerts-tab-panel__subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 960px) {
.alerts-tab-panel__head {
flex-direction: column;
align-items: stretch;
}
.bgp-page__brief-head {
flex-direction: column;
}
@@ -1100,6 +1678,22 @@ body {
margin-top: 14px;
}
.bgp-page__brief-facts {
display: grid;
gap: 8px;
}
.bgp-page__brief-fact-list {
display: grid;
gap: 8px;
}
.bgp-page__brief-fact-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.bgp-page__brief-meta .ant-descriptions-view {
background: #f7f8fa;
border-radius: 12px;
@@ -1107,6 +1701,8 @@ body {
}
.bgp-page__brief-modal-body {
display: grid;
gap: 14px;
max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 6px;
@@ -1114,6 +1710,89 @@ body {
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.bgp-page__brief-evidence {
display: grid;
gap: 12px;
}
.bgp-page__brief-evidence-card {
border-radius: 12px;
}
.alerts-brief-drawer {
display: flex;
flex-direction: column;
gap: 12px;
}
.alerts-brief-drawer__loading {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
.alerts-brief-fact {
display: flex;
align-items: flex-start;
gap: 8px;
}
.alerts-brief-content {
white-space: pre-wrap;
margin-bottom: 0 !important;
}
.system-alerts-page__body,
.bgp-alerts-page__body,
.situational-alerts-page__body {
overflow: hidden;
}
.system-alerts-page__stack,
.bgp-alerts-page__stack,
.situational-alerts-page__stack {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.system-alerts-page__table-card,
.bgp-alerts-page__table-card {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.system-alerts-page__table-card .ant-card-body,
.bgp-alerts-page__table-card .ant-card-body {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.system-alerts-page__table-region,
.bgp-alerts-page__table-region {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.bgp-alerts-page__tabs,
.bgp-alerts-page__tabs .ant-tabs-content-holder,
.bgp-alerts-page__tabs .ant-tabs-content,
.bgp-alerts-page__tabs .ant-tabs-tabpane {
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
.bgp-page__brief-modal {
max-width: min(920px, calc(100vw - 32px));
}

View File

@@ -1,221 +1,66 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd'
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useMemo } from 'react'
interface Alert {
id: number
severity: 'critical' | 'warning' | 'info'
status: 'active' | 'acknowledged' | 'resolved'
datasource_name: string
message: string
created_at: string
acknowledged_at?: string
resolved_at?: string
}
import { AlertOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons'
import { Tabs, Typography } from 'antd'
import { useSearchParams } from 'react-router-dom'
import AppLayout from '../../components/AppLayout/AppLayout'
import { BGPAlertsPanel } from './BGPAlerts'
import { SituationalAlertsPanel } from './SituationalAlerts'
import { SystemAlertsPanel } from './SystemAlerts'
const { Title, Text } = Typography
const ALERT_TABS = [
{
key: 'system',
label: '系统告警',
icon: <AlertOutlined />,
children: <SystemAlertsPanel />,
},
{
key: 'bgp',
label: 'BGP 告警',
icon: <DeploymentUnitOutlined />,
children: <BGPAlertsPanel />,
},
{
key: 'situational',
label: '态势告警',
icon: <RadarChartOutlined />,
children: <SituationalAlertsPanel />,
},
]
function Alerts() {
const { token } = useAuthStore()
const [alerts, setAlerts] = useState<Alert[]>([])
const [loading, setLoading] = useState(false)
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null)
const [detailVisible, setDetailVisible] = useState(false)
const fetchAlerts = async () => {
setLoading(true)
try {
const res = await fetch('/api/v1/alerts', {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
setAlerts(data.data || [])
} catch (error) {
console.error('Failed to fetch alerts:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAlerts()
}, [token])
const handleAcknowledge = async (alertId: number) => {
try {
await fetch(`/api/v1/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
fetchAlerts()
} catch (error) {
console.error('Failed to acknowledge alert:', error)
}
}
const handleResolve = async (alertId: number) => {
try {
await fetch(`/api/v1/alerts/${alertId}/resolve`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ resolution: '已处理' }),
})
fetchAlerts()
} catch (error) {
console.error('Failed to resolve alert:', error)
}
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{
title: '级别',
dataIndex: 'severity',
key: 'severity',
render: (s: string) => {
const colors: Record<string, string> = { critical: 'error', warning: 'warning', info: 'blue' }
const icons: Record<string, JSX.Element> = {
critical: <AlertOutlined />,
warning: <AlertOutlined />,
info: <InfoCircleOutlined />,
}
return (
<Tag color={colors[s]} icon={icons[s]}>
{s === 'critical' ? '严重' : s === 'warning' ? '警告' : '信息'}
</Tag>
)
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (s: string) => {
const colors: Record<string, string> = { active: 'red', acknowledged: 'orange', resolved: 'green' }
return (
<Tag color={colors[s]}>
{s === 'active' ? '待处理' : s === 'acknowledged' ? '已确认' : '已解决'}
</Tag>
)
},
},
{ title: '数据源', dataIndex: 'datasource_name', key: 'datasource_name' },
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true },
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
render: (t: string) => formatDateTimeZhCN(t),
},
{
title: '操作',
key: 'action',
render: (_: unknown, record: Alert) => (
<Space>
{record.status === 'active' && (
<Button type="link" size="small" onClick={() => handleAcknowledge(record.id)}>
</Button>
)}
{record.status !== 'resolved' && (
<Button type="link" size="small" onClick={() => handleResolve(record.id)}>
</Button>
)}
<Button type="link" size="small" onClick={() => { setSelectedAlert(record); setDetailVisible(true); }}>
</Button>
</Space>
),
},
]
const stats = alerts.reduce(
(acc, alert) => {
if (alert.status === 'active') {
acc[alert.severity]++
}
return acc
},
{ critical: 0, warning: 0, info: 0 } as Record<string, number>
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab') || 'system'
const activeTab = useMemo(
() => (ALERT_TABS.some((item) => item.key === requestedTab) ? requestedTab : 'system'),
[requestedTab],
)
return (
<AppLayout>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic
title="严重告警"
value={stats.critical}
valueStyle={{ color: '#ff4d4f' }}
prefix={<AlertOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="警告"
value={stats.warning}
valueStyle={{ color: '#faad14' }}
prefix={<AlertOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} />
</Card>
</Col>
</Row>
<Card
title="告警列表"
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}></Button>}
>
<div className="table-scroll-region">
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
<div className="page-shell alerts-page">
<div className="page-shell__header alerts-page__header">
<div>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary">
BGP AI Tab
</Text>
</div>
</div>
</Card>
<Modal
title="告警详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={null}
width={600}
>
{selectedAlert && (
<Descriptions column={1} bordered>
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
<Descriptions.Item label="级别">
<Tag color={selectedAlert.severity === 'critical' ? 'error' : selectedAlert.severity === 'warning' ? 'warning' : 'blue'}>
{selectedAlert.severity}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedAlert.status === 'active' ? 'red' : selectedAlert.status === 'acknowledged' ? 'orange' : 'green'}>
{selectedAlert.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
{selectedAlert.acknowledged_at && (
<Descriptions.Item label="确认时间">
{formatDateTimeZhCN(selectedAlert.acknowledged_at)}
</Descriptions.Item>
)}
{selectedAlert.resolved_at && (
<Descriptions.Item label="解决时间">
{formatDateTimeZhCN(selectedAlert.resolved_at)}
</Descriptions.Item>
)}
</Descriptions>
)}
</Modal>
<div className="page-shell__body alerts-page__body">
<Tabs
className="alerts-page__tabs"
activeKey={activeTab}
onChange={(key) => setSearchParams({ tab: key })}
items={ALERT_TABS}
/>
</div>
</div>
</AppLayout>
)
}

View File

@@ -0,0 +1,264 @@
import { useEffect, useMemo, useState } from 'react'
import { ReloadOutlined, RobotOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Modal,
Row,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
type TableColumnsType,
} from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout'
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Text } = Typography
const gateway = getSituationalAwarenessGateway()
function severityColor(severity: string) {
if (severity === 'critical') return 'red'
if (severity === 'high') return 'orange'
if (severity === 'medium') return 'gold'
return 'blue'
}
export function BGPAlertsPanel() {
const [messageApi, contextHolder] = message.useMessage()
const [loading, setLoading] = useState(false)
const [incidents, setIncidents] = useState<BGPIncident[]>([])
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
const [briefLoading, setBriefLoading] = useState(false)
const [briefModalOpen, setBriefModalOpen] = useState(false)
const [brief, setBrief] = useState<BGPBriefRecord | null>(null)
const loadData = async () => {
setLoading(true)
try {
const [incidentRows, anomalyRows] = await Promise.all([
gateway.getBGPIncidents(50),
gateway.getBGPAnomalies(80),
])
setIncidents(incidentRows)
setAnomalies(anomalyRows)
} catch (error) {
console.error('Failed to load BGP alerts:', error)
messageApi.error('BGP 告警加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadData()
}, [])
const summary = useMemo(
() => ({
activeIncidents: incidents.filter((item) => item.status === 'active').length,
criticalIncidents: incidents.filter((item) => item.severity === 'critical').length,
activeAnomalies: anomalies.filter((item) => item.status === 'active').length,
highRiskAnomalies: anomalies.filter((item) => ['critical', 'high'].includes(item.severity)).length,
}),
[anomalies, incidents],
)
const handleGenerateBrief = async () => {
setBriefModalOpen(true)
setBriefLoading(true)
try {
const record = await gateway.generateBGPBrief()
setBrief(record)
messageApi.success('BGP AI 简报已生成')
} catch (error) {
console.error('Failed to generate BGP brief:', error)
messageApi.error('BGP AI 简报生成失败')
} finally {
setBriefLoading(false)
}
}
const incidentColumns: TableColumnsType<BGPIncident> = [
{
title: '开始时间',
dataIndex: 'started_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{ title: '类型', dataIndex: 'incident_type', width: 180 },
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
},
{
title: '影响前缀',
dataIndex: 'affected_prefixes',
width: 220,
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
},
{ title: '摘要', dataIndex: 'summary', width: 320 },
]
const anomalyColumns: TableColumnsType<BGPAnomaly> = [
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{ title: '类型', dataIndex: 'anomaly_type', width: 180 },
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
},
{
title: '前缀',
dataIndex: 'prefix',
width: 200,
render: (value: string | null) => value || '-',
},
{ title: '摘要', dataIndex: 'summary', width: 320 },
]
return (
<AppLayout>
<div className="alerts-tab-panel bgp-alerts-page">
{contextHolder}
<Space className="bgp-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
<div className="alerts-tab-panel__head">
<div>
<Text strong>BGP </Text>
<div className="alerts-tab-panel__subtitle"> BGP incidents anomalies </div>
</div>
<Space>
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
BGP AI
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>
</Button>
</Space>
</div>
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
<Row gutter={[12, 12]}>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
</Col>
</Row>
<Card className="bgp-alerts-page__table-card">
<Tabs
className="bgp-alerts-page__tabs"
items={[
{
key: 'incidents',
label: 'BGP 事件',
children: (
<div className="table-scroll-region bgp-alerts-page__table-region">
<Table<BGPIncident>
columns={incidentColumns}
dataSource={incidents}
loading={loading}
pagination={false}
rowKey="id"
scroll={{ x: 1200, y: 480 }}
tableLayout="fixed"
/>
</div>
),
},
{
key: 'anomalies',
label: 'BGP 异常',
children: (
<div className="table-scroll-region bgp-alerts-page__table-region">
<Table<BGPAnomaly>
columns={anomalyColumns}
dataSource={anomalies}
loading={loading}
pagination={false}
rowKey="id"
scroll={{ x: 1100, y: 480 }}
tableLayout="fixed"
/>
</div>
),
},
]}
/>
</Card>
</Space>
<Modal
title="BGP AI 简报"
open={briefModalOpen}
onCancel={() => setBriefModalOpen(false)}
footer={null}
width={920}
className="bgp-page__brief-modal"
style={{ top: 24 }}
styles={{ body: { paddingTop: 12 } }}
>
{briefLoading ? (
<div className="bgp-page__brief-loading">
<Spin tip="正在生成 BGP AI 简报..." />
</div>
) : brief ? (
<div className="bgp-page__brief-modal-body">
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
</Descriptions>
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
</div>
) : (
<Text type="secondary"> BGP </Text>
)}
</Modal>
</div>
</AppLayout>
)
}
export default BGPAlertsPanel

View File

@@ -0,0 +1,202 @@
import { useEffect, useMemo, useState } from 'react'
import { DeploymentUnitOutlined, ReloadOutlined, RobotOutlined, WarningOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Drawer,
Row,
Space,
Spin,
Statistic,
Typography,
message,
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
import {
getSituationalAwarenessGateway,
type SituationalAlertBriefResponse,
} from '../../services/situational-awareness'
const { Text } = Typography
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
const gateway = getSituationalAwarenessGateway()
interface AlertStatsResponse {
critical: number
warning: number
info: number
}
export function SituationalAlertsPanel() {
const [messageApi, contextHolder] = message.useMessage()
const [systemStats, setSystemStats] = useState<AlertStatsResponse | null>(null)
const [bgpSummary, setBgpSummary] = useState<BGPSummarySnapshot | null>(null)
const [loading, setLoading] = useState(false)
const [briefOpen, setBriefOpen] = useState(false)
const [briefLoading, setBriefLoading] = useState(false)
const [briefError, setBriefError] = useState<string | null>(null)
const [briefResult, setBriefResult] = useState<SituationalAlertBriefResponse | null>(null)
const loadOverview = async () => {
setLoading(true)
try {
const [alertStatsResponse, bgpSummaryResponse] = await Promise.all([
axios.get<AlertStatsResponse>(`${API_BASE_URL}/alerts/stats`),
gateway.getBGPSummary(),
])
setSystemStats(alertStatsResponse.data)
setBgpSummary(bgpSummaryResponse)
} catch (error) {
console.error('Failed to load situational alerts overview:', error)
messageApi.error('态势告警概览加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadOverview()
}, [])
const summary = useMemo(
() => ({
activeSystemAlerts: (systemStats?.critical || 0) + (systemStats?.warning || 0) + (systemStats?.info || 0),
criticalSystemAlerts: systemStats?.critical || 0,
activeBGPIncidents: bgpSummary?.incidentSummary?.by_status?.active || 0,
criticalBGPIncidents: bgpSummary?.incidentSummary?.by_severity?.critical || 0,
}),
[bgpSummary, systemStats],
)
const handleGenerateBrief = async () => {
setBriefOpen(true)
setBriefLoading(true)
setBriefError(null)
try {
const response = await axios.post<SituationalAlertBriefResponse>(`${API_BASE_URL}/ai/situational-alerts/brief`, {})
setBriefResult(response.data)
} catch (error) {
console.error('Failed to generate situational alert brief:', error)
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setBriefError(detail || '生成态势告警 AI 简报失败')
messageApi.error('生成态势告警 AI 简报失败')
} finally {
setBriefLoading(false)
}
}
return (
<AppLayout>
<div className="alerts-tab-panel situational-alerts-page">
{contextHolder}
<Space className="situational-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
<div className="alerts-tab-panel__head">
<div>
<Text strong></Text>
<div className="alerts-tab-panel__subtitle"> BGP </div>
</div>
<Space>
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
AI
</Button>
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadOverview()}>
</Button>
</Space>
</div>
<Alert
type="info"
showIcon
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
/>
<Row gutter={[12, 12]}>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={24} lg={12}>
<Card title="系统告警侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="BGP 风险侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
</Space>
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
<div className="alerts-brief-drawer">
{briefLoading ? (
<div className="alerts-brief-drawer__loading">
<Spin tip="正在生成态势告警 AI 简报..." />
</div>
) : null}
{!briefLoading && briefError ? (
<Alert type="error" showIcon message="态势告警 AI 简报生成失败" description={briefError} />
) : null}
{!briefLoading && briefResult ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
<Descriptions.Item label="活跃系统告警">{String(briefResult.context.active_system_alerts ?? '-')}</Descriptions.Item>
<Descriptions.Item label="活跃 BGP 事件">{String(briefResult.context.active_bgp_incidents ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="事实输入">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{briefResult.facts.map((fact, index) => (
<div key={`${index}-${fact}`} className="alerts-brief-fact">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</Space>
</Card>
<Card size="small" title="AI 简报">
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
</Card>
</Space>
) : null}
</div>
</Drawer>
</div>
</AppLayout>
)
}
export default SituationalAlertsPanel

View File

@@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Drawer,
Modal,
Row,
Space,
Spin,
Statistic,
Table,
Tag,
Typography,
message,
type TableColumnsType,
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Text } = Typography
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
function renderAlertSeverityTag(value: AlertRecord['severity']) {
const colorMap = { critical: 'error', warning: 'warning', info: 'blue' }
const labelMap = { critical: '严重', warning: '警告', info: '信息' }
const iconMap = {
critical: <AlertOutlined />,
warning: <AlertOutlined />,
info: <InfoCircleOutlined />,
}
return (
<Tag color={colorMap[value]} icon={iconMap[value]}>
{labelMap[value]}
</Tag>
)
}
function renderAlertStatusTag(value: AlertRecord['status']) {
const colorMap = { active: 'red', acknowledged: 'orange', resolved: 'green' }
const labelMap = { active: '待处理', acknowledged: '已确认', resolved: '已解决' }
return <Tag color={colorMap[value]}>{labelMap[value]}</Tag>
}
export function SystemAlertsPanel() {
const [messageApi, contextHolder] = message.useMessage()
const [alerts, setAlerts] = useState<AlertRecord[]>([])
const [loading, setLoading] = useState(false)
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null)
const [detailVisible, setDetailVisible] = useState(false)
const [briefOpen, setBriefOpen] = useState(false)
const [briefLoading, setBriefLoading] = useState(false)
const [briefError, setBriefError] = useState<string | null>(null)
const [briefResult, setBriefResult] = useState<AlertBriefResponse | null>(null)
const fetchAlerts = async () => {
setLoading(true)
try {
const response = await axios.get<{ data: AlertRecord[] }>(`${API_BASE_URL}/alerts`)
setAlerts(response.data.data || [])
} catch (error) {
console.error('Failed to fetch system alerts:', error)
messageApi.error('系统告警加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchAlerts()
}, [])
const stats = useMemo(
() =>
alerts.reduce(
(accumulator, item) => {
if (item.status === 'active') {
accumulator[item.severity] += 1
}
return accumulator
},
{ critical: 0, warning: 0, info: 0 } as Record<AlertRecord['severity'], number>,
),
[alerts],
)
const handleAcknowledge = async (alertId: number) => {
try {
await axios.post(`${API_BASE_URL}/alerts/${alertId}/acknowledge`)
messageApi.success('告警已确认')
await fetchAlerts()
} catch (error) {
console.error('Failed to acknowledge alert:', error)
messageApi.error('确认告警失败')
}
}
const handleResolve = async (alertId: number) => {
try {
await axios.post(`${API_BASE_URL}/alerts/${alertId}/resolve`, { resolution: '已处理' })
messageApi.success('告警已解决')
await fetchAlerts()
} catch (error) {
console.error('Failed to resolve alert:', error)
messageApi.error('解决告警失败')
}
}
const handleGenerateBrief = async () => {
setBriefOpen(true)
setBriefLoading(true)
setBriefError(null)
try {
const response = await axios.post<AlertBriefResponse>(`${API_BASE_URL}/ai/alerts/brief`, {})
setBriefResult(response.data)
} catch (error) {
console.error('Failed to generate system alert brief:', error)
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setBriefError(detail || '生成系统告警 AI 简报失败')
messageApi.error('生成系统告警 AI 简报失败')
} finally {
setBriefLoading(false)
}
}
const columns: TableColumnsType<AlertRecord> = [
{ title: 'ID', dataIndex: 'id', width: 72 },
{
title: '级别',
dataIndex: 'severity',
width: 108,
render: (value: AlertRecord['severity']) => renderAlertSeverityTag(value),
},
{
title: '状态',
dataIndex: 'status',
width: 108,
render: (value: AlertRecord['status']) => renderAlertStatusTag(value),
},
{
title: '数据源',
dataIndex: 'datasource_name',
width: 180,
render: (value: string | null) => value || '-',
},
{
title: '消息',
dataIndex: 'message',
ellipsis: true,
},
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (value: string) => formatDateTimeZhCN(value),
},
{
title: '操作',
key: 'action',
width: 180,
render: (_, record) => (
<Space size={4}>
{record.status === 'active' ? (
<Button type="link" size="small" onClick={() => void handleAcknowledge(record.id)}>
</Button>
) : null}
{record.status !== 'resolved' ? (
<Button type="link" size="small" onClick={() => void handleResolve(record.id)}>
</Button>
) : null}
<Button
type="link"
size="small"
onClick={() => {
setSelectedAlert(record)
setDetailVisible(true)
}}
>
</Button>
</Space>
),
},
]
return (
<AppLayout>
<div className="alerts-tab-panel system-alerts-page">
{contextHolder}
<Space className="system-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
<div className="alerts-tab-panel__head">
<div>
<Text strong></Text>
<div className="alerts-tab-panel__subtitle"></div>
</div>
<Space>
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
AI
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void fetchAlerts()}>
</Button>
</Space>
</div>
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
<Row gutter={[12, 12]}>
<Col xs={24} sm={8}>
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
</Col>
</Row>
<Card className="system-alerts-page__table-card" title="系统告警列表">
<div className="table-scroll-region system-alerts-page__table-region">
<Table<AlertRecord>
columns={columns}
dataSource={alerts}
loading={loading}
pagination={{ pageSize: 10 }}
rowKey="id"
scroll={{ x: 1100, y: 480 }}
tableLayout="fixed"
/>
</div>
</Card>
</Space>
<Modal title="告警详情" open={detailVisible} onCancel={() => setDetailVisible(false)} footer={null} width={640}>
{selectedAlert ? (
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
<Descriptions.Item label="级别">{renderAlertSeverityTag(selectedAlert.severity)}</Descriptions.Item>
<Descriptions.Item label="状态">{renderAlertStatusTag(selectedAlert.status)}</Descriptions.Item>
<Descriptions.Item label="数据源">{selectedAlert.datasource_name || '-'}</Descriptions.Item>
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
<Descriptions.Item label="确认时间">{formatDateTimeZhCN(selectedAlert.acknowledged_at || null)}</Descriptions.Item>
<Descriptions.Item label="解决时间">{formatDateTimeZhCN(selectedAlert.resolved_at || null)}</Descriptions.Item>
<Descriptions.Item label="处理说明">{selectedAlert.resolution_notes || '-'}</Descriptions.Item>
</Descriptions>
) : null}
</Modal>
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
<div className="alerts-brief-drawer">
{briefLoading ? (
<div className="alerts-brief-drawer__loading">
<Spin tip="正在汇总系统告警事实并生成简报..." />
</div>
) : null}
{!briefLoading && briefError ? (
<Alert type="error" showIcon message="系统告警 AI 简报生成失败" description={briefError} />
) : null}
{!briefLoading && briefResult ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
<Descriptions.Item label="待处理告警">{String(briefResult.context.active_alerts ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="事实输入">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{briefResult.facts.map((fact, index) => (
<div key={`${index}-${fact}`} className="alerts-brief-fact">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</Space>
</Card>
<Card size="small" title="AI 简报">
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
</Card>
</Space>
) : null}
</div>
</Drawer>
</div>
</AppLayout>
)
}
export default SystemAlertsPanel

View File

@@ -66,6 +66,19 @@ function sortBriefRecords<T extends BGPBriefRecordSummary>(records: T[]) {
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
}
function renderBriefContextValue(value: unknown) {
if (value === null || value === undefined) return '-'
if (Array.isArray(value)) {
return value.length > 0 ? JSON.stringify(value) : '-'
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return '-'
return entries.map(([key, count]) => `${key}: ${String(count)}`).join('')
}
return String(value)
}
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
return [record.city, record.country].filter(Boolean).join(', ') || '-'
}
@@ -574,7 +587,23 @@ function BGP() {
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
{formatDateTimeZhCN(brief.generated_at)}
</Descriptions.Item>
<Descriptions.Item label="事实条目">{brief.facts.length}</Descriptions.Item>
<Descriptions.Item label="Incident 总数">{renderBriefContextValue(brief.context.incident_total)}</Descriptions.Item>
<Descriptions.Item label="活跃观测站">{renderBriefContextValue(brief.context.active_collectors)}</Descriptions.Item>
</Descriptions>
{brief.facts.length > 0 ? (
<div className="bgp-page__brief-facts">
<Text strong></Text>
<div className="bgp-page__brief-fact-list">
{brief.facts.slice(0, 3).map((fact, index) => (
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</div>
</div>
) : null}
</div>
) : (
<div className="bgp-page__brief-empty">
@@ -727,6 +756,34 @@ function BGP() {
>
{brief ? (
<div className="bgp-page__brief-modal-body">
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
<div className="bgp-page__brief-evidence">
{brief.facts.length > 0 ? (
<Card size="small" title="事实输入快照" className="bgp-page__brief-evidence-card">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{brief.facts.map((fact, index) => (
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</Space>
</Card>
) : null}
{Object.keys(brief.context || {}).length > 0 ? (
<Card size="small" title="结构化上下文" className="bgp-page__brief-evidence-card">
<Descriptions size="small" column={1}>
{Object.entries(brief.context).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{renderBriefContextValue(value)}
</Descriptions.Item>
))}
</Descriptions>
</Card>
) : null}
</div>
) : null}
<MarkdownRenderer markdown={brief.content_markdown} />
</div>
) : (

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import type { SituationalAwarenessGateway } from './port'
import { HttpSituationalAwarenessGateway } from './http-gateway'
import { MockSituationalAwarenessGateway } from './mock-gateway'
export * from './types'
export type { SituationalAwarenessGateway } from './port'
@@ -8,10 +7,6 @@ export type { SituationalAwarenessGateway } from './port'
let singleton: SituationalAwarenessGateway | null = null
export function createSituationalAwarenessGateway(): SituationalAwarenessGateway {
const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http'
if (provider === 'mock') {
return new MockSituationalAwarenessGateway()
}
return new HttpSituationalAwarenessGateway()
}

View File

@@ -1,103 +0,0 @@
import type { SituationalAwarenessGateway } from './port'
import type {
BGPBriefRecord,
BGPBriefRecordSummary,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
} from './types'
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
incidents: [],
incidentSummary: {
total: 0,
by_type: {},
by_severity: {},
by_status: {},
},
anomalies: [],
events: [],
eventSummary: {
total: 0,
collector_count: 0,
prefix_count: 0,
by_type: {},
},
collectors: [],
collectorSummary: {
total: 0,
active_collectors: 0,
observed_prefixes: 0,
observed_origins: 0,
recent_24h_events: 0,
recent_7d_events: 0,
},
}
export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
private readonly brief: BGPBriefRecord = {
id: 'mock-bgp-brief-001',
title: 'BGP AI 简报',
provider: 'mock',
model: 'mock-brief',
request_id: 'mock-bgp-brief',
generated_at: '2026-04-09T12:00:00+08:00',
content_markdown: [
'# BGP 态势简报',
'',
'## 当前判断',
'',
'- 当前 BGP 态势以高严重度事件为主。',
'- 建议优先核查活跃 incidents 涉及的受影响前缀与重点观测站。',
'',
'## 值班建议',
'',
'1. 先确认高严重度 incident 是否持续活跃。',
'2. 对照重点 collector 的近 24h 波动,避免将控制平面噪声误判为真实业务中断。',
].join('\n'),
}
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
return EMPTY_SNAPSHOT
}
async getBGPSummary(): Promise<BGPSummarySnapshot> {
return {
incidentSummary: EMPTY_SNAPSHOT.incidentSummary,
eventSummary: EMPTY_SNAPSHOT.eventSummary,
collectorSummary: EMPTY_SNAPSHOT.collectorSummary,
}
}
async getBGPCollectors() {
return EMPTY_SNAPSHOT.collectors
}
async getBGPIncidents() {
return EMPTY_SNAPSHOT.incidents
}
async getBGPAnomalies() {
return EMPTY_SNAPSHOT.anomalies
}
async getBGPEvents() {
return EMPTY_SNAPSHOT.events
}
async generateBGPBrief(): Promise<BGPBriefRecord> {
return this.brief
}
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
return [this.brief]
}
async getBGPBrief(_briefId: string): Promise<BGPBriefRecord> {
return this.brief
}
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
return this.brief
}
}

View File

@@ -144,4 +144,47 @@ export interface BGPBriefRecordSummary {
export interface BGPBriefRecord extends BGPBriefRecordSummary {
content_markdown: string
facts: string[]
context: Record<string, unknown>
}
export interface AlertRecord {
id: number
severity: 'critical' | 'warning' | 'info'
status: 'active' | 'acknowledged' | 'resolved'
datasource_name: string | null
message: string
created_at: string
acknowledged_at?: string | null
resolved_at?: string | null
alert_metadata?: string | null
resolution_notes?: string | null
}
export interface AlertBriefResponse {
provider: string
model: string
content: string
content_blocks: AIContentBlock[]
text_blocks: string[]
thinking_blocks: string[]
raw_response: Record<string, unknown>
title: string
objective: string
facts: string[]
context: Record<string, unknown>
}
export interface SituationalAlertBriefResponse {
provider: string
model: string
content: string
content_blocks: AIContentBlock[]
text_blocks: string[]
thinking_blocks: string[]
raw_response: Record<string, unknown>
title: string
objective: string
facts: string[]
context: Record<string, unknown>
}