package services import ( "context" "encoding/json" "fmt" "strings" "time" "lokistack/models" "lokistack/repository" ) // AIAnalysisResult 结构化结论。 // // 强制模型输出这个结构而不是自由文本,原因: // 1. 前端能分区渲染(结论/证据/建议/不确定),可读性远好于一段散文; // 2. 每条结论都要挂 evidence,逼模型把话落在数据上,降低编造; // 3. unknowns 单独一栏,明确"不知道什么"比编一个答案有价值得多。 type AIAnalysisResult struct { Summary string `json:"summary"` Findings []AIFinding `json:"findings"` Actions []string `json:"actions"` Unknowns []string `json:"unknowns"` RawText string `json:"-"` ParseErr string `json:"parseError,omitempty"` } type AIFinding struct { Title string `json:"title"` Evidence string `json:"evidence"` Severity string `json:"severity"` // high | medium | low } // AIAnalysisOutcome 一次分析的完整返回(含成本信息)。 type AIAnalysisOutcome struct { Result AIAnalysisResult `json:"result"` Signals *AISignalBundle `json:"signals"` Prompt string `json:"prompt"` Run models.AIAnalysisRun `json:"run"` Warnings []string `json:"warnings"` } const aiSystemPrompt = `你是资深 SRE,负责分析 Loki 日志平台的运行状况。 严格规则: 1. 只能基于我给你的"信号数据"下结论。**不许编造**日志内容、错误信息、时间点或服务名。 2. 每一条结论都必须在 evidence 里引用信号数据中的具体数字或条目。 3. 如果信号数据不足以判断根因,把它写进 unknowns,不要猜。 4. 你看不到日志原文(只有统计与排行),因此不要描述"日志里写了什么"。 5. 输出必须是**单个 JSON 对象**,不要 markdown 代码块,不要任何解释性前后缀。 JSON 结构: { "summary": "两三句话的整体判断", "findings": [{"title": "简短结论", "evidence": "引用具体数据", "severity": "high|medium|low"}], "actions": ["可执行的下一步动作,按优先级排序"], "unknowns": ["无法从当前数据判断的点"] }` // RunAIAnalysis 执行一次分析:采集信号 → 脱敏 → 调模型 → 解析 → 落审计。 func RunAIAnalysis(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, username string, admin bool, from, to time.Time, question string) (*AIAnalysisOutcome, error) { if !cfg.Enabled { return nil, fmt.Errorf("AI 分析未启用:请先在「智能运维 → 模型配置」中开启并保存") } // ── 每日配额闸门(成本控制第一层)── if cfg.DailyTokenCap > 0 { used := AITokenUsedToday(userID) if used >= cfg.DailyTokenCap { run := models.AIAnalysisRun{ UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name, Status: "quota-exceeded", Error: fmt.Sprintf("今日 AI token 用量已达上限(%d/%d)", used, cfg.DailyTokenCap), CreatedAt: time.Now(), } persistAIRun(&run) return nil, fmt.Errorf("今日 AI 用量已达上限(%d/%d tokens),可在模型配置中调整上限或明天再试", used, cfg.DailyTokenCap) } } started := time.Now() signals, err := CollectAISignals(ctx, src, userID, admin, from, to) if err != nil { return nil, err } // 扫描预算闸门:P0 全是索引/快照,实际扫描为 0,这里留出扩展位 if cfg.MaxScanBytes > 0 && signals.TotalBytes > cfg.MaxScanBytes*8 { signals.Warnings = append(signals.Warnings, fmt.Sprintf("该范围日志体积 %.1f GB 偏大,已只使用索引统计(未读取日志内容)", float64(signals.TotalBytes)/1024/1024/1024)) } // ── 脱敏(第二层:隐私)── rawPrompt := SignalsAsText(signals) redacted, invalidRules := RedactText(rawPrompt, cfg.RedactExtra) if question = strings.TrimSpace(question); question != "" { redacted += "\n\n运维补充的问题:" + RedactTextOnly(question, cfg.RedactExtra) } warnings := append([]string{}, signals.Warnings...) for _, r := range invalidRules { warnings = append(warnings, "额外脱敏规则无效已跳过:"+r) } // ── 调模型 ── reply, promptTokens, completionTokens, callErr := aiChat(ctx, cfg, []aiMessage{ {Role: "system", Content: aiSystemPrompt}, {Role: "user", Content: redacted}, }) run := models.AIAnalysisRun{ UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name, RangeMinutes: signals.RangeMinutes, Model: cfg.Model, Question: question, PromptPayload: truncateStr(redacted, 20000), PromptTokens: promptTokens, CompletionTokens: completionTokens, ScanBytes: 0, // P0 不扫内容 DurationMs: time.Since(started).Milliseconds(), CreatedAt: time.Now(), } if callErr != nil { run.Status = "failed" run.Error = callErr.Error() run.DurationMs = time.Since(started).Milliseconds() persistAIRun(&run) return nil, callErr } result := parseAIResult(reply) conclusion, _ := json.Marshal(result) run.Status = "ok" run.Conclusion = string(conclusion) run.DurationMs = time.Since(started).Milliseconds() persistAIRun(&run) return &AIAnalysisOutcome{ Result: result, Signals: signals, Prompt: redacted, Run: run, Warnings: warnings, }, nil } // RedactTextOnly 只脱敏不做规则校验提示(用于短文本如用户提问)。 func RedactTextOnly(text string, extra []string) string { out, _ := RedactText(text, extra) return out } // parseAIResult 宽松解析模型输出。 // // 模型常见的"不听话"有三种:包了 ```json 代码块、前后加了解释、 // 直接返回纯文本。前两种剥壳后重试,第三种降级为 summary=原文, // 保证用户至少能看到内容,而不是一个"解析失败"。 func parseAIResult(reply string) AIAnalysisResult { text := strings.TrimSpace(reply) text = strings.TrimPrefix(text, "```json") text = strings.TrimPrefix(text, "```") text = strings.TrimSuffix(text, "```") text = strings.TrimSpace(text) if idx := strings.Index(text, "{"); idx > 0 { text = text[idx:] } if end := strings.LastIndex(text, "}"); end >= 0 && end < len(text)-1 { text = text[:end+1] } var parsed AIAnalysisResult if err := json.Unmarshal([]byte(text), &parsed); err == nil && (parsed.Summary != "" || len(parsed.Findings) > 0) { return parsed } return AIAnalysisResult{ Summary: strings.TrimSpace(reply), Findings: []AIFinding{}, Actions: []string{}, Unknowns: []string{}, RawText: reply, ParseErr: "模型未按 JSON 格式返回,已按纯文本展示", } } // persistAIRun 落审计(同时用于每日 token 统计)。 func persistAIRun(run *models.AIAnalysisRun) { db := repository.GetDB() if db == nil { return } newID := db.NextID("aiAnalysisRuns") db.Lock() defer db.Unlock() run.ID = newID db.AIAnalysisRuns = append(db.AIAnalysisRuns, *run) // 只保留最近 500 条,避免审计表无限膨胀(明细可通过日志追溯) if len(db.AIAnalysisRuns) > 500 { db.AIAnalysisRuns = db.AIAnalysisRuns[len(db.AIAnalysisRuns)-500:] } db.RequestSave() } // ListAIRuns 分页查询审计记录(按时间倒序)。 func ListAIRuns(page, pageSize int) ([]models.AIAnalysisRun, int) { if page <= 0 { page = 1 } if pageSize <= 0 || pageSize > 100 { pageSize = 20 } db := repository.GetDB() if db == nil { return nil, 0 } db.RLock() all := make([]models.AIAnalysisRun, len(db.AIAnalysisRuns)) copy(all, db.AIAnalysisRuns) db.RUnlock() total := len(all) // 倒序(新的在前) for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { all[i], all[j] = all[j], all[i] } start := (page - 1) * pageSize if start >= total { return []models.AIAnalysisRun{}, total } end := start + pageSize if end > total { end = total } return all[start:end], total } // GetAIRun 取单条审计记录(用于查看完整 payload)。 func GetAIRun(id uint) *models.AIAnalysisRun { db := repository.GetDB() if db == nil { return nil } db.RLock() defer db.RUnlock() for i := range db.AIAnalysisRuns { if db.AIAnalysisRuns[i].ID == id { run := db.AIAnalysisRuns[i] return &run } } return nil }