diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 878500151..568e6b7ff 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -159,3 +159,7 @@ the same change source of truth. The unified intelligence summary should follow the same rule when it counts recent activity, so the shared AI summary and the Patrol seed context stay aligned with the canonical timeline. +The shared AI resource and infrastructure prompt contexts should also surface +the same canonical recent changes section before any patrol-local fallback so +the model sees the same timeline entries that power the resource API and +intelligence summary counts. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 1fc8579a0..dd477069a 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -77,3 +77,7 @@ that powers the shared resource API. Patrol-owned intelligence summaries should keep their recent-change counts backed by the same canonical timeline when available instead of only counting Patrol-local detector history. +Patrol-owned resource and global intelligence prompt contexts should also +render the canonical recent changes section before any patrol-local change +detector fallback so the prompt surface stays aligned with the shared +unified-resource timeline. diff --git a/internal/ai/intelligence.go b/internal/ai/intelligence.go index 9cc7ad57c..8d787bfad 100644 --- a/internal/ai/intelligence.go +++ b/internal/ai/intelligence.go @@ -346,46 +346,67 @@ func (i *Intelligence) GetResourceIntelligence(resourceID string) *ResourceIntel // FormatContext builds a comprehensive context string for AI prompts func (i *Intelligence) FormatContext(resourceID string) string { i.mu.RLock() - defer i.mu.RUnlock() + knowledgeStore := i.knowledge + baselinesStore := i.baselines + patternsDetector := i.patterns + correlationsDetector := i.correlations + incidentsStore := i.incidents + resourceTimelineStore := i.resourceTimelineStore + changesDetector := i.changes + anomalyDetector := i.anomalyDetector + i.mu.RUnlock() var sections []string // Knowledge (most important - what we've learned) - if i.knowledge != nil { - if ctx := i.knowledge.FormatForContext(resourceID); ctx != "" { + if knowledgeStore != nil { + if ctx := knowledgeStore.FormatForContext(resourceID); ctx != "" { sections = append(sections, ctx) } } // Baselines (what's normal for this resource) - if i.baselines != nil { + if baselinesStore != nil { if ctx := i.formatBaselinesForContext(resourceID); ctx != "" { sections = append(sections, ctx) } } // Current anomalies - if anomalies := i.detectCurrentAnomalies(resourceID); len(anomalies) > 0 { - sections = append(sections, i.formatAnomaliesForContext(anomalies)) + if anomalyDetector != nil { + if anomalies := anomalyDetector(resourceID); len(anomalies) > 0 { + sections = append(sections, i.formatAnomaliesForContext(anomalies)) + } + } + + // Canonical recent changes (preferred) with patrol-local fallback + if recentChanges := i.buildRecentChangesContext( + resourceID, + resourceTimelineStore, + changesDetector, + false, + 5, + ); recentChanges != "" { + sections = append(sections, recentChanges) } // Patterns/Predictions - if i.patterns != nil { - if ctx := i.patterns.FormatForContext(resourceID); ctx != "" { + if patternsDetector != nil { + if ctx := patternsDetector.FormatForContext(resourceID); ctx != "" { sections = append(sections, ctx) } } // Correlations - if i.correlations != nil { - if ctx := i.correlations.FormatForContext(resourceID); ctx != "" { + if correlationsDetector != nil { + if ctx := correlationsDetector.FormatForContext(resourceID); ctx != "" { sections = append(sections, ctx) } } // Incidents - if i.incidents != nil { - if ctx := i.incidents.FormatForResource(resourceID, 5); ctx != "" { + if incidentsStore != nil { + if ctx := incidentsStore.FormatForResource(resourceID, 5); ctx != "" { sections = append(sections, ctx) } } @@ -396,34 +417,51 @@ func (i *Intelligence) FormatContext(resourceID string) string { // FormatGlobalContext builds context for infrastructure-wide analysis func (i *Intelligence) FormatGlobalContext() string { i.mu.RLock() - defer i.mu.RUnlock() + knowledgeStore := i.knowledge + incidentsStore := i.incidents + correlationsDetector := i.correlations + patternsDetector := i.patterns + resourceTimelineStore := i.resourceTimelineStore + changesDetector := i.changes + i.mu.RUnlock() var sections []string // All saved knowledge (limited) - if i.knowledge != nil { - if ctx := i.knowledge.FormatAllForContext(); ctx != "" { + if knowledgeStore != nil { + if ctx := knowledgeStore.FormatAllForContext(); ctx != "" { sections = append(sections, ctx) } } + // Canonical recent changes across infrastructure (preferred) with patrol-local fallback + if recentChanges := i.buildRecentChangesContext( + "", + resourceTimelineStore, + changesDetector, + true, + 5, + ); recentChanges != "" { + sections = append(sections, recentChanges) + } + // Recent incidents across infrastructure - if i.incidents != nil { - if ctx := i.incidents.FormatForPatrol(8); ctx != "" { + if incidentsStore != nil { + if ctx := incidentsStore.FormatForPatrol(8); ctx != "" { sections = append(sections, ctx) } } // Top correlations - if i.correlations != nil { - if ctx := i.correlations.FormatForContext(""); ctx != "" { + if correlationsDetector != nil { + if ctx := correlationsDetector.FormatForContext(""); ctx != "" { sections = append(sections, ctx) } } // Top predictions - if i.patterns != nil { - if ctx := i.patterns.FormatForContext(""); ctx != "" { + if patternsDetector != nil { + if ctx := patternsDetector.FormatForContext(""); ctx != "" { sections = append(sections, ctx) } } @@ -431,6 +469,111 @@ func (i *Intelligence) FormatGlobalContext() string { return strings.Join(sections, "\n") } +func (i *Intelligence) buildRecentChangesContext(resourceID string, resourceTimelineStore unifiedresources.ResourceStore, changesDetector *memory.ChangeDetector, includeResourcePrefix bool, limit int) string { + since := time.Now().Add(-24 * time.Hour) + + if resourceTimelineStore != nil { + if recent, err := resourceTimelineStore.GetRecentChanges(resourceID, since, limit); err == nil && len(recent) > 0 { + return formatCanonicalRecentChangesContext(recent, includeResourcePrefix) + } + } + + if changesDetector == nil { + return "" + } + + var recent []memory.Change + if resourceID != "" { + recent = changesDetector.GetChangesForResource(resourceID, limit) + } else { + recent = changesDetector.GetRecentChanges(limit, since) + } + if len(recent) == 0 { + return "" + } + return formatMemoryRecentChangesContext(recent, includeResourcePrefix) +} + +func formatCanonicalRecentChangesContext(changes []unifiedresources.ResourceChange, includeResourcePrefix bool) string { + if len(changes) == 0 { + return "" + } + + heading := "\n## Recent Changes" + if includeResourcePrefix { + heading = "\n## Recent Changes Across Infrastructure" + } + + lines := []string{heading, "What changed recently:"} + for _, change := range changes { + entry := formatResourceChangeContext(change) + if includeResourcePrefix { + if resourceID := strings.TrimSpace(change.ResourceID); resourceID != "" { + entry = fmt.Sprintf("%s: %s", resourceID, entry) + } + } + lines = append(lines, "- "+entry) + } + return strings.Join(lines, "\n") +} + +func formatMemoryRecentChangesContext(changes []memory.Change, includeResourcePrefix bool) string { + if len(changes) == 0 { + return "" + } + + heading := "\n## Recent Changes" + if includeResourcePrefix { + heading = "\n## Recent Changes Across Infrastructure" + } + + lines := []string{heading, "What changed recently:"} + for _, change := range changes { + entry := fmt.Sprintf("**%s** %s", formatMemoryChangeTypeLabel(change.ChangeType), change.Description) + if includeResourcePrefix { + scope := strings.TrimSpace(change.ResourceID) + if scope == "" { + scope = strings.TrimSpace(change.ResourceName) + } + if scope == "" { + scope = "resource" + } + if resourceType := strings.TrimSpace(change.ResourceType); resourceType != "" && !strings.Contains(scope, resourceType) { + scope = fmt.Sprintf("%s (%s)", scope, resourceType) + } + entry = fmt.Sprintf("%s: %s", scope, entry) + } + ago := time.Since(change.DetectedAt).Truncate(time.Minute) + lines = append(lines, fmt.Sprintf("- %s (%s ago)", entry, formatDuration(ago))) + } + return strings.Join(lines, "\n") +} + +func formatMemoryChangeTypeLabel(changeType memory.ChangeType) string { + switch changeType { + case memory.ChangeCreated: + return "Created" + case memory.ChangeDeleted: + return "Deleted" + case memory.ChangeConfig: + return "Config update" + case memory.ChangeStatus: + return "Status change" + case memory.ChangeMigrated: + return "Migration" + case memory.ChangeRestarted: + return "Restart" + case memory.ChangeBackedUp: + return "Backup" + default: + raw := strings.TrimSpace(strings.ReplaceAll(string(changeType), "_", " ")) + if raw == "" { + return "Change" + } + return strings.ToUpper(raw[:1]) + raw[1:] + } +} + // RecordLearning saves a learning to the knowledge store after a fix func (i *Intelligence) RecordLearning(resourceID, resourceName, resourceType, title, content string) error { i.mu.RLock() @@ -913,11 +1056,15 @@ func (i *Intelligence) detectCurrentAnomalies(resourceID string) []AnomalyReport } func (i *Intelligence) formatBaselinesForContext(resourceID string) string { - if i.baselines == nil { + i.mu.RLock() + store := i.baselines + i.mu.RUnlock() + + if store == nil { return "" } - rb, ok := i.baselines.GetResourceBaseline(resourceID) + rb, ok := store.GetResourceBaseline(resourceID) if !ok || len(rb.Metrics) == 0 { return "" } diff --git a/internal/ai/intelligence_test.go b/internal/ai/intelligence_test.go index a22569486..d64f9595f 100644 --- a/internal/ai/intelligence_test.go +++ b/internal/ai/intelligence_test.go @@ -10,6 +10,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" "github.com/rcourtman/pulse-go-rewrite/internal/ai/patterns" + ur "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) func TestNewIntelligence(t *testing.T) { @@ -330,6 +331,99 @@ func TestIntelligence_FormatContext_WithKnowledge(t *testing.T) { } } +func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) + changes.DetectChanges([]memory.ResourceSnapshot{ + {ID: "vm-300", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, + }) + intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) + + canonicalStore := ur.NewMemoryStore() + if err := canonicalStore.RecordChange(ur.ResourceChange{ + ID: "change-1", + ObservedAt: time.Now().Add(-time.Hour), + ResourceID: "vm-300", + Kind: ur.ChangeConfigUpdate, + SourceType: ur.SourcePulseDiff, + SourceAdapter: ur.AdapterOpsAgent, + Reason: "canonical refresh", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + intel.SetResourceTimelineStore(canonicalStore, "org-1") + + ctx := intel.FormatContext("vm-300") + if !strings.Contains(ctx, "Recent Changes") { + t.Fatalf("expected recent changes section, got %q", ctx) + } + if !strings.Contains(ctx, "canonical refresh") { + t.Fatalf("expected canonical timeline entry, got %q", ctx) + } +} + +func TestIntelligence_FormatContext_FallsBackToChangeDetector(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) + changes.DetectChanges([]memory.ResourceSnapshot{ + {ID: "vm-301", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, + }) + intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) + + ctx := intel.FormatContext("vm-301") + if !strings.Contains(ctx, "Recent Changes") { + t.Fatalf("expected recent changes section, got %q", ctx) + } + if !strings.Contains(ctx, "fallback-vm") { + t.Fatalf("expected fallback change detector entry, got %q", ctx) + } +} + +func TestIntelligence_FormatGlobalContext_IncludesCanonicalRecentChanges(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + canonicalStore := ur.NewMemoryStore() + if err := canonicalStore.RecordChange(ur.ResourceChange{ + ID: "change-1", + ObservedAt: time.Now().Add(-time.Hour), + ResourceID: "node-1", + Kind: ur.ChangeRestart, + SourceType: ur.SourcePlatformEvent, + SourceAdapter: ur.AdapterProxmox, + Reason: "node restarted after maintenance", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + intel.SetResourceTimelineStore(canonicalStore, "org-1") + + ctx := intel.FormatGlobalContext() + if !strings.Contains(ctx, "Recent Changes Across Infrastructure") { + t.Fatalf("expected global recent changes section, got %q", ctx) + } + if !strings.Contains(ctx, "node-1") { + t.Fatalf("expected resource identifier in global timeline, got %q", ctx) + } + if !strings.Contains(ctx, "node restarted after maintenance") { + t.Fatalf("expected canonical global timeline entry, got %q", ctx) + } +} + +func TestIntelligence_FormatGlobalContext_FallsBackToChangeDetector(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) + changes.DetectChanges([]memory.ResourceSnapshot{ + {ID: "node-fallback", Name: "fallback-node", Type: "node", Status: "running", SnapshotTime: time.Now()}, + }) + intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) + + ctx := intel.FormatGlobalContext() + if !strings.Contains(ctx, "Recent Changes Across Infrastructure") { + t.Fatalf("expected global recent changes section, got %q", ctx) + } + if !strings.Contains(ctx, "fallback-node") { + t.Fatalf("expected fallback global timeline entry, got %q", ctx) + } +} + func TestIntelligence_CreatePredictionFinding_LowSeverity(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{})