feat: ship persistent ai playground and alerts foundation
This commit is contained in:
@@ -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)
|
||||
|
||||
## 前端页面布局规范
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
backend/app/models/playground_message.py
Normal file
40
backend/app/models/playground_message.py
Normal 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}>"
|
||||
27
backend/app/models/playground_session.py
Normal file
27
backend/app/models/playground_session.py
Normal 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}>"
|
||||
@@ -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)
|
||||
|
||||
5
backend/app/schemas/alert.py
Normal file
5
backend/app/schemas/alert.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AlertResolutionRequest(BaseModel):
|
||||
resolution: str = Field(..., min_length=1, max_length=1000)
|
||||
103
backend/app/services/alert_ai_brief.py
Normal file
103
backend/app/services/alert_ai_brief.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
678
backend/app/services/playground_chat_service.py
Normal file
678
backend/app/services/playground_chat_service.py
Normal 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)
|
||||
72
backend/app/services/playground_session_store.py
Normal file
72
backend/app/services/playground_session_store.py
Normal 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)
|
||||
174
backend/app/services/situational_alert_ai_brief.py
Normal file
174
backend/app/services/situational_alert_ai_brief.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
309
docs/situational-awareness-foundation-plan.md
Normal file
309
docs/situational-awareness-foundation-plan.md
Normal 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 2:Evidence-first Assessment
|
||||
|
||||
目标:
|
||||
|
||||
- 从“文本摘要”升级成“结构化 assessment”
|
||||
|
||||
工作项:
|
||||
|
||||
- 为 brief/assessment 定义统一 schema
|
||||
- 固化:
|
||||
- summary
|
||||
- key_risks
|
||||
- evidence
|
||||
- confidence
|
||||
- recommendations
|
||||
- missing_data
|
||||
- 页面以结构化区块展示,而不只是大段文本
|
||||
|
||||
完成标准:
|
||||
|
||||
- AI 输出可持久化、可比较、可审计
|
||||
|
||||
## Phase 3:多维证据接入
|
||||
|
||||
目标:
|
||||
|
||||
- 让“态势感知”真正拥有更多维度,而不是只靠 BGP 与系统告警
|
||||
|
||||
优先接入方向:
|
||||
|
||||
- datasource health findings
|
||||
- 流量或业务指标
|
||||
- 区域/资产/链路映射
|
||||
- 外部事件与公告
|
||||
- 业务垂直数据,例如电商分析相关指标
|
||||
|
||||
完成标准:
|
||||
|
||||
- AI 能基于多个维度做交叉说明
|
||||
- 不再只围绕单一模块自说自话
|
||||
|
||||
## Phase 4:Correlation Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 不同来源的信号不再只是并列,而是形成统一的事件关联
|
||||
|
||||
工作项:
|
||||
|
||||
- 统一 signal/finding 模型
|
||||
- 跨模块事件聚合
|
||||
- 证据来源权重
|
||||
- collector bias 与真实热度分离
|
||||
|
||||
完成标准:
|
||||
|
||||
- 系统能回答“这些异常是不是同一件事”
|
||||
- 系统能回答“哪些结论只是观测偏差”
|
||||
|
||||
## Phase 5:Agent-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` 是核心服务底座
|
||||
|
||||
现阶段不需要追求“已经具备完整态势感知能力”。
|
||||
|
||||
现阶段真正的成功标准是:
|
||||
|
||||
- 这套底座可用
|
||||
- 可回看
|
||||
- 可扩展
|
||||
- 不自欺欺人
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal file
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal 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
|
||||
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal file
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal 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
|
||||
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal file
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal 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
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user