From 4d7d2e42dcd37638c89d7cda263334802161f9e6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 21 Dec 2025 21:09:24 +0000 Subject: [PATCH] feat(ai): pass raw metric samples to LLM for pattern interpretation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of relying on pre-computed trend heuristics (which can be misleading for edge cases like step changes vs continuous growth), we now pass downsampled raw data points to the LLM so it can interpret patterns directly. Changes: - Add MetricSamples field to ResourceContext - Add DownsampleMetrics() to reduce data points for LLM consumption - Add formatMetricSamples() to format data compactly (e.g., 'Disk: 26→26→31%') - Add computeGuestMetricSamples() to gather 7-day sampled history - Populate MetricSamples for VMs and containers during context build - Add History section to formatted context output The LLM now sees actual patterns like 'stable for 6 days then jumped' rather than just '45.8%/day growth rate' - allowing for much more nuanced interpretation. This approach: - Leverages LLM's pattern recognition instead of hard-coded heuristics - Provides 7 days of data (~24 samples) for context on normal behavior - Uses minimal tokens due to compact formatting with deduplication - Is more future-proof as LLMs improve Example output: **History (7d sampled, oldest→newest)**: Disk: 26→26→26→26→26→31% Refs: Frigate disk usage false positive investigation --- frontend-modern/src/pages/Alerts.tsx | 42 ++++++--- internal/ai/context/builder.go | 34 +++++++ internal/ai/context/formatter.go | 83 +++++++++++++++- internal/ai/context/formatter_test.go | 131 ++++++++++++++++++++++++++ internal/ai/context/types.go | 5 + 5 files changed, 283 insertions(+), 12 deletions(-) diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index cc4b7fa..d668e89 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -32,7 +32,7 @@ import History from 'lucide-solid/icons/history'; import Gauge from 'lucide-solid/icons/gauge'; import Send from 'lucide-solid/icons/send'; import Calendar from 'lucide-solid/icons/calendar'; -import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, getSuppressionRules, addSuppressionRule, deleteSuppressionRule, type Finding, type PatrolStatus, type PatrolRunRecord, type SuppressionRule, severityColors, formatTimestamp, categoryLabels } from '@/api/patrol'; +import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, resolveFinding, getSuppressionRules, addSuppressionRule, deleteSuppressionRule, type Finding, type PatrolStatus, type PatrolRunRecord, type SuppressionRule, severityColors, formatTimestamp, categoryLabels } from '@/api/patrol'; import { aiChatStore } from '@/stores/aiChat'; type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history'; @@ -2180,6 +2180,8 @@ function OverviewTab(props: { const [licenseLoading, setLicenseLoading] = createSignal(false); const [remediationsByFinding, setRemediationsByFinding] = createSignal>({}); const [remediationLoadingByFinding, setRemediationLoadingByFinding] = createSignal>({}); + // Track which findings are expanded - lifted to parent to persist across API updates + const [expandedFindingIds, setExpandedFindingIds] = createSignal>(new Set()); const hasAIAlertsFeature = createMemo(() => { const status = licenseFeatures(); if (!status) return true; @@ -2822,7 +2824,22 @@ function OverviewTab(props: { !pendingFixFindings().has(f.id))}> {(finding) => { const colors = severityColors[finding.severity]; - const [isExpanded, setIsExpanded] = createSignal(false); + const isExpanded = () => expandedFindingIds().has(finding.id); + const toggleExpanded = () => { + setExpandedFindingIds((prev) => { + const next = new Set(prev); + if (next.has(finding.id)) { + next.delete(finding.id); + } else { + next.add(finding.id); + // Load remediations when expanding (if not already loaded) + if (remediationsByFinding()[finding.id] === undefined && !remediationLoadingByFinding()[finding.id]) { + void loadRemediationsForFinding(finding.id); + } + } + return next; + }); + }; return (
{ - const nextExpanded = !isExpanded(); - setIsExpanded(nextExpanded); - if (nextExpanded && remediationsByFinding()[finding.id] === undefined && !remediationLoadingByFinding()[finding.id]) { - void loadRemediationsForFinding(finding.id); - } - }} + onClick={toggleExpanded} > {/* Expand chevron */} { + onClick={async (e) => { e.stopPropagation(); + // Immediately hide the finding locally setPendingFixFindings(prev => { const next = new Set(prev); next.add(finding.id); return next; }); + try { + await resolveFinding(finding.id); + showSuccess('Marked as fixed - the next patrol will verify'); + fetchAiData(); + } catch (_err) { + // Still keep it hidden locally since user said they fixed it + showError('Failed to mark as fixed on server'); + } }} - title="Hide until next patrol verifies the fix" + title="Mark as fixed - the next patrol will verify" > diff --git a/internal/ai/context/builder.go b/internal/ai/context/builder.go index 345b019..1a4d345 100644 --- a/internal/ai/context/builder.go +++ b/internal/ai/context/builder.go @@ -115,6 +115,8 @@ func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *Infrastruc vm.CPU, vm.Memory.Usage, vm.Disk.Usage, vm.Uptime, vm.LastBackup, trends, ) + // Add raw metric samples for LLM interpretation + resourceCtx.MetricSamples = b.computeGuestMetricSamples(vm.ID) b.enrichWithNotes(&resourceCtx) b.enrichWithAnomalies(&resourceCtx) ctx.VMs = append(ctx.VMs, resourceCtx) @@ -139,6 +141,10 @@ func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *Infrastruc ct.Uptime, ct.LastBackup, trends, ) + // Add raw metric samples for LLM interpretation + // This lets the LLM see actual patterns without pre-computed heuristics + resourceCtx.MetricSamples = b.computeGuestMetricSamples(ct.ID) + // Add OCI image info for AI context if ct.IsOCI && ct.OSTemplate != "" { if resourceCtx.Metadata == nil { @@ -462,6 +468,34 @@ func formatAnomalyDescription(metric string, current, mean, stddev float64, seve return sb.String() } +// computeGuestMetricSamples gets downsampled raw metrics for LLM interpretation +// Returns ~24 samples from the last 7 days, letting the LLM see patterns and determine if behavior is normal +// With modern context windows (128k+ tokens), this is a small cost for much better insights +func (b *Builder) computeGuestMetricSamples(guestID string) map[string][]MetricPoint { + samples := make(map[string][]MetricPoint) + + if b.metricsHistory == nil { + return samples + } + + // Get 7 days of data - enough to see weekly patterns and determine normalcy + allMetrics := b.metricsHistory.GetAllGuestMetrics(guestID, b.trendWindow7d) + + for metric, points := range allMetrics { + if len(points) < 3 { + continue + } + // Downsample to ~24 points (roughly 3 per day over 7 days) + // This lets the LLM see: daily patterns, weekly cycles, and recent changes + sampled := DownsampleMetrics(points, 24) + if len(sampled) >= 3 { + samples[metric] = sampled + } + } + + return samples +} + // 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 f4b9021..ccd9f19 100644 --- a/internal/ai/context/formatter.go +++ b/internal/ai/context/formatter.go @@ -42,7 +42,7 @@ func FormatResourceContext(ctx ResourceContext) string { sb.WriteString("**Current**: " + strings.Join(metrics, " | ") + "\n") } - // Trends section (the differentiating context) + // Trends section (computed summaries - kept for backwards compatibility) if len(ctx.Trends) > 0 { var trendLines []string for metric, trend := range ctx.Trends { @@ -61,6 +61,25 @@ func FormatResourceContext(ctx ResourceContext) string { } } + // Raw metric samples - let the LLM interpret patterns directly + // This is more reliable than pre-computed trends for edge cases + if len(ctx.MetricSamples) > 0 { + sb.WriteString("**History (7d sampled, oldest→newest)**: ") + var sampleLines []string + for metric, points := range ctx.MetricSamples { + if len(points) >= 3 { + line := formatMetricSamples(metric, points) + if line != "" { + sampleLines = append(sampleLines, line) + } + } + } + if len(sampleLines) > 0 { + sb.WriteString(strings.Join(sampleLines, " | ")) + } + sb.WriteString("\n") + } + // Anomalies (high value - what's unusual) if len(ctx.Anomalies) > 0 { sb.WriteString("**ANOMALIES**: ") @@ -157,6 +176,68 @@ func formatRate(ratePerDay float64) string { return "slow" } +// formatMetricSamples creates a compact representation of sampled values +// Example output: "Disk: 26→26→26→31→31→31" (shows step change visually) +// This lets the LLM interpret patterns directly rather than relying on computed rates +func formatMetricSamples(metric string, points []MetricPoint) string { + if len(points) < 3 { + return "" + } + + metricLabel := strings.Title(metric) + + // Build compact arrow-separated value list + var values []string + prevValue := -1.0 + for _, p := range points { + roundedValue := float64(int(p.Value + 0.5)) // Round to nearest integer + // Skip consecutive duplicates for compactness + if roundedValue == prevValue && len(values) > 0 { + continue + } + values = append(values, fmt.Sprintf("%.0f", roundedValue)) + prevValue = roundedValue + } + + // If all values are the same, just show "stable at X%" + if len(values) == 1 { + return fmt.Sprintf("%s: stable at %.0f%%", metricLabel, prevValue) + } + + // Join with arrows to show progression + return fmt.Sprintf("%s: %s%%", metricLabel, strings.Join(values, "→")) +} + +// DownsampleMetrics takes raw metric points and returns a smaller set for LLM consumption +// It aims for about 10-15 samples across the time range, picking representative values +func DownsampleMetrics(points []MetricPoint, targetSamples int) []MetricPoint { + if len(points) <= targetSamples { + return points + } + + if targetSamples < 3 { + targetSamples = 3 + } + + // Calculate step size + step := len(points) / targetSamples + if step < 1 { + step = 1 + } + + var sampled []MetricPoint + for i := 0; i < len(points); i += step { + sampled = append(sampled, points[i]) + } + + // Always include the last point (current value) + if len(sampled) > 0 && sampled[len(sampled)-1].Timestamp != points[len(points)-1].Timestamp { + sampled = append(sampled, points[len(points)-1]) + } + + return sampled +} + // FormatInfrastructureContext formats full infrastructure context for AI func FormatInfrastructureContext(ctx *InfrastructureContext) string { var sb strings.Builder diff --git a/internal/ai/context/formatter_test.go b/internal/ai/context/formatter_test.go index 255bfdd..67d35fd 100644 --- a/internal/ai/context/formatter_test.go +++ b/internal/ai/context/formatter_test.go @@ -663,3 +663,134 @@ func containsStr(s, substr string) bool { } return false } + +func TestFormatMetricSamples_StepChange(t *testing.T) { + // Simulate a step change: stable at 26%, then jump to 31%, then stable at 31% + now := time.Now() + points := []MetricPoint{ + {Value: 26.2, Timestamp: now.Add(-6 * time.Hour)}, + {Value: 26.1, Timestamp: now.Add(-5 * time.Hour)}, + {Value: 26.3, Timestamp: now.Add(-4 * time.Hour)}, + {Value: 30.7, Timestamp: now.Add(-2 * time.Hour)}, // Jump + {Value: 30.8, Timestamp: now.Add(-1 * time.Hour)}, + {Value: 30.7, Timestamp: now}, + } + + result := formatMetricSamples("disk", points) + + // Should show the step change: 26→31 (deduped) + if !containsStr(result, "Disk:") { + t.Error("Expected result to contain 'Disk:'") + } + // Should show the progression, not just the rate + if !containsStr(result, "26") || !containsStr(result, "31") { + t.Errorf("Expected result to show both values (26 and 31), got: %s", result) + } +} + +func TestFormatMetricSamples_Stable(t *testing.T) { + // All values the same + now := time.Now() + points := []MetricPoint{ + {Value: 50.0, Timestamp: now.Add(-3 * time.Hour)}, + {Value: 50.1, Timestamp: now.Add(-2 * time.Hour)}, + {Value: 49.9, Timestamp: now.Add(-1 * time.Hour)}, + {Value: 50.0, Timestamp: now}, + } + + result := formatMetricSamples("memory", points) + + // All values round to 50, should show "stable at 50%" + if !containsStr(result, "stable at 50%") { + t.Errorf("Expected 'stable at 50%%' for consistent values, got: %s", result) + } +} + +func TestFormatMetricSamples_InsufficientData(t *testing.T) { + points := []MetricPoint{ + {Value: 50.0, Timestamp: time.Now()}, + } + + result := formatMetricSamples("cpu", points) + + if result != "" { + t.Errorf("Expected empty string for insufficient data, got: %s", result) + } +} + +func TestDownsampleMetrics(t *testing.T) { + now := time.Now() + + // Create 100 points + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{ + Value: float64(i), + Timestamp: now.Add(time.Duration(-100+i) * time.Minute), + } + } + + // Downsample to 10 + sampled := DownsampleMetrics(points, 10) + + // Should have roughly 10-11 points (plus potentially the last one) + if len(sampled) < 10 || len(sampled) > 15 { + t.Errorf("Expected ~10-15 samples, got %d", len(sampled)) + } + + // Last point should be included + if sampled[len(sampled)-1].Timestamp != points[99].Timestamp { + t.Error("Expected last point to be included") + } + + // First point should be included + if sampled[0].Timestamp != points[0].Timestamp { + t.Error("Expected first point to be included") + } +} + +func TestDownsampleMetrics_SmallInput(t *testing.T) { + now := time.Now() + + // Create 5 points - less than target + points := []MetricPoint{ + {Value: 10, Timestamp: now.Add(-4 * time.Minute)}, + {Value: 20, Timestamp: now.Add(-3 * time.Minute)}, + {Value: 30, Timestamp: now.Add(-2 * time.Minute)}, + {Value: 40, Timestamp: now.Add(-1 * time.Minute)}, + {Value: 50, Timestamp: now}, + } + + // Downsample to 10 should return all 5 + sampled := DownsampleMetrics(points, 10) + + if len(sampled) != 5 { + t.Errorf("Expected all 5 points when target > input, got %d", len(sampled)) + } +} + +func TestFormatResourceContext_WithMetricSamples(t *testing.T) { + now := time.Now() + ctx := ResourceContext{ + ResourceID: "ct-105", + ResourceType: "container", + ResourceName: "frigate", + Status: "running", + CurrentDisk: 30.7, + MetricSamples: map[string][]MetricPoint{ + "disk": { + {Value: 26.2, Timestamp: now.Add(-3 * time.Hour)}, + {Value: 30.7, Timestamp: now.Add(-1 * time.Hour)}, + {Value: 30.7, Timestamp: now}, + }, + }, + } + + result := FormatResourceContext(ctx) + + // Should contain the History section with sampled data + if !containsStr(result, "History") { + t.Error("Expected result to contain History section with metric samples") + } +} + diff --git a/internal/ai/context/types.go b/internal/ai/context/types.go index d980108..36ceef1 100644 --- a/internal/ai/context/types.go +++ b/internal/ai/context/types.go @@ -129,6 +129,11 @@ type ResourceContext struct { Baselines map[string]Baseline // metric -> baseline Anomalies []Anomaly // Current anomalies + // Raw metric samples - downsampled for LLM interpretation + // Key is metric name (cpu, memory, disk), value is sampled points + // This lets the LLM see actual patterns without pre-computed heuristics + MetricSamples map[string][]MetricPoint + // Predictions Predictions []Prediction