package controller import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "ezops/middleware" "ezops/model" "ezops/pkg/agentcli/llm" "ezops/pkg/aiopscontext" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" ) // AIOpsAlertAnalysisHandler 基于上下文包执行告警 RCA。 // Logan.Li type AIOpsAlertAnalysisHandler struct { db *gorm.DB context *aiopscontext.Service } // NewAIOpsAlertAnalysisHandler 创建告警分析处理器。 // Logan.Li func NewAIOpsAlertAnalysisHandler(db *gorm.DB) *AIOpsAlertAnalysisHandler { return &AIOpsAlertAnalysisHandler{db: db, context: aiopscontext.NewService(db)} } // AlertAnalysisRequest 告警分析请求,前端传告警 ID 和少量兜底字段。 // Logan.Li type AlertAnalysisRequest struct { AlertID uint `json:"alert_id"` Name string `json:"name"` Severity string `json:"severity"` Target string `json:"target"` Metric string `json:"metric"` CurrentValue string `json:"current_value"` Threshold string `json:"threshold"` Duration string `json:"duration"` Description string `json:"description"` TimeRange string `json:"time_range"` } // AlertAnalysisResponse 告警 RCA 结构化响应。 // Logan.Li type AlertAnalysisResponse struct { TraceID string `json:"trace_id"` SpanID string `json:"span_id"` RootCause string `json:"root_cause"` Impact string `json:"impact"` Recommendations []string `json:"recommendations"` AutoFixScript string `json:"auto_fix_script,omitempty"` SimilarCases int `json:"similar_cases"` Confidence int `json:"confidence"` Evidence []aiopscontext.EvidenceItem `json:"evidence"` ContextPack *aiopscontext.ContextPack `json:"context_pack,omitempty"` Model string `json:"model"` Source string `json:"source"` GeneratedAt time.Time `json:"generated_at"` } // Analyze POST /api/v1/aiops/alert-analysis。 // Logan.Li func (h *AIOpsAlertAnalysisHandler) Analyze(c *gin.Context) { start := time.Now() traceID := uuid.NewString() spanID := uuid.NewString() userID := middleware.GetUserID(c) var req AlertAnalysisRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) return } timeRange := req.TimeRange if strings.TrimSpace(timeRange) == "" { timeRange = "24h" } metrics := metricsForAlert(req.Metric) pack, err := h.context.BuildPack(c.Request.Context(), aiopscontext.ContextPackRequest{ AlertID: req.AlertID, Target: req.Target, Metrics: metrics, TimeRange: timeRange, Limit: 12, }) if err != nil { h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", Target: req.Target, AlertID: req.AlertID, Status: "error", Source: "context-pack", Duration: time.Since(start).Milliseconds(), Error: err.Error(), Result: gin.H{"error": "构建上下文失败: " + err.Error()}, }) c.JSON(http.StatusOK, model.Response{Code: 500, Message: "构建上下文失败: " + err.Error()}) return } fallback := fallbackAlertAnalysis(req, pack) attachTrace(fallback, traceID, spanID) var cfg model.LLMConfig if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled { h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider, Model: fallback.Model, Source: fallback.Source, Status: "fallback", Pack: pack, Result: fallback, Duration: time.Since(start).Milliseconds(), }) c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback}) return } result, usage, err := h.callAlertRCA(c.Request.Context(), cfg, req, pack, fallback) if err != nil { fallback.Source = "rule-fallback: " + err.Error() attachTrace(fallback, traceID, spanID) h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider, Model: cfg.Model, Source: "rule-fallback", Status: "fallback", Pack: pack, Result: fallback, Duration: time.Since(start).Milliseconds(), Error: err.Error(), }) c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback}) return } attachTrace(result, traceID, spanID) h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider, Model: result.Model, Source: result.Source, Status: "success", Pack: pack, Result: result, PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens, Duration: time.Since(start).Milliseconds(), }) c.JSON(http.StatusOK, model.Response{Code: 200, Data: result}) } func (h *AIOpsAlertAnalysisHandler) callAlertRCA(ctx context.Context, cfg model.LLMConfig, req AlertAnalysisRequest, pack *aiopscontext.ContextPack, fallback *AlertAnalysisResponse) (*AlertAnalysisResponse, llm.Usage, error) { packJSON, _ := json.MarshalIndent(pack, "", " ") prompt := fmt.Sprintf(`你是 AIOps 平台的企业级告警 RCA 分析器。请只基于【告警基本信息】和【AIOps Context Pack】做判断,不要编造不存在的数据。 【告警基本信息】 - ID: %d - 名称: %s - 级别: %s - 目标: %s - 指标: %s - 当前值: %s - 阈值: %s - 持续: %s - 描述: %s 【AIOps Context Pack】 %s 请返回严格 JSON,不要 markdown,不要代码块,字段如下: { "root_cause": "根因分析,2-4句,必须引用证据", "impact": "影响范围,1-2句", "recommendations": ["建议1", "建议2", "建议3"], "auto_fix_script": "仅限只读诊断脚本;没有则为空字符串", "similar_cases": 0, "confidence": 0-100 }`, req.AlertID, req.Name, req.Severity, req.Target, req.Metric, req.CurrentValue, req.Threshold, req.Duration, req.Description, string(packJSON)) resp, err := buildLLMClientFromConfig(cfg).Complete(ctx, &llm.CompletionRequest{ Messages: []llm.Message{ {Role: "system", Content: "你是 AIOps 平台的告警根因分析专家,输出必须是可解析 JSON。"}, {Role: "user", Content: prompt}, }, Temperature: 0.2, MaxTokens: cfg.MaxTokens, }) if err != nil { return nil, llm.Usage{}, err } parsed := *fallback if err := parseAlertAnalysisJSON(resp.Message.Content, &parsed); err != nil { parsed.RootCause = strings.TrimSpace(resp.Message.Content) } parsed.ContextPack = pack parsed.Evidence = pack.Evidence parsed.Model = cfg.Model parsed.Source = "llm" parsed.GeneratedAt = time.Now() return &parsed, resp.Usage, nil } // ListAnalysisTraces 查询 AI 分析审计记录,普通用户只看自己的记录。 // Logan.Li func (h *AIOpsAlertAnalysisHandler) ListAnalysisTraces(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) if page < 1 { page = 1 } pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if pageSize <= 0 || pageSize > 100 { pageSize = 20 } q := h.db.Model(&model.AIAnalysisTrace{}) if !middleware.GetIsAdmin(c) { q = q.Where("user_id = ?", middleware.GetUserID(c)) } if feature := strings.TrimSpace(c.Query("feature")); feature != "" { q = q.Where("feature = ?", feature) } if target := strings.TrimSpace(c.Query("target")); target != "" { q = q.Where("target LIKE ?", "%"+target+"%") } if status := strings.TrimSpace(c.Query("status")); status != "" { q = q.Where("status = ?", status) } var total int64 q.Count(&total) var rows []model.AIAnalysisTrace if err := q.Select("id, trace_id, span_id, parent_span_id, user_id, feature, target, alert_id, provider, model, source, status, evidence_count, prompt_tokens, completion_tokens, total_tokens, duration, error, created_at, updated_at"). Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": rows, "total": total}}) } // GetAnalysisTrace 按 trace_id 回放某次 AI 分析看到的上下文包和输出结论。 // Logan.Li func (h *AIOpsAlertAnalysisHandler) GetAnalysisTrace(c *gin.Context) { traceID := strings.TrimSpace(c.Param("trace_id")) q := h.db.Where("trace_id = ?", traceID) if !middleware.GetIsAdmin(c) { q = q.Where("user_id = ?", middleware.GetUserID(c)) } var row model.AIAnalysisTrace if err := q.Order("id ASC").First(&row).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 404, Message: "分析记录不存在"}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ "trace": row, "context_pack": decodeJSONField(row.ContextPack), "result": decodeJSONField(row.Result), }}) } func parseAlertAnalysisJSON(raw string, out *AlertAnalysisResponse) error { clean := strings.TrimSpace(raw) clean = strings.TrimPrefix(clean, "```json") clean = strings.TrimPrefix(clean, "```") clean = strings.TrimSuffix(clean, "```") clean = strings.TrimSpace(clean) var tmp struct { RootCause string `json:"root_cause"` Impact string `json:"impact"` Recommendations []string `json:"recommendations"` AutoFixScript string `json:"auto_fix_script"` SimilarCases int `json:"similar_cases"` Confidence int `json:"confidence"` } if err := json.Unmarshal([]byte(clean), &tmp); err != nil { return err } if strings.TrimSpace(tmp.RootCause) != "" { out.RootCause = tmp.RootCause } if strings.TrimSpace(tmp.Impact) != "" { out.Impact = tmp.Impact } if len(tmp.Recommendations) > 0 { out.Recommendations = tmp.Recommendations } out.AutoFixScript = tmp.AutoFixScript out.SimilarCases = tmp.SimilarCases if tmp.Confidence > 0 { if tmp.Confidence > 100 { tmp.Confidence = 100 } out.Confidence = tmp.Confidence } return nil } func fallbackAlertAnalysis(req AlertAnalysisRequest, pack *aiopscontext.ContextPack) *AlertAnalysisResponse { evidenceText := "暂无足够平台证据" if len(pack.Evidence) > 0 { evidenceText = pack.Evidence[0].Summary } target := firstText(req.Target, pack.Scope, "未知目标") metric := firstText(req.Metric, "unknown") recs := []string{ fmt.Sprintf("先查看 %s 的近期指标趋势和告警时间线,确认是否持续异常", target), "结合变更、发布、定时任务和日志错误进一步缩小根因范围", "修复动作前先执行只读诊断,涉及重启、清理、扩容等动作需走确认或审批", } return &AlertAnalysisResponse{ RootCause: fmt.Sprintf("告警 %s 在目标 %s 上触发,指标 %s 当前值 %s,阈值 %s。基于当前上下文,最直接证据是:%s。", firstText(req.Name, "未命名告警"), target, metric, req.CurrentValue, req.Threshold, evidenceText), Impact: fmt.Sprintf("该告警级别为 %s,可能影响目标 %s 的稳定性,需要结合业务等级判断优先级。", firstText(req.Severity, "unknown"), target), Recommendations: recs, SimilarCases: 0, Confidence: fallbackConfidence(pack), Evidence: pack.Evidence, ContextPack: pack, Model: "rule-fallback", Source: "rule-fallback", GeneratedAt: time.Now(), } } func firstText(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func metricsForAlert(metric string) []string { m := strings.ToLower(metric) switch { case strings.Contains(m, "mem"): return []string{"mem_usage", "load1"} case strings.Contains(m, "load"): return []string{"load1", "load5", "cpu_usage"} case strings.Contains(m, "io"): return []string{"cpu_iowait", "load1"} default: return []string{"cpu_usage", "mem_usage", "load1"} } } func fallbackConfidence(pack *aiopscontext.ContextPack) int { score := 45 if len(pack.Alerts) > 0 { score += 10 } if len(pack.Metrics) > 0 { score += 15 } if len(pack.Assets) > 0 { score += 5 } if score > 80 { score = 80 } return score } type analysisTraceInput struct { TraceID string SpanID string ParentSpanID string UserID uint Feature string Target string AlertID uint Provider string Model string Source string Status string Pack *aiopscontext.ContextPack Result interface{} PromptTokens int CompletionTokens int TotalTokens int Duration int64 Error string } func attachTrace(resp *AlertAnalysisResponse, traceID, spanID string) { if resp == nil { return } resp.TraceID = traceID resp.SpanID = spanID } func (h *AIOpsAlertAnalysisHandler) saveAnalysisTrace(ctx context.Context, in analysisTraceInput) { packJSON := marshalTraceJSON(in.Pack) resultJSON := marshalTraceJSON(traceResultForStorage(in.Result)) evidenceCount := 0 if in.Pack != nil { evidenceCount = len(in.Pack.Evidence) } row := model.AIAnalysisTrace{ TraceID: in.TraceID, SpanID: in.SpanID, ParentSpanID: in.ParentSpanID, UserID: in.UserID, Feature: firstText(in.Feature, "unknown"), Target: in.Target, AlertID: in.AlertID, Provider: in.Provider, Model: in.Model, Source: trimTraceText(in.Source, 64), Status: firstText(in.Status, "unknown"), ContextPack: packJSON, Result: resultJSON, EvidenceCount: evidenceCount, PromptTokens: in.PromptTokens, CompletionTokens: in.CompletionTokens, TotalTokens: in.TotalTokens, Duration: in.Duration, Error: in.Error, } _ = h.db.WithContext(ctx).Create(&row).Error } func marshalTraceJSON(v interface{}) string { if v == nil { return "" } raw, err := json.Marshal(v) if err != nil { return "" } return string(raw) } func traceResultForStorage(v interface{}) interface{} { switch r := v.(type) { case *AlertAnalysisResponse: if r == nil { return nil } cp := *r cp.ContextPack = nil return cp case AlertAnalysisResponse: r.ContextPack = nil return r default: return v } } func decodeJSONField(raw string) interface{} { if strings.TrimSpace(raw) == "" { return nil } var out interface{} if err := json.Unmarshal([]byte(raw), &out); err != nil { return raw } return out } func trimTraceText(s string, max int) string { if max <= 0 { return s } runes := []rune(s) if len(runes) <= max { return s } return string(runes[:max]) }