feat: 展示 AI 分析过程
This commit is contained in:
+94
-50
@@ -1,4 +1,5 @@
|
||||
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果"""
|
||||
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果 (支持流式输出)"""
|
||||
import json
|
||||
import httpx
|
||||
from backend.config import AI_API_BASE, AI_API_KEY, AI_MODEL
|
||||
|
||||
@@ -13,68 +14,111 @@ SYSTEM_PROMPT = """你是一位资深的 Kubernetes 运维专家和 SRE 工程
|
||||
|
||||
输出格式:使用清晰的 Markdown,包含标题、列表、代码块。语言使用中文。"""
|
||||
|
||||
_HEADERS = {
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def analyze_with_ai(report: str, question: str = "") -> dict:
|
||||
"""调用 AI 分析诊断报告"""
|
||||
|
||||
async def _stream_chat(messages: list[dict]):
|
||||
"""通用流式请求 - yield 文本 chunk"""
|
||||
# connect=10s 快速失败; read=90s 防止 AI 服务无响应时前端无限卡死
|
||||
timeout = httpx.Timeout(90, connect=10)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers=_HEADERS,
|
||||
json={
|
||||
"model": AI_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
"stream": True,
|
||||
},
|
||||
) as resp:
|
||||
# 非 2xx 直接抛异常 (由上层转成 error 事件)
|
||||
resp.raise_for_status()
|
||||
|
||||
# 有些服务认证失败时返回 200 + JSON 错误体 (非 SSE),需要识别
|
||||
ctype = resp.headers.get("content-type", "")
|
||||
if "text/event-stream" not in ctype and "application/json" in ctype:
|
||||
body = await resp.aread()
|
||||
try:
|
||||
err = json.loads(body.decode())
|
||||
raise httpx.HTTPStatusError(
|
||||
f"AI 服务返回错误: {err.get('error', body.decode()[:300])}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
raise httpx.HTTPStatusError(
|
||||
f"AI 服务返回非流式响应: {body.decode()[:300]}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data_str = line[6:].strip()
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
yield content
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
continue
|
||||
|
||||
|
||||
async def analyze_with_ai_stream(report: str, question: str = ""):
|
||||
"""流式 AI 分析诊断报告 - yield 文本 chunk"""
|
||||
user_msg = f"以下是 K8S 集群诊断报告,请进行详细分析:\n\n{report}"
|
||||
if question:
|
||||
user_msg += f"\n\n用户额外问题:{question}"
|
||||
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
]
|
||||
async for chunk in _stream_chat(messages):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def chat_with_ai_stream(messages: list[dict], context: str = ""):
|
||||
"""流式多轮对话 - yield 文本 chunk"""
|
||||
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
if context:
|
||||
msgs.append({"role": "system", "content": f"当前诊断上下文:\n{context}"})
|
||||
msgs.extend(messages)
|
||||
async for chunk in _stream_chat(msgs):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def analyze_with_ai(report: str, question: str = "") -> dict:
|
||||
"""非流式 AI 分析 (兼容旧接口)"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": AI_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return {"success": True, "analysis": content, "model": AI_MODEL}
|
||||
content = ""
|
||||
async for chunk in analyze_with_ai_stream(report, question):
|
||||
content += chunk
|
||||
return {"success": True, "analysis": content, "model": AI_MODEL}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"success": False, "analysis": f"AI 接口返回错误: {e.response.status_code} - {e.response.text[:500]}", "model": AI_MODEL}
|
||||
except httpx.ConnectError:
|
||||
return {"success": False, "analysis": f"无法连接 AI 服务 ({AI_API_BASE}),请检查配置", "model": AI_MODEL}
|
||||
return {"success": False, "analysis": f"无法连接 AI 服务 ({AI_API_BASE}),请检查 .env 中的 AI_API_BASE 配置", "model": AI_MODEL}
|
||||
except Exception as e:
|
||||
return {"success": False, "analysis": f"AI 分析失败: {str(e)}", "model": AI_MODEL}
|
||||
|
||||
|
||||
async def chat_with_ai(messages: list[dict], context: str = "") -> dict:
|
||||
"""多轮对话 - 基于诊断上下文追问"""
|
||||
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
if context:
|
||||
msgs.append({"role": "system", "content": f"当前诊断上下文:\n{context}"})
|
||||
msgs.extend(messages)
|
||||
|
||||
"""非流式多轮对话 (兼容旧接口)"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": AI_MODEL,
|
||||
"messages": msgs,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return {"success": True, "reply": content}
|
||||
content = ""
|
||||
async for chunk in chat_with_ai_stream(messages, context):
|
||||
content += chunk
|
||||
return {"success": True, "reply": content}
|
||||
except Exception as e:
|
||||
return {"success": False, "reply": f"AI 对话失败: {str(e)}"}
|
||||
|
||||
+68
-4
@@ -2,6 +2,7 @@
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -10,7 +11,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from backend.config import PORT
|
||||
from backend.config import PORT, AI_API_BASE, AI_MODEL
|
||||
from backend.diagnosis import (
|
||||
run_full_diagnosis, build_diagnosis_report, ALL_CHECKS,
|
||||
check_connectivity,
|
||||
@@ -18,7 +19,10 @@ from backend.diagnosis import (
|
||||
check_deployments, check_services, check_pvc, check_resource_usage,
|
||||
check_network_policies,
|
||||
)
|
||||
from backend.ai_agent import analyze_with_ai, chat_with_ai
|
||||
from backend.ai_agent import (
|
||||
analyze_with_ai, chat_with_ai,
|
||||
analyze_with_ai_stream, chat_with_ai_stream,
|
||||
)
|
||||
|
||||
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
|
||||
|
||||
@@ -196,7 +200,7 @@ async def get_latest():
|
||||
|
||||
@app.post("/api/analyze")
|
||||
async def analyze(req: AnalyzeRequest):
|
||||
"""AI 分析诊断结果"""
|
||||
"""AI 分析诊断结果 (非流式,兼容旧接口)"""
|
||||
global _last_diagnosis, _last_report
|
||||
if _last_diagnosis is None:
|
||||
_last_diagnosis = await run_full_diagnosis()
|
||||
@@ -205,13 +209,73 @@ async def analyze(req: AnalyzeRequest):
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/analyze/stream")
|
||||
async def analyze_stream(question: str = ""):
|
||||
"""SSE 流式 AI 分析 - 逐字推送分析内容"""
|
||||
global _last_diagnosis, _last_report
|
||||
if _last_diagnosis is None:
|
||||
_last_diagnosis = await run_full_diagnosis()
|
||||
_last_report = build_diagnosis_report(_last_diagnosis)
|
||||
|
||||
async def event_generator():
|
||||
process_steps = [
|
||||
("report", "读取诊断报告", "已加载本次集群诊断上下文"),
|
||||
("risk", "识别关键风险", "正在梳理严重问题、警告和潜在影响"),
|
||||
("solution", "生成修复方案", "正在为高优先级问题生成可执行操作"),
|
||||
("summary", "整理分析结论", "正在组织根因、影响和预防建议"),
|
||||
]
|
||||
|
||||
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL}, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
for step, title, detail in process_steps:
|
||||
yield f"data: {json.dumps({'type': 'process', 'step': step, 'title': title, 'detail': detail, 'status': 'running'}, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(0)
|
||||
yield f"data: {json.dumps({'type': 'process', 'step': step, 'title': title, 'detail': detail, 'status': 'done'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
async for chunk in analyze_with_ai_stream(_last_report, question=question):
|
||||
yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
||||
except httpx.HTTPStatusError as e:
|
||||
err = f"AI 接口返回错误: {e.response.status_code} - {str(e)[:300]}"
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': err}, ensure_ascii=False)}\n\n"
|
||||
except httpx.ConnectError:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'无法连接 AI 服务 ({AI_API_BASE}),请检查 .env 中的 AI_API_BASE 配置'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'AI 分析失败: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(req: ChatRequest):
|
||||
"""AI 多轮对话 (基于诊断上下文追问)"""
|
||||
"""AI 多轮对话 (非流式,兼容旧接口)"""
|
||||
result = await chat_with_ai(req.messages, context=_last_report)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/chat/stream")
|
||||
async def chat_stream(req: ChatRequest):
|
||||
"""SSE 流式 AI 对话 - 逐字推送回复"""
|
||||
async def event_generator():
|
||||
yield f"data: {json.dumps({'type': 'start'}, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
async for chunk in chat_with_ai_stream(req.messages, context=_last_report):
|
||||
yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'AI 对话失败: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/report")
|
||||
async def get_report():
|
||||
"""获取诊断报告文本"""
|
||||
|
||||
Reference in New Issue
Block a user