mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Surface canonical recent changes in intelligence context
This commit is contained in:
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user