package analysis import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "time" "unicode" "gitea.wayfinderak.com/wayfinderak/log-guardian/internal/loki" "gitea.wayfinderak.com/wayfinderak/log-guardian/internal/store" ) type Finding struct { Summary string `json:"summary"` RecommendedActions []string `json:"recommended_actions,omitempty"` Confidence string `json:"confidence"` } type Analyzer interface { Analyze(ctx context.Context, rule store.Rule, matches []loki.Match) (Finding, error) } type NoopAnalyzer struct{} func (NoopAnalyzer) Analyze(ctx context.Context, rule store.Rule, matches []loki.Match) (Finding, error) { _ = ctx return Finding{Summary: summarize(rule, matches), Confidence: "rule-only"}, nil } type OllamaAnalyzer struct { URL string Model string http *http.Client } func NewOllamaAnalyzer(url, model string) *OllamaAnalyzer { return &OllamaAnalyzer{URL: strings.TrimRight(url, "/"), Model: model, http: &http.Client{Timeout: 60 * time.Second}} } func (a *OllamaAnalyzer) Analyze(ctx context.Context, rule store.Rule, matches []loki.Match) (Finding, error) { if a.URL == "" || a.Model == "" { return NoopAnalyzer{}.Analyze(ctx, rule, matches) } prompt := buildPrompt(rule, matches) body, _ := json.Marshal(map[string]any{"model": a.Model, "prompt": prompt, "stream": false}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.URL+"/api/generate", bytes.NewReader(body)) if err != nil { return Finding{}, err } req.Header.Set("Content-Type", "application/json") resp, err := a.http.Do(req) if err != nil { return Finding{}, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return Finding{}, fmt.Errorf("ollama analysis failed: status %d", resp.StatusCode) } var raw struct { Response string `json:"response"` } if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return Finding{}, err } summary := strings.TrimSpace(raw.Response) if summary == "" { return NoopAnalyzer{}.Analyze(ctx, rule, matches) } if len(summary) > 500 { summary = summary[:500] + "..." } return Finding{Summary: summary, Confidence: "ollama-local"}, nil } func buildPrompt(rule store.Rule, matches []loki.Match) string { var b strings.Builder b.WriteString("You are analyzing private application logs locally. Summarize likely cause and operator next steps in 4 concise sentences or fewer. Do not suggest destructive actions.\n") b.WriteString("Rule: ") b.WriteString(rule.Name) b.WriteString("\nSeverity: ") b.WriteString(rule.Severity) b.WriteString("\nSanitized samples:\n") limit := len(matches) if limit > 5 { limit = 5 } for i := 0; i < limit; i++ { line := sanitizeLine(matches[i].Line) line = redactSecrets(line) if len(line) > 300 { line = line[:300] + "..." } b.WriteString("- ") b.WriteString(line) b.WriteByte('\n') } return b.String() } func summarize(rule store.Rule, matches []loki.Match) string { if len(matches) == 0 { return "No matching log entries." } line := redactSecrets(sanitizeLine(matches[0].Line)) if len(line) > 180 { line = line[:180] + "..." } return rule.Name + " matched " + plural(len(matches), "log entry", "log entries") + "; first match: " + line } func sanitizeLine(line string) string { line = strings.ReplaceAll(line, "\n", " ") line = strings.ReplaceAll(line, "\r", " ") return strings.TrimSpace(line) } func redactSecrets(line string) string { fields := strings.Fields(line) for i, field := range fields { lower := strings.ToLower(field) if strings.Contains(lower, "token=") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "apikey=") || strings.Contains(lower, "api_key=") || strings.HasPrefix(lower, "bearer") { fields[i] = redactField(field) } } return strings.Join(fields, " ") } func redactField(field string) string { idx := strings.IndexAny(field, "=:") if idx < 0 { return "[REDACTED]" } prefix := strings.TrimRightFunc(field[:idx], func(r rune) bool { return unicode.IsSpace(r) }) return prefix + "=[REDACTED]" } func plural(count int, singular, plural string) string { if count == 1 { return "1 " + singular } return strconvItoa(count) + " " + plural } func strconvItoa(i int) string { if i == 0 { return "0" } var b [20]byte bp := len(b) for i > 0 { bp-- b[bp] = byte('0' + i%10) i /= 10 } return string(b[bp:]) }