Add recent changes to intelligence summary

This commit is contained in:
rcourtman
2026-03-19 00:18:28 +00:00
parent 12e705df1e
commit 771f2a3e41
5 changed files with 87 additions and 2 deletions
@@ -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.
+13 -2
View File
@@ -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 {
+12
View File
@@ -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) {
@@ -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) {
+39
View File
@@ -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)