From f3e95c24ae846ab97307c46583c290606b7bfdc8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Dec 2025 11:26:31 +0000 Subject: [PATCH] feat(ai): Add baseline learning and anomaly detection (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of Pulse AI differentiation: - Create internal/ai/baseline package for learned baselines - Implement statistical baseline learning with mean, stddev, percentiles - Add z-score based anomaly detection with severity classification (low, medium, high, critical based on standard deviations) - Integrate baseline provider into context builder - Wire baseline store into patrol service with adapters - Add anomaly enrichment to resource contexts Key features: - Learn computes baseline from historical metric data points - IsAnomaly and CheckAnomaly detect deviations from normal - Persists baselines to disk as JSON for durability - Formatted anomaly descriptions for AI consumption Example: 'Memory is high above normal (85.2% vs typical 42.1% ± 8.3%)' The baseline store needs to be initialized and triggered to learn from metrics history. Next step is adding the learning loop. All tests passing. --- .../src/components/AI/AICostDashboard.tsx | 11 +- internal/ai/baseline/store.go | 395 ++++++++++++++++++ internal/ai/baseline/store_test.go | 209 +++++++++ internal/ai/baseline_adapter.go | 46 ++ internal/ai/context/builder.go | 80 +++- internal/ai/context/formatter.go | 7 +- internal/ai/patrol.go | 54 ++- 7 files changed, 793 insertions(+), 9 deletions(-) create mode 100644 internal/ai/baseline/store.go create mode 100644 internal/ai/baseline/store_test.go create mode 100644 internal/ai/baseline_adapter.go diff --git a/frontend-modern/src/components/AI/AICostDashboard.tsx b/frontend-modern/src/components/AI/AICostDashboard.tsx index fa7cc89..e0810d5 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -38,7 +38,11 @@ export const AICostDashboard: Component = () => { const loadSummary = async (rangeDays: number) => { const seq = ++requestSeq; - setLoading(true); + const isInitialLoad = summary() === null; + // Only show loading indicator on initial load to prevent flicker on range changes + if (isInitialLoad) { + setLoading(true); + } setLoadError(null); try { const data = await AIAPI.getCostSummary(rangeDays); @@ -47,7 +51,10 @@ export const AICostDashboard: Component = () => { } catch (err) { if (seq !== requestSeq) return; logger.error('[AICostDashboard] Failed to load cost summary:', err); - notificationStore.error('Failed to load AI cost summary'); + // Only show notification on refresh failures, not initial load + if (!isInitialLoad) { + notificationStore.error('Failed to refresh AI cost summary'); + } const message = err instanceof Error && err.message ? err.message : 'Failed to load usage data'; setLoadError(message); diff --git a/internal/ai/baseline/store.go b/internal/ai/baseline/store.go new file mode 100644 index 0000000..81ecf0a --- /dev/null +++ b/internal/ai/baseline/store.go @@ -0,0 +1,395 @@ +// Package baseline provides learned baseline metrics for anomaly detection. +// It learns what "normal" looks like for each resource by analyzing historical +// metrics and can then flag current values that deviate significantly from the baseline. +package baseline + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +// MetricBaseline represents learned "normal" behavior for a single metric +type MetricBaseline struct { + Mean float64 `json:"mean"` // Average value + StdDev float64 `json:"stddev"` // Standard deviation + Percentiles map[int]float64 `json:"percentiles"` // 5, 25, 50, 75, 95 + SampleCount int `json:"sample_count"` // Number of samples used + + // Time-of-day patterns (future enhancement) + HourlyMeans [24]float64 `json:"hourly_means,omitempty"` +} + +// ResourceBaseline contains baselines for all metrics of a resource +type ResourceBaseline struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` // node, vm, container, storage + LastUpdated time.Time `json:"last_updated"` + Metrics map[string]*MetricBaseline `json:"metrics"` // cpu, memory, disk +} + +// Store manages baseline storage and learning +type Store struct { + mu sync.RWMutex + baselines map[string]*ResourceBaseline // resourceID -> baseline + + // Configuration + learningWindow time.Duration // How far back to learn from (default: 7 days) + minSamples int // Minimum samples needed (default: 50) + updateInterval time.Duration // How often to recompute (default: 1 hour) + + // Persistence + dataDir string + persistence Persistence +} + +// Persistence interface for saving/loading baselines +type Persistence interface { + Save(baselines map[string]*ResourceBaseline) error + Load() (map[string]*ResourceBaseline, error) +} + +// StoreConfig configures the baseline store +type StoreConfig struct { + LearningWindow time.Duration + MinSamples int + UpdateInterval time.Duration + DataDir string +} + +// DefaultConfig returns sensible defaults +func DefaultConfig() StoreConfig { + return StoreConfig{ + LearningWindow: 7 * 24 * time.Hour, // 7 days + MinSamples: 50, + UpdateInterval: 1 * time.Hour, + } +} + +// NewStore creates a new baseline store +func NewStore(cfg StoreConfig) *Store { + if cfg.LearningWindow == 0 { + cfg.LearningWindow = 7 * 24 * time.Hour + } + if cfg.MinSamples == 0 { + cfg.MinSamples = 50 + } + if cfg.UpdateInterval == 0 { + cfg.UpdateInterval = 1 * time.Hour + } + + s := &Store{ + baselines: make(map[string]*ResourceBaseline), + learningWindow: cfg.LearningWindow, + minSamples: cfg.MinSamples, + updateInterval: cfg.UpdateInterval, + dataDir: cfg.DataDir, + } + + // Try to load existing baselines from disk + if cfg.DataDir != "" { + if err := s.loadFromDisk(); err != nil { + log.Warn().Err(err).Msg("Failed to load baselines from disk, starting fresh") + } else { + log.Info().Int("count", len(s.baselines)).Msg("Loaded baselines from disk") + } + } + + return s +} + +// MetricPoint represents a single metric value at a point in time +type MetricPoint struct { + Value float64 + Timestamp time.Time +} + +// Learn computes baseline from historical data points +func (s *Store) Learn(resourceID, resourceType, metric string, points []MetricPoint) error { + if len(points) < s.minSamples { + log.Debug(). + Str("resource", resourceID). + Str("metric", metric). + Int("samples", len(points)). + Int("required", s.minSamples). + Msg("Insufficient data for baseline learning") + return nil // Not an error, just not enough data yet + } + + // Extract values + values := make([]float64, len(points)) + for i, p := range points { + values[i] = p.Value + } + + // Compute statistics + baseline := &MetricBaseline{ + Mean: computeMean(values), + StdDev: computeStdDev(values), + Percentiles: computePercentiles(values), + SampleCount: len(values), + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Get or create resource baseline + rb, exists := s.baselines[resourceID] + if !exists { + rb = &ResourceBaseline{ + ResourceID: resourceID, + ResourceType: resourceType, + Metrics: make(map[string]*MetricBaseline), + } + s.baselines[resourceID] = rb + } + + rb.Metrics[metric] = baseline + rb.LastUpdated = time.Now() + + log.Debug(). + Str("resource", resourceID). + Str("metric", metric). + Float64("mean", baseline.Mean). + Float64("stddev", baseline.StdDev). + Int("samples", baseline.SampleCount). + Msg("Baseline learned") + + return nil +} + +// GetBaseline returns the baseline for a resource/metric +func (s *Store) GetBaseline(resourceID, metric string) (*MetricBaseline, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + rb, exists := s.baselines[resourceID] + if !exists { + return nil, false + } + + mb, exists := rb.Metrics[metric] + return mb, exists +} + +// GetResourceBaseline returns all baselines for a resource +func (s *Store) GetResourceBaseline(resourceID string) (*ResourceBaseline, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + rb, exists := s.baselines[resourceID] + if !exists { + return nil, false + } + + // Return a copy to prevent mutation + copy := &ResourceBaseline{ + ResourceID: rb.ResourceID, + ResourceType: rb.ResourceType, + LastUpdated: rb.LastUpdated, + Metrics: make(map[string]*MetricBaseline), + } + for k, v := range rb.Metrics { + copy.Metrics[k] = v + } + return copy, true +} + +// IsAnomaly checks if a value is anomalous for the given resource/metric +// Returns: isAnomaly, zScore (number of standard deviations from mean) +func (s *Store) IsAnomaly(resourceID, metric string, value float64) (bool, float64) { + baseline, ok := s.GetBaseline(resourceID, metric) + if !ok || baseline.SampleCount < s.minSamples { + return false, 0 // Not enough data to determine + } + + if baseline.StdDev == 0 { + // No variance - any different value is anomalous + if value != baseline.Mean { + return true, math.Inf(1) + } + return false, 0 + } + + zScore := (value - baseline.Mean) / baseline.StdDev + + // Consider anything > 2 standard deviations as anomalous + // (covers ~95% of normal distribution) + isAnomaly := math.Abs(zScore) > 2.0 + + return isAnomaly, zScore +} + +// AnomalySeverity categorizes how severe an anomaly is +type AnomalySeverity string + +const ( + AnomalyNone AnomalySeverity = "" + AnomalyLow AnomalySeverity = "low" // 2-2.5 std devs + AnomalyMedium AnomalySeverity = "medium" // 2.5-3 std devs + AnomalyHigh AnomalySeverity = "high" // 3-4 std devs + AnomalyCritical AnomalySeverity = "critical" // > 4 std devs +) + +// CheckAnomaly performs a detailed anomaly check with severity classification +func (s *Store) CheckAnomaly(resourceID, metric string, value float64) (AnomalySeverity, float64, *MetricBaseline) { + baseline, ok := s.GetBaseline(resourceID, metric) + if !ok || baseline.SampleCount < s.minSamples { + return AnomalyNone, 0, nil + } + + if baseline.StdDev == 0 { + if value != baseline.Mean { + return AnomalyCritical, math.Inf(1), baseline + } + return AnomalyNone, 0, baseline + } + + zScore := (value - baseline.Mean) / baseline.StdDev + absZ := math.Abs(zScore) + + var severity AnomalySeverity + switch { + case absZ < 2.0: + severity = AnomalyNone + case absZ < 2.5: + severity = AnomalyLow + case absZ < 3.0: + severity = AnomalyMedium + case absZ < 4.0: + severity = AnomalyHigh + default: + severity = AnomalyCritical + } + + return severity, zScore, baseline +} + +// ResourceCount returns the number of resources with baselines +func (s *Store) ResourceCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.baselines) +} + +// Save persists baselines to disk +func (s *Store) Save() error { + if s.dataDir == "" { + return nil + } + + s.mu.RLock() + defer s.mu.RUnlock() + + return s.saveToDisk() +} + +// saveToDisk writes baselines to JSON file +func (s *Store) saveToDisk() error { + if s.dataDir == "" { + return nil + } + + path := filepath.Join(s.dataDir, "baselines.json") + + data, err := json.MarshalIndent(s.baselines, "", " ") + if err != nil { + return err + } + + // Write to temp file first, then rename for atomicity + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { + return err + } + + return os.Rename(tmpPath, path) +} + +// loadFromDisk reads baselines from JSON file +func (s *Store) loadFromDisk() error { + path := filepath.Join(s.dataDir, "baselines.json") + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil // No saved baselines yet + } + return err + } + + return json.Unmarshal(data, &s.baselines) +} + +// Helper functions for statistics + +func computeMean(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sum := 0.0 + for _, v := range values { + sum += v + } + return sum / float64(len(values)) +} + +func computeStdDev(values []float64) float64 { + if len(values) < 2 { + return 0 + } + mean := computeMean(values) + sumSqDiff := 0.0 + for _, v := range values { + diff := v - mean + sumSqDiff += diff * diff + } + variance := sumSqDiff / float64(len(values)-1) // Sample standard deviation + return math.Sqrt(variance) +} + +func computePercentiles(values []float64) map[int]float64 { + if len(values) == 0 { + return nil + } + + // Sort a copy + sorted := make([]float64, len(values)) + copy(sorted, values) + sort.Float64s(sorted) + + percentiles := map[int]float64{ + 5: percentile(sorted, 5), + 25: percentile(sorted, 25), + 50: percentile(sorted, 50), + 75: percentile(sorted, 75), + 95: percentile(sorted, 95), + } + + return percentiles +} + +func percentile(sorted []float64, p int) float64 { + if len(sorted) == 0 { + return 0 + } + + // Use linear interpolation + rank := float64(p) / 100.0 * float64(len(sorted)-1) + lower := int(rank) + upper := lower + 1 + + if upper >= len(sorted) { + return sorted[len(sorted)-1] + } + + // Interpolate + weight := rank - float64(lower) + return sorted[lower]*(1-weight) + sorted[upper]*weight +} diff --git a/internal/ai/baseline/store_test.go b/internal/ai/baseline/store_test.go new file mode 100644 index 0000000..37f44ea --- /dev/null +++ b/internal/ai/baseline/store_test.go @@ -0,0 +1,209 @@ +package baseline + +import ( + "math" + "testing" + "time" +) + +func TestLearn_Basic(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Create 50 data points with mean ~50 and some variance + points := make([]MetricPoint, 50) + now := time.Now() + for i := 0; i < 50; i++ { + points[i] = MetricPoint{ + Value: 50 + float64(i%10) - 5, // Values from 45-54 + Timestamp: now.Add(-time.Duration(50-i) * time.Minute), + } + } + + err := store.Learn("test-vm", "vm", "cpu", points) + if err != nil { + t.Fatalf("Learn failed: %v", err) + } + + baseline, ok := store.GetBaseline("test-vm", "cpu") + if !ok { + t.Fatal("Baseline not found after learning") + } + + // Check mean is around 50 + if math.Abs(baseline.Mean-50) > 1 { + t.Errorf("Expected mean ~50, got %f", baseline.Mean) + } + + // Check stddev is reasonable (should be ~3 for our data) + if baseline.StdDev < 1 || baseline.StdDev > 5 { + t.Errorf("Expected stddev ~3, got %f", baseline.StdDev) + } + + if baseline.SampleCount != 50 { + t.Errorf("Expected 50 samples, got %d", baseline.SampleCount) + } +} + +func TestLearn_InsufficientData(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 50}) + + // Only 10 points, not enough + points := make([]MetricPoint, 10) + for i := 0; i < 10; i++ { + points[i] = MetricPoint{Value: float64(i)} + } + + err := store.Learn("test-vm", "vm", "cpu", points) + if err != nil { + t.Fatalf("Learn should not error on insufficient data: %v", err) + } + + _, ok := store.GetBaseline("test-vm", "cpu") + if ok { + t.Error("Should not have baseline with insufficient data") + } +} + +func TestIsAnomaly(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Create stable data around 50 with low variance + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{ + Value: 50 + float64(i%3) - 1, // Values 49, 50, 51 + } + } + + store.Learn("test-vm", "vm", "cpu", points) + + // Test normal value + isAnomaly, zScore := store.IsAnomaly("test-vm", "cpu", 50) + if isAnomaly { + t.Errorf("50 should not be anomaly, zScore=%f", zScore) + } + + // Test slightly high - with stddev ~0.82, 51 is within 2 std devs + isAnomaly, zScore = store.IsAnomaly("test-vm", "cpu", 51) + if isAnomaly { + t.Errorf("51 should not be anomaly with this variance, zScore=%f", zScore) + } + + // Test very high (should be anomaly) + isAnomaly, zScore = store.IsAnomaly("test-vm", "cpu", 60) + if !isAnomaly { + t.Errorf("60 should be anomaly, zScore=%f", zScore) + } + + // Test very low (should be anomaly) + isAnomaly, zScore = store.IsAnomaly("test-vm", "cpu", 40) + if !isAnomaly { + t.Errorf("40 should be anomaly, zScore=%f", zScore) + } +} + +func TestCheckAnomaly_Severity(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Create very stable data with known statistics + // Mean = 50, StdDev = 1 + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + // Alternate between 49, 50, 51 for stddev ~1 + points[i] = MetricPoint{Value: 50 + float64(i%3) - 1} + } + + store.Learn("test-vm", "vm", "cpu", points) + baseline, _ := store.GetBaseline("test-vm", "cpu") + + testCases := []struct { + value float64 + expectedSeverity AnomalySeverity + }{ + {50, AnomalyNone}, // Mean + {50 + baseline.StdDev*1.5, AnomalyNone}, // 1.5 std devs - normal + {50 + baseline.StdDev*2.2, AnomalyLow}, // 2.2 std devs + {50 + baseline.StdDev*2.7, AnomalyMedium}, // 2.7 std devs + {50 + baseline.StdDev*3.5, AnomalyHigh}, // 3.5 std devs + {50 + baseline.StdDev*4.5, AnomalyCritical}, // 4.5 std devs + } + + for _, tc := range testCases { + severity, _, _ := store.CheckAnomaly("test-vm", "cpu", tc.value) + if severity != tc.expectedSeverity { + t.Errorf("Value %f: expected severity %s, got %s", tc.value, tc.expectedSeverity, severity) + } + } +} + +func TestGetResourceBaseline(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Learn multiple metrics + cpuPoints := make([]MetricPoint, 50) + memPoints := make([]MetricPoint, 50) + for i := 0; i < 50; i++ { + cpuPoints[i] = MetricPoint{Value: 30} + memPoints[i] = MetricPoint{Value: 70} + } + + store.Learn("test-vm", "vm", "cpu", cpuPoints) + store.Learn("test-vm", "vm", "memory", memPoints) + + rb, ok := store.GetResourceBaseline("test-vm") + if !ok { + t.Fatal("Resource baseline not found") + } + + if rb.ResourceType != "vm" { + t.Errorf("Expected resource type 'vm', got '%s'", rb.ResourceType) + } + + if len(rb.Metrics) != 2 { + t.Errorf("Expected 2 metrics, got %d", len(rb.Metrics)) + } + + if rb.Metrics["cpu"] == nil { + t.Error("CPU metric baseline missing") + } + + if rb.Metrics["memory"] == nil { + t.Error("Memory metric baseline missing") + } +} + +func TestPercentiles(t *testing.T) { + values := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + percentiles := computePercentiles(values) + + // P50 should be ~5.5 for 1-10 + if percentiles[50] < 5 || percentiles[50] > 6 { + t.Errorf("P50 should be ~5.5, got %f", percentiles[50]) + } + + // P5 should be close to 1 + if percentiles[5] < 1 || percentiles[5] > 2 { + t.Errorf("P5 should be ~1, got %f", percentiles[5]) + } + + // P95 should be close to 10 + if percentiles[95] < 9 || percentiles[95] > 10 { + t.Errorf("P95 should be ~10, got %f", percentiles[95]) + } +} + +func TestComputeStats(t *testing.T) { + // Test mean and stddev with known values + values := []float64{2, 4, 4, 4, 5, 5, 7, 9} // Mean = 5, Stddev = 2 (sample) + + mean := computeMean(values) + if mean != 5 { + t.Errorf("Expected mean 5, got %f", mean) + } + + stddev := computeStdDev(values) + // Sample stddev of [2,4,4,4,5,5,7,9] is approximately 2.14, not exactly 2 + if math.Abs(stddev-2.14) > 0.1 { + t.Errorf("Expected stddev ~2.14, got %f", stddev) + } +} diff --git a/internal/ai/baseline_adapter.go b/internal/ai/baseline_adapter.go new file mode 100644 index 0000000..d129ae1 --- /dev/null +++ b/internal/ai/baseline_adapter.go @@ -0,0 +1,46 @@ +package ai + +import ( + "github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline" +) + +// BaselineStoreAdapter adapts baseline.Store to the context.BaselineProvider interface +type BaselineStoreAdapter struct { + store *baseline.Store +} + +// NewBaselineStoreAdapter creates an adapter for baseline.Store +func NewBaselineStoreAdapter(store *baseline.Store) *BaselineStoreAdapter { + if store == nil { + return nil + } + return &BaselineStoreAdapter{store: store} +} + +// CheckAnomaly implements context.BaselineProvider +func (a *BaselineStoreAdapter) CheckAnomaly(resourceID, metric string, value float64) (severity string, zScore float64, mean float64, stddev float64, ok bool) { + if a.store == nil { + return "", 0, 0, 0, false + } + + s, z, b := a.store.CheckAnomaly(resourceID, metric, value) + if b == nil { + return "", 0, 0, 0, false + } + + return string(s), z, b.Mean, b.StdDev, true +} + +// GetBaseline implements context.BaselineProvider +func (a *BaselineStoreAdapter) GetBaseline(resourceID, metric string) (mean float64, stddev float64, sampleCount int, ok bool) { + if a.store == nil { + return 0, 0, 0, false + } + + b, exists := a.store.GetBaseline(resourceID, metric) + if !exists || b == nil { + return 0, 0, 0, false + } + + return b.Mean, b.StdDev, b.SampleCount, true +} diff --git a/internal/ai/context/builder.go b/internal/ai/context/builder.go index b6be293..9a3b8e0 100644 --- a/internal/ai/context/builder.go +++ b/internal/ai/context/builder.go @@ -29,12 +29,22 @@ type FindingsProvider interface { GetPastFindingsForResource(resourceID string) []string } +// BaselineProvider provides learned baselines for anomaly detection +type BaselineProvider interface { + // CheckAnomaly returns severity, z-score, and baseline data + // Severity is "", "low", "medium", "high", or "critical" + CheckAnomaly(resourceID, metric string, value float64) (severity string, zScore float64, mean float64, stddev float64, ok bool) + // GetBaseline returns the baseline for a resource/metric + GetBaseline(resourceID, metric string) (mean float64, stddev float64, sampleCount int, ok bool) +} + // Builder constructs enriched AI context from multiple data sources type Builder struct { // Data sources metricsHistory MetricsHistoryProvider knowledge KnowledgeProvider findings FindingsProvider + baseline BaselineProvider // Configuration trendWindow24h time.Duration @@ -51,7 +61,7 @@ func NewBuilder() *Builder { trendWindow7d: 7 * 24 * time.Hour, includeHistory: true, includeTrends: true, - includeBaseline: false, // Disabled until baseline store is implemented + includeBaseline: true, // Enable when baseline provider is set } } @@ -73,6 +83,12 @@ func (b *Builder) WithFindings(f FindingsProvider) *Builder { return b } +// WithBaseline sets the baseline provider for anomaly detection +func (b *Builder) WithBaseline(bp BaselineProvider) *Builder { + b.baseline = bp + return b +} + // BuildForInfrastructure creates comprehensive context for the entire infrastructure func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *InfrastructureContext { ctx := &InfrastructureContext{ @@ -84,6 +100,7 @@ func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *Infrastruc trends := b.computeNodeTrends(node.ID) resourceCtx := FormatNodeForContext(node, trends) b.enrichWithNotes(&resourceCtx) + b.enrichWithAnomalies(&resourceCtx) ctx.Nodes = append(ctx.Nodes, resourceCtx) } @@ -99,6 +116,7 @@ func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *Infrastruc vm.Uptime, vm.LastBackup, trends, ) b.enrichWithNotes(&resourceCtx) + b.enrichWithAnomalies(&resourceCtx) ctx.VMs = append(ctx.VMs, resourceCtx) } @@ -114,6 +132,7 @@ func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *Infrastruc ct.Uptime, ct.LastBackup, trends, ) b.enrichWithNotes(&resourceCtx) + b.enrichWithAnomalies(&resourceCtx) ctx.Containers = append(ctx.Containers, resourceCtx) } @@ -368,6 +387,65 @@ func (b *Builder) enrichWithNotes(ctx *ResourceContext) { } } +// enrichWithAnomalies checks current values against baselines and adds anomalies +func (b *Builder) enrichWithAnomalies(ctx *ResourceContext) { + if b.baseline == nil || !b.includeBaseline { + return + } + + // Check each metric type for anomalies + metrics := map[string]float64{ + "cpu": ctx.CurrentCPU, + "memory": ctx.CurrentMemory, + "disk": ctx.CurrentDisk, + } + + for metric, value := range metrics { + if value == 0 { + continue // Skip zeroes (usually means not reported) + } + + severity, zScore, mean, stddev, ok := b.baseline.CheckAnomaly(ctx.ResourceID, metric, value) + if !ok || severity == "" { + continue // No anomaly or no baseline + } + + direction := "above" + if zScore < 0 { + direction = "below" + } + + anomaly := Anomaly{ + Metric: metric, + Current: value, + Expected: mean, + Deviation: zScore, + Severity: severity, + Since: time.Now(), // We don't track onset time yet + Description: formatAnomalyDescription(metric, value, mean, stddev, severity, direction), + } + ctx.Anomalies = append(ctx.Anomalies, anomaly) + } +} + +// formatAnomalyDescription creates human-readable anomaly description +func formatAnomalyDescription(metric string, current, mean, stddev float64, severity, direction string) string { + var sb strings.Builder + sb.WriteString(strings.Title(metric)) + sb.WriteString(" is ") + sb.WriteString(severity) + sb.WriteString(" ") + sb.WriteString(direction) + sb.WriteString(" normal (") + sb.WriteString(formatFloat(current, 1)) + sb.WriteString("% vs typical ") + sb.WriteString(formatFloat(mean, 1)) + sb.WriteString("% ± ") + sb.WriteString(formatFloat(stddev, 1)) + sb.WriteString("%)") + return sb.String() +} + // filterRecentPoints filters points to only include those within duration func filterRecentPoints(points []MetricPoint, duration time.Duration) []MetricPoint { cutoff := time.Now().Add(-duration) diff --git a/internal/ai/context/formatter.go b/internal/ai/context/formatter.go index b62e2d3..b116da5 100644 --- a/internal/ai/context/formatter.go +++ b/internal/ai/context/formatter.go @@ -385,6 +385,7 @@ func FormatNodeForContext(node models.Node, trends map[string]Trend) ResourceCon } // FormatGuestForContext creates context for a VM or container +// Note: cpu is 0-1 ratio from Proxmox API, memUsage and diskUsage are already 0-100 percentages func FormatGuestForContext( id, name, node, guestType, status string, cpu, memUsage, diskUsage float64, @@ -397,9 +398,9 @@ func FormatGuestForContext( ResourceType: guestType, ResourceName: name, Node: node, - CurrentCPU: cpu * 100, // Convert from 0-1 to percentage - CurrentMemory: memUsage * 100, - CurrentDisk: diskUsage * 100, + CurrentCPU: cpu * 100, // Convert from 0-1 to percentage + CurrentMemory: memUsage, // Already 0-100 percentage from Memory.Usage + CurrentDisk: diskUsage, // Already 0-100 percentage from Disk.Usage Status: status, Uptime: time.Duration(uptime) * time.Second, Trends: trends, diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 2597772..6202f00 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline" aicontext "github.com/rcourtman/pulse-go-rewrite/internal/ai/context" "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -210,6 +211,7 @@ type PatrolService struct { findings *FindingsStore knowledgeStore *knowledge.Store // For per-resource notes in patrol context metricsHistory MetricsHistoryProvider // For trend analysis and predictions + baselineStore *baseline.Store // For anomaly detection via learned baselines // Cached thresholds (recalculated when thresholdProvider changes) thresholds PatrolThresholds @@ -340,6 +342,22 @@ func (p *PatrolService) SetMetricsHistoryProvider(provider MetricsHistoryProvide log.Info().Msg("AI Patrol: Metrics history provider set for enriched context") } +// SetBaselineStore sets the baseline store for anomaly detection +// This enables the patrol service to detect anomalies based on learned normal behavior +func (p *PatrolService) SetBaselineStore(store *baseline.Store) { + p.mu.Lock() + defer p.mu.Unlock() + p.baselineStore = store + log.Info().Msg("AI Patrol: Baseline store set for anomaly detection") +} + +// GetBaselineStore returns the baseline store (for external baseline learning) +func (p *PatrolService) GetBaselineStore() *baseline.Store { + p.mu.RLock() + defer p.mu.RUnlock() + return p.baselineStore +} + // GetConfig returns the current patrol configuration func (p *PatrolService) GetConfig() PatrolConfig { p.mu.RLock() @@ -828,6 +846,7 @@ func (p *PatrolService) analyzeNode(node models.Node) []*Finding { } // analyzeGuest checks a VM or container for issues +// Note: cpu is 0-1 ratio, memUsage and diskUsage are already 0-100 percentages from Memory.Usage/Disk.Usage func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string, cpu, memUsage, diskUsage float64, lastBackup *time.Time, template bool) []*Finding { var findings []*Finding @@ -837,9 +856,9 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string, return findings } - // Convert ratios to percentages for comparison with thresholds - memPct := memUsage * 100 - diskPct := diskUsage * 100 + // memUsage and diskUsage are already percentages (0-100) + memPct := memUsage + diskPct := diskUsage // High memory (sustained) - use dynamic thresholds if memPct > p.thresholds.GuestMemWatch { @@ -1683,6 +1702,7 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string p.mu.RLock() metricsHistory := p.metricsHistory knowledgeStore := p.knowledgeStore + baselineStore := p.baselineStore p.mu.RUnlock() // If no metrics history, fall back to basic summary @@ -1699,6 +1719,14 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string if knowledgeStore != nil { builder = builder.WithKnowledge(&knowledgeShim{store: knowledgeStore}) } + + // Add baseline provider for anomaly detection if available + if baselineStore != nil { + adapter := NewBaselineStoreAdapter(baselineStore) + if adapter != nil { + builder = builder.WithBaseline(&baselineShim{adapter: adapter}) + } + } // Build full infrastructure context with trends infraCtx := builder.BuildForInfrastructure(state) @@ -1713,6 +1741,7 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string log.Debug(). Int("resources", infraCtx.TotalResources). Int("predictions", len(infraCtx.Predictions)). + Int("anomalies", len(infraCtx.Anomalies)). Msg("AI Patrol: Built enriched context with trends") return formatted @@ -1783,6 +1812,25 @@ func (k *knowledgeShim) FormatAllForContext() string { return k.store.FormatAllForContext() } +// baselineShim adapts BaselineStoreAdapter to aicontext.BaselineProvider +type baselineShim struct { + adapter *BaselineStoreAdapter +} + +func (b *baselineShim) CheckAnomaly(resourceID, metric string, value float64) (severity string, zScore float64, mean float64, stddev float64, ok bool) { + if b.adapter == nil { + return "", 0, 0, 0, false + } + return b.adapter.CheckAnomaly(resourceID, metric, value) +} + +func (b *baselineShim) GetBaseline(resourceID, metric string) (mean float64, stddev float64, sampleCount int, ok bool) { + if b.adapter == nil { + return 0, 0, 0, false + } + return b.adapter.GetBaseline(resourceID, metric) +} + // convertToContextPoints converts ai.MetricPoint to aicontext.MetricPoint // Since both are aliases for types.MetricPoint, this is just a type assertion func convertToContextPoints(points []MetricPoint) []aicontext.MetricPoint {