feat: 持久化诊断历史并修复 Agent 命令执行

This commit is contained in:
cnbugs
2026-07-25 12:40:53 +08:00
parent 4f3a7833a0
commit fdac1eaffc
10 changed files with 650 additions and 45 deletions
+133 -32
View File
@@ -11,7 +11,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional
from backend.config import PORT, AI_API_BASE, AI_MODEL
from backend.config import PORT, AI_API_BASE, AI_MODEL, HISTORY_DB_PATH
from backend.diagnosis import (
run_full_diagnosis, build_diagnosis_report, ALL_CHECKS,
check_connectivity,
@@ -25,8 +25,10 @@ from backend.ai_agent import (
run_diagnostic_agent, take_pending_approval,
)
from backend.agent_tools import execute_kubectl
from backend.history_store import HistoryStore
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
history_store = HistoryStore(HISTORY_DB_PATH)
app.add_middleware(
CORSMiddleware,
@@ -38,6 +40,25 @@ app.add_middleware(
# 存储最近诊断结果 (内存)
_last_diagnosis: Optional[dict] = None
_last_report: str = ""
_last_diagnosis_id: Optional[str] = None
def _save_current_diagnosis(diagnosis: dict, report: str) -> str:
global _last_diagnosis, _last_report, _last_diagnosis_id
_last_diagnosis = diagnosis
_last_report = report
_last_diagnosis_id = history_store.save_diagnosis(diagnosis, report)
return _last_diagnosis_id
def _ensure_current_history() -> str:
global _last_diagnosis_id
if _last_diagnosis_id:
return _last_diagnosis_id
if _last_diagnosis is None:
raise RuntimeError("暂无诊断记录")
_last_diagnosis_id = history_store.save_diagnosis(_last_diagnosis, _last_report)
return _last_diagnosis_id
# 诊断项执行顺序 (带中文标题)
CHECK_ORDER = [
@@ -121,9 +142,9 @@ async def diagnose_stream(namespace: str = ""):
}],
"checks": {},
}
_last_diagnosis = diagnosis
_last_report = build_diagnosis_report(diagnosis)
yield f"data: {json.dumps({'type': 'complete', 'diagnosis': diagnosis}, ensure_ascii=False)}\n\n"
report = build_diagnosis_report(diagnosis)
_save_current_diagnosis(diagnosis, report)
yield f"data: {json.dumps({'type': 'complete', 'diagnosis': diagnosis, 'history_id': _last_diagnosis_id}, ensure_ascii=False)}\n\n"
return
yield f"data: {json.dumps({'type': 'progress', 'step': 0, 'total': len(CHECK_ORDER) + 1, 'name': 'connectivity', 'title': '集群连通性检查', 'status': 'done', 'issues': 0}, ensure_ascii=False)}\n\n"
@@ -176,10 +197,10 @@ async def diagnose_stream(namespace: str = ""):
"checks": results,
}
_last_diagnosis = diagnosis
_last_report = build_diagnosis_report(diagnosis)
report = build_diagnosis_report(diagnosis)
_save_current_diagnosis(diagnosis, report)
yield f"data: {json.dumps({'type': 'complete', 'diagnosis': diagnosis}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'complete', 'diagnosis': diagnosis, 'history_id': _last_diagnosis_id}, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
@@ -192,17 +213,24 @@ async def diagnose_stream(namespace: str = ""):
async def diagnose(req: DiagnoseRequest):
"""执行一键诊断 (非流式,兼容旧接口)"""
global _last_diagnosis, _last_report
_last_diagnosis = await run_full_diagnosis(namespace=req.namespace, checks=req.checks)
_last_report = build_diagnosis_report(_last_diagnosis)
return _last_diagnosis
diagnosis = await run_full_diagnosis(namespace=req.namespace, checks=req.checks)
report = build_diagnosis_report(diagnosis)
_save_current_diagnosis(diagnosis, report)
return {**diagnosis, "history_id": _last_diagnosis_id}
@app.get("/api/diagnosis/latest")
async def get_latest():
"""获取最近一次诊断结果"""
"""获取最近一次诊断结果,服务重启后从 SQLite 恢复。"""
global _last_diagnosis, _last_report, _last_diagnosis_id
if _last_diagnosis is None:
raise HTTPException(status_code=404, detail="暂无诊断记录,请先执行诊断")
return _last_diagnosis
latest = history_store.latest()
if latest is None:
raise HTTPException(status_code=404, detail="暂无诊断记录,请先执行诊断")
_last_diagnosis = latest["diagnosis"]
_last_report = latest["report"]
_last_diagnosis_id = latest["id"]
return {**_last_diagnosis, "history_id": _last_diagnosis_id}
@app.post("/api/analyze")
@@ -210,19 +238,25 @@ async def analyze(req: AnalyzeRequest):
"""AI 分析诊断结果 (非流式,兼容旧接口)"""
global _last_diagnosis, _last_report
if _last_diagnosis is None:
_last_diagnosis = await run_full_diagnosis()
_last_report = build_diagnosis_report(_last_diagnosis)
diagnosis = await run_full_diagnosis()
report = build_diagnosis_report(diagnosis)
_save_current_diagnosis(diagnosis, report)
run_id = history_store.create_run(_ensure_current_history(), "analysis", "standard", req.question)
result = await analyze_with_ai(_last_report, question=req.question)
return result
history_store.append_event(run_id, "analysis", result)
history_store.finish_run(run_id, "completed" if result.get("success") else "failed", result.get("analysis", ""))
return {**result, "run_id": run_id}
@app.get("/api/analyze/stream")
async def analyze_stream(question: str = ""):
"""SSE 流式 AI 分析 - 逐字推送分析内容"""
"""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)
diagnosis = await run_full_diagnosis()
report = build_diagnosis_report(diagnosis)
_save_current_diagnosis(diagnosis, report)
run_id = history_store.create_run(_ensure_current_history(), "analysis", "stream", question)
async def event_generator():
process_steps = [
@@ -232,23 +266,39 @@ async def analyze_stream(question: str = ""):
("summary", "整理分析结论", "正在组织根因、影响和预防建议"),
]
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL, 'run_id': run_id}, ensure_ascii=False)}\n\n"
content = ""
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"
process_event = {'step': step, 'title': title, 'detail': detail, 'status': 'running'}
history_store.append_event(run_id, "process", process_event)
yield f"data: {json.dumps({'type': 'process', **process_event}, 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"
process_event = {'step': step, 'title': title, 'detail': detail, 'status': 'done'}
history_store.append_event(run_id, "process", process_event)
yield f"data: {json.dumps({'type': 'process', **process_event}, ensure_ascii=False)}\n\n"
async for chunk in analyze_with_ai_stream(_last_report, question=question):
content += chunk
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"
history_store.append_event(run_id, "final", {"content": content, "model": AI_MODEL})
history_store.finish_run(run_id, "completed", content)
yield f"data: {json.dumps({'type': 'done', 'run_id': run_id}, ensure_ascii=False)}\n\n"
except httpx.HTTPStatusError as e:
err = f"AI 接口返回错误: {e.response.status_code} - {str(e)[:300]}"
history_store.append_event(run_id, "error", {"message": err})
history_store.finish_run(run_id, "failed", err)
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"
err = f"无法连接 AI 服务 ({AI_API_BASE}),请检查 .env 中的 AI_API_BASE 配置"
history_store.append_event(run_id, "error", {"message": err})
history_store.finish_run(run_id, "failed", err)
yield f"data: {json.dumps({'type': 'error', 'message': err}, 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"
err = f"AI 分析失败: {str(e)}"
history_store.append_event(run_id, "error", {"message": err})
history_store.finish_run(run_id, "failed", err)
yield f"data: {json.dumps({'type': 'error', 'message': err}, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
@@ -264,21 +314,42 @@ async def agent_diagnose_stream(question: str = "", execution_mode: str = "ai"):
raise HTTPException(status_code=400, detail="execution_mode 仅支持 ai 或 manual")
global _last_diagnosis, _last_report
if _last_diagnosis is None:
_last_diagnosis = await run_full_diagnosis()
_last_report = build_diagnosis_report(_last_diagnosis)
diagnosis = await run_full_diagnosis()
report = build_diagnosis_report(diagnosis)
_save_current_diagnosis(diagnosis, report)
run_id = history_store.create_run(_ensure_current_history(), "agent", execution_mode, question)
async def event_generator():
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL, 'mode': execution_mode}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL, 'mode': execution_mode, 'run_id': run_id}, ensure_ascii=False)}\n\n"
final_status = "completed"
summary = ""
try:
async for event in run_diagnostic_agent(
_last_report,
question=question,
execution_mode=execution_mode,
):
history_store.append_event(run_id, event.get("type", "event"), {k: v for k, v in event.items() if k != "type"})
if event.get("type") == "final":
summary = event.get("content", "")
elif event.get("type") == "error":
final_status = "failed"
summary = event.get("message", "")
elif event.get("type") in {"manual_command", "approval_required"}:
final_status = "waiting"
if event.get("type") == "approval_required":
from backend.ai_agent import _PENDING_APPROVALS
pending = _PENDING_APPROVALS.get(event.get("approval_id"))
if pending is not None:
pending["run_id"] = run_id
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
history_store.finish_run(run_id, final_status, summary)
yield f"data: {json.dumps({'type': 'done', 'run_id': run_id}, ensure_ascii=False)}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': f'自主诊断失败: {exc}'}, ensure_ascii=False)}\n\n"
message = f"自主诊断失败: {exc}"
history_store.append_event(run_id, "error", {"message": message})
history_store.finish_run(run_id, "failed", message)
yield f"data: {json.dumps({'type': 'error', 'message': message}, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
@@ -294,9 +365,18 @@ async def execute_approved_agent_command(req: AgentApprovalRequest):
if pending is None:
raise HTTPException(status_code=404, detail="审批请求不存在或已经处理")
if not req.approved:
return {"approved": False, "command": pending["command"], "message": "用户已拒绝执行"}
result = {"approved": False, "command": pending["command"], "message": "用户已拒绝执行"}
if pending.get("run_id"):
history_store.append_event(pending["run_id"], "approval_rejected", result)
history_store.finish_run(pending["run_id"], "rejected", result["message"])
return result
result = await execute_kubectl(pending["command"])
return {"approved": True, "reason": pending["reason"], **result}
response = {"approved": True, "reason": pending["reason"], **result}
if pending.get("run_id"):
history_store.append_event(pending["run_id"], "approved_result", response)
status = "completed" if result.get("returncode") == 0 else "failed"
history_store.finish_run(pending["run_id"], status, result.get("stdout") or result.get("stderr", ""))
return response
@app.post("/api/chat")
@@ -325,6 +405,27 @@ async def chat_stream(req: ChatRequest):
)
@app.get("/api/history/diagnoses")
async def list_diagnosis_history(
status: str = "",
namespace: str = "",
limit: int = 100,
offset: int = 0,
):
"""查询全部历史诊断记录,可按状态和命名空间过滤。"""
items = history_store.list_diagnoses(status=status, namespace=namespace, limit=limit, offset=offset)
return {"total": len(items), "items": items, "limit": limit, "offset": offset}
@app.get("/api/history/diagnoses/{record_id}")
async def get_diagnosis_history(record_id: str):
"""查询单次诊断及其全部 AI 分析、Agent 命令和处理记录。"""
record = history_store.get_diagnosis(record_id)
if record is None:
raise HTTPException(status_code=404, detail="历史诊断记录不存在")
return record
@app.get("/api/report")
async def get_report():
"""获取诊断报告文本"""