feat(ai): pass raw metric samples to LLM for pattern interpretation

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
This commit is contained in:
rcourtman 2025-12-21 21:09:24 +00:00
parent 17c36ed124
commit 4d7d2e42dc
5 changed files with 283 additions and 12 deletions

View file

@ -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<Record<string, RemediationRecord[]>>({});
const [remediationLoadingByFinding, setRemediationLoadingByFinding] = createSignal<Record<string, boolean>>({});
// Track which findings are expanded - lifted to parent to persist across API updates
const [expandedFindingIds, setExpandedFindingIds] = createSignal<Set<string>>(new Set());
const hasAIAlertsFeature = createMemo(() => {
const status = licenseFeatures();
if (!status) return true;
@ -2822,7 +2824,22 @@ function OverviewTab(props: {
<For each={aiFindings().filter(f => !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 (
<div
class="border rounded-lg transition-all"
@ -2834,13 +2851,7 @@ function OverviewTab(props: {
{/* Compact header - always visible, clickable */}
<div
class="flex items-center gap-3 p-3 cursor-pointer hover:opacity-80"
onClick={() => {
const nextExpanded = !isExpanded();
setIsExpanded(nextExpanded);
if (nextExpanded && remediationsByFinding()[finding.id] === undefined && !remediationLoadingByFinding()[finding.id]) {
void loadRemediationsForFinding(finding.id);
}
}}
onClick={toggleExpanded}
>
{/* Expand chevron */}
<svg
@ -2961,15 +2972,24 @@ function OverviewTab(props: {
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700 hover:bg-green-100 dark:hover:bg-green-900/50 flex items-center gap-1.5"
onClick={(e) => {
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"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />

View file

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

View file

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

View file

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

View file

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