feat: ship persistent ai playground and alerts foundation

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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