mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
Major new AI capabilities for infrastructure monitoring: Investigation System: - Autonomous finding investigation with configurable autonomy levels - Investigation orchestrator with rate limiting and guardrails - Safety checks for read-only mode enforcement - Chat-based investigation with approval workflows Forecasting & Remediation: - Trend forecasting for resource capacity planning - Remediation engine for generating fix proposals - Circuit breaker for AI operation protection Unified Findings: - Unified store bridging alerts and AI findings - Correlation and root cause analysis - Incident coordinator with metrics recording New Frontend: - AI Intelligence page with patrol controls - Investigation drawer for finding details - Unified findings panel with actions Supporting Infrastructure: - Learning store for user preference tracking - Proxmox event ingestion and correlation - Enhanced patrol with investigation triggers
354 lines
8.3 KiB
Go
354 lines
8.3 KiB
Go
package learning
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLearningStore_RecordFeedback(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
record := FeedbackRecord{
|
|
FindingID: "finding-1",
|
|
ResourceID: "vm-101",
|
|
Category: "performance",
|
|
Severity: "warning",
|
|
Action: ActionQuickFix,
|
|
}
|
|
|
|
store.RecordFeedback(record)
|
|
|
|
stats := store.GetStatistics()
|
|
if stats.TotalFeedbackRecords != 1 {
|
|
t.Errorf("Expected 1 feedback record, got %d", stats.TotalFeedbackRecords)
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_ResourcePreferences(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
// Record feedback for a resource
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f1",
|
|
ResourceID: "vm-101",
|
|
Category: "performance",
|
|
Severity: "warning",
|
|
Action: ActionQuickFix,
|
|
})
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f2",
|
|
ResourceID: "vm-101",
|
|
Category: "capacity",
|
|
Severity: "info",
|
|
Action: ActionDismissNotAnIssue,
|
|
})
|
|
|
|
pref := store.GetResourcePreference("vm-101")
|
|
if pref == nil {
|
|
t.Fatal("Expected resource preference")
|
|
}
|
|
|
|
if pref.TotalFindings != 2 {
|
|
t.Errorf("Expected 2 findings, got %d", pref.TotalFindings)
|
|
}
|
|
|
|
if pref.ActionedCount != 1 {
|
|
t.Errorf("Expected 1 actioned, got %d", pref.ActionedCount)
|
|
}
|
|
|
|
if pref.DismissedCount != 1 {
|
|
t.Errorf("Expected 1 dismissed (false positive), got %d", pref.DismissedCount)
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_CategoryPreferences(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
// Record multiple actions in same category
|
|
for i := 0; i < 15; i++ {
|
|
action := ActionQuickFix
|
|
if i%3 == 0 {
|
|
action = ActionDismissNotAnIssue
|
|
}
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f" + intToStr(i),
|
|
ResourceID: "vm-" + intToStr(i),
|
|
Category: "performance",
|
|
Severity: "warning",
|
|
Action: action,
|
|
})
|
|
}
|
|
|
|
pref := store.GetCategoryPreference("performance")
|
|
if pref == nil {
|
|
t.Fatal("Expected category preference")
|
|
}
|
|
|
|
if pref.TotalFindings != 15 {
|
|
t.Errorf("Expected 15 findings, got %d", pref.TotalFindings)
|
|
}
|
|
|
|
// With enough data, severity weight should be adjusted
|
|
if pref.SeverityWeight == 1.0 {
|
|
// Weight should change based on action rate
|
|
t.Log("Severity weight adjusted based on action rate")
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_ShouldSuppress(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
// Record many dismissals to create high false positive rate
|
|
for i := 0; i < 10; i++ {
|
|
action := ActionDismissNotAnIssue
|
|
if i == 0 {
|
|
action = ActionQuickFix
|
|
}
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f" + intToStr(i),
|
|
ResourceID: "noisy-vm",
|
|
Category: "performance",
|
|
Severity: "info",
|
|
Action: action,
|
|
})
|
|
}
|
|
|
|
// Should suppress info-level findings for this resource
|
|
if !store.ShouldSuppress("noisy-vm", "other", "info") {
|
|
t.Error("Expected to suppress info-level for high FP resource")
|
|
}
|
|
|
|
// Should not suppress critical
|
|
if store.ShouldSuppress("noisy-vm", "other", "critical") {
|
|
t.Error("Should not suppress critical level")
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_GetSeverityWeight(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
// Default weight is 1.0
|
|
weight := store.GetSeverityWeight("unknown")
|
|
if weight != 1.0 {
|
|
t.Errorf("Expected default weight 1.0, got %.2f", weight)
|
|
}
|
|
|
|
// After recording feedback, weight changes
|
|
for i := 0; i < 15; i++ {
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f" + intToStr(i),
|
|
ResourceID: "vm-" + intToStr(i),
|
|
Category: "capacity",
|
|
Severity: "warning",
|
|
Action: ActionQuickFix, // High action rate
|
|
})
|
|
}
|
|
|
|
weight = store.GetSeverityWeight("capacity")
|
|
if weight <= 1.0 {
|
|
t.Errorf("Expected weight > 1.0 for high action rate category, got %.2f", weight)
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_FormatForContext(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
// Record enough data
|
|
for i := 0; i < 20; i++ {
|
|
action := ActionQuickFix
|
|
if i%2 == 0 {
|
|
action = ActionDismissNotAnIssue
|
|
}
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f" + intToStr(i),
|
|
ResourceID: "vm-101",
|
|
Category: "performance",
|
|
Severity: "warning",
|
|
Action: action,
|
|
})
|
|
}
|
|
|
|
context := store.FormatForContext()
|
|
|
|
if context == "" {
|
|
t.Error("Expected non-empty context")
|
|
}
|
|
|
|
if !containsStr(context, "Learned Preferences") {
|
|
t.Error("Expected 'Learned Preferences' in context")
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_FormatForContext_NoData(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
context := store.FormatForContext()
|
|
if context != "" {
|
|
t.Error("Expected empty context with no data")
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_Cleanup(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{RetentionDays: 1})
|
|
|
|
// Add old feedback
|
|
store.mu.Lock()
|
|
oldRecord := &FeedbackRecord{
|
|
ID: "old-1",
|
|
FindingID: "f1",
|
|
Category: "test",
|
|
Action: ActionQuickFix,
|
|
Timestamp: time.Now().AddDate(0, 0, -2),
|
|
}
|
|
store.feedbackRecords["old-1"] = oldRecord
|
|
store.mu.Unlock()
|
|
|
|
// Add recent feedback
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f2",
|
|
ResourceID: "vm-1",
|
|
Category: "test",
|
|
Action: ActionQuickFix,
|
|
})
|
|
|
|
removed := store.Cleanup()
|
|
if removed != 1 {
|
|
t.Errorf("Expected 1 removed, got %d", removed)
|
|
}
|
|
|
|
stats := store.GetStatistics()
|
|
if stats.TotalFeedbackRecords != 1 {
|
|
t.Errorf("Expected 1 remaining, got %d", stats.TotalFeedbackRecords)
|
|
}
|
|
}
|
|
|
|
func TestComputeFeedbackSignal(t *testing.T) {
|
|
tests := []struct {
|
|
action UserAction
|
|
expectedFP bool
|
|
expectedAction bool
|
|
minConfidence float64
|
|
}{
|
|
{ActionDismissNotAnIssue, true, false, 0.8},
|
|
{ActionDismissExpected, false, false, 0.6},
|
|
{ActionQuickFix, false, true, 0.9},
|
|
{ActionThumbsUp, false, true, 0.8},
|
|
{ActionThumbsDown, true, false, 0.8},
|
|
{ActionIgnore, false, false, 0.2},
|
|
{ActionSnooze, false, false, 0.4},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.action), func(t *testing.T) {
|
|
signal := computeFeedbackSignal(tt.action)
|
|
|
|
if signal.IsFalsePositive != tt.expectedFP {
|
|
t.Errorf("Expected IsFalsePositive=%v, got %v", tt.expectedFP, signal.IsFalsePositive)
|
|
}
|
|
|
|
if signal.WasActionable != tt.expectedAction {
|
|
t.Errorf("Expected WasActionable=%v, got %v", tt.expectedAction, signal.WasActionable)
|
|
}
|
|
|
|
if signal.Confidence < tt.minConfidence {
|
|
t.Errorf("Expected confidence >= %.1f, got %.2f", tt.minConfidence, signal.Confidence)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSeverityLevel(t *testing.T) {
|
|
tests := []struct {
|
|
severity string
|
|
expected int
|
|
}{
|
|
{"info", 0},
|
|
{"watch", 1},
|
|
{"warning", 2},
|
|
{"critical", 3},
|
|
{"unknown", 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := severityLevel(tt.severity)
|
|
if result != tt.expected {
|
|
t.Errorf("severityLevel(%s) = %d, want %d", tt.severity, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_UserNotes(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f1",
|
|
ResourceID: "vm-101",
|
|
Category: "performance",
|
|
Action: ActionDismissExpected,
|
|
UserNote: "This VM runs batch jobs",
|
|
})
|
|
|
|
pref := store.GetResourcePreference("vm-101")
|
|
if len(pref.Notes) != 1 {
|
|
t.Errorf("Expected 1 note, got %d", len(pref.Notes))
|
|
}
|
|
|
|
if pref.Notes[0] != "This VM runs batch jobs" {
|
|
t.Errorf("Note mismatch")
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_Cleanup_WithActionQuickFix(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f1",
|
|
ResourceID: "vm-101",
|
|
Category: "performance",
|
|
Action: ActionQuickFix,
|
|
})
|
|
|
|
stats := store.GetStatistics()
|
|
if stats.TotalFeedbackRecords != 1 {
|
|
t.Errorf("Expected 1 record, got %d", stats.TotalFeedbackRecords)
|
|
}
|
|
}
|
|
|
|
func TestLearningStore_TimeToAction(t *testing.T) {
|
|
store := NewLearningStore(LearningStoreConfig{})
|
|
|
|
store.RecordFeedback(FeedbackRecord{
|
|
FindingID: "f1",
|
|
ResourceID: "vm-101",
|
|
Category: "performance",
|
|
Action: ActionQuickFix,
|
|
TimeToAction: 5 * time.Minute,
|
|
})
|
|
|
|
pref := store.GetCategoryPreference("performance")
|
|
if pref.AverageTimeToAction == 0 {
|
|
t.Error("Expected average time to action to be set")
|
|
}
|
|
}
|
|
|
|
// Helpers
|
|
func intToStr(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var result string
|
|
for n > 0 {
|
|
result = string(rune('0'+n%10)) + result
|
|
n /= 10
|
|
}
|
|
return result
|
|
}
|
|
|
|
func containsStr(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|