feat(ai): Add baseline learning and anomaly detection (Phase 2)

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.
This commit is contained in:
rcourtman 2025-12-12 11:26:31 +00:00
parent 64dad395a2
commit f3e95c24ae
7 changed files with 793 additions and 9 deletions

View file

@ -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);

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)

View file

@ -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,

View file

@ -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 {