Surface canonical recent changes in intelligence context

This commit is contained in:
rcourtman
2026-03-19 00:04:32 +00:00
parent 2121e57d74
commit eaf702c358
4 changed files with 272 additions and 23 deletions
@@ -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.
@@ -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.
+170 -23
View File
@@ -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 ""
}
+94
View File
@@ -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{})