diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 62e5fd836..30bcdef7d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -167,3 +167,6 @@ The per-resource intelligence payload returned from `/api/ai/intelligence?resource_id=...` should also include the canonical `recent_changes` history so UI and API consumers can read the same timeline slice that the prompt context uses. +The system-wide `/api/ai/intelligence` summary should also surface the same +canonical recent-change slice, alongside the count, so the aggregate payload +and the prompt context stay aligned on the same shared timeline source. diff --git a/internal/ai/intelligence.go b/internal/ai/intelligence.go index 21f98adfb..61552e784 100644 --- a/internal/ai/intelligence.go +++ b/internal/ai/intelligence.go @@ -98,8 +98,9 @@ type IntelligenceSummary struct { UpcomingRisks []patterns.FailurePrediction `json:"upcoming_risks,omitempty"` // Recent activity - RecentChangesCount int `json:"recent_changes_count"` - RecentRemediations []memory.RemediationRecord `json:"recent_remediations,omitempty"` + RecentChangesCount int `json:"recent_changes_count"` + RecentChanges []unifiedresources.ResourceChange `json:"recent_changes,omitempty"` + RecentRemediations []memory.RemediationRecord `json:"recent_remediations,omitempty"` // Learning progress Learning LearningStats `json:"learning"` @@ -247,12 +248,22 @@ func (i *Intelligence) GetSummary() *IntelligenceSummary { if resourceTimelineStore != nil { if recent, err := resourceTimelineStore.GetRecentChanges("", time.Now().Add(-24*time.Hour), 100); err == nil { summary.RecentChangesCount = len(recent) + if len(recent) > 0 { + summary.RecentChanges = append([]unifiedresources.ResourceChange{}, recent[:min(len(recent), 5)]...) + } usedCanonicalRecentChanges = true } } if !usedCanonicalRecentChanges && changes != nil { recent := changes.GetRecentChanges(100, time.Now().Add(-24*time.Hour)) summary.RecentChangesCount = len(recent) + if len(recent) > 0 { + converted := make([]unifiedresources.ResourceChange, 0, len(recent)) + for _, change := range recent { + converted = append(converted, convertMemoryChangeToResourceChange(change)) + } + summary.RecentChanges = append([]unifiedresources.ResourceChange{}, converted[:min(len(converted), 5)]...) + } } if remediations != nil { diff --git a/internal/ai/intelligence_coverage_test.go b/internal/ai/intelligence_coverage_test.go index 3ea7621b9..5e0ff7802 100644 --- a/internal/ai/intelligence_coverage_test.go +++ b/internal/ai/intelligence_coverage_test.go @@ -466,6 +466,12 @@ func TestIntelligence_GetSummary_WithSubsystems(t *testing.T) { if summary.RecentChangesCount == 0 { t.Error("expected recent changes in summary") } + if len(summary.RecentChanges) == 0 { + t.Error("expected recent change entries in summary") + } + if summary.RecentChanges[0].SourceType != ur.SourceHeuristic { + t.Fatalf("expected heuristic source type for fallback summary, got %s", summary.RecentChanges[0].SourceType) + } if len(summary.RecentRemediations) == 0 { t.Error("expected recent remediations in summary") } @@ -495,6 +501,12 @@ func TestIntelligence_GetSummary_UsesCanonicalResourceTimeline(t *testing.T) { if summary.RecentChangesCount != 1 { t.Fatalf("expected canonical recent change count, got %d", summary.RecentChangesCount) } + if len(summary.RecentChanges) != 1 { + t.Fatalf("expected canonical recent change slice, got %d", len(summary.RecentChanges)) + } + if summary.RecentChanges[0].Kind != ur.ChangeConfigUpdate { + t.Fatalf("expected canonical recent change kind, got %s", summary.RecentChanges[0].Kind) + } } func TestIntelligence_GetResourceIntelligence_WithAllSubsystems(t *testing.T) { diff --git a/internal/api/ai_handlers_intelligence_additional_test.go b/internal/api/ai_handlers_intelligence_additional_test.go index 6dd97f023..46e39a2c9 100644 --- a/internal/api/ai_handlers_intelligence_additional_test.go +++ b/internal/api/ai_handlers_intelligence_additional_test.go @@ -89,6 +89,19 @@ func TestHandleGetIntelligence_ResourceIDTooLong(t *testing.T) { func TestHandleGetIntelligence_WithPatrolService(t *testing.T) { t.Parallel() svc := newEnabledAIService(t) + canonicalStore := ur.NewMemoryStore() + if err := canonicalStore.RecordChange(ur.ResourceChange{ + ID: "change-summary", + ObservedAt: time.Now().Add(-20 * time.Minute), + ResourceID: "vm-summary", + Kind: ur.ChangeRestart, + SourceType: ur.SourcePlatformEvent, + Reason: "guest restarted", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + setUnexportedField(t, svc, "resourceExportStore", canonicalStore) + setUnexportedField(t, svc.GetPatrolService(), "aiService", svc) handler := &AISettingsHandler{defaultAIService: svc} req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence", nil) @@ -107,6 +120,13 @@ func TestHandleGetIntelligence_WithPatrolService(t *testing.T) { if _, ok := payload["timestamp"]; !ok { t.Fatalf("expected timestamp in intelligence summary, got %v", payload) } + recentChanges, ok := payload["recent_changes"].([]interface{}) + if !ok { + t.Fatalf("expected recent_changes array in summary response, got %T", payload["recent_changes"]) + } + if len(recentChanges) != 1 { + t.Fatalf("expected 1 recent change in summary, got %d", len(recentChanges)) + } } func TestHandleGetIntelligence_WithResourceID(t *testing.T) { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 685e91049..ad5cda841 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -323,6 +323,45 @@ func TestContract_ResourceIntelligenceIncludesRecentChanges(t *testing.T) { } } +func TestContract_IntelligenceSummaryIncludesRecentChanges(t *testing.T) { + svc := newEnabledAIService(t) + canonicalStore := unifiedresources.NewMemoryStore() + if err := canonicalStore.RecordChange(unifiedresources.ResourceChange{ + ID: "change-summary", + ObservedAt: time.Now().Add(-15 * time.Minute), + ResourceID: "vm-100", + Kind: unifiedresources.ChangeRestart, + SourceType: unifiedresources.SourcePlatformEvent, + Reason: "guest restarted", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + setUnexportedField(t, svc, "resourceExportStore", canonicalStore) + setUnexportedField(t, svc.GetPatrolService(), "aiService", svc) + + handlers := &AISettingsHandler{defaultAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence", nil) + rec := httptest.NewRecorder() + + handlers.HandleGetIntelligence(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + + var payload map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + recentChanges, ok := payload["recent_changes"].([]interface{}) + if !ok { + t.Fatalf("expected recent_changes array in response, got %T", payload["recent_changes"]) + } + if len(recentChanges) != 1 { + t.Fatalf("expected 1 recent change, got %d", len(recentChanges)) + } +} + func TestContract_ResolveAuthEnvPathUsesCanonicalRuntimeDataDir(t *testing.T) { envDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", envDir)