From 399f5d64dcea9cde97f367642193d971c7755739 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 19 Mar 2026 02:53:50 +0000 Subject: [PATCH] Share recent change section formatting --- .../v6/internal/subsystems/ai-runtime.md | 4 +++ .../subsystems/patrol-intelligence.md | 3 ++ .../internal/subsystems/unified-resources.md | 3 ++ internal/ai/intelligence.go | 25 +---------------- internal/ai/intelligence_test.go | 27 ++++++++++++++++++ internal/ai/service.go | 12 +++----- .../unifiedresources/change_presentation.go | 28 +++++++++++++++++++ .../change_presentation_test.go | 24 ++++++++++++++++ .../unifiedresources/code_standards_test.go | 5 ++-- 9 files changed, 96 insertions(+), 35 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 7d84713ee..5222b7eb3 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -172,6 +172,10 @@ The canonical recent-change sentence formatting also lives in `internal/unifiedresources.FormatResourceChangeSummary`, so AI runtime prompt sections and Patrol seed context reuse the same change wording instead of keeping another lane-local formatter. +The canonical recent-change section wrapper also lives in +`internal/unifiedresources.FormatResourceRecentChangesContext`, so the AI +summary and resource-specific context share the same heading and prefix rules +instead of rebuilding that section layout locally. The related-resource correlation section now also comes from the shared correlation formatter in `internal/ai/correlation`, so resource chat and incident prompts reuse the same learned-edge wording instead of rebuilding a diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 5042eca95..56c45d7e8 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -152,6 +152,9 @@ That same helper now also owns the one-line recent-change summary text used by the AI runtime prompt sections and Patrol seed context, so the change wording itself stays canonical before the surrounding section headers are applied. +The same helper now also owns the canonical recent-change section wrapper, +so the Patrol page and AI runtime can share the same heading and resource +prefix rules instead of rebuilding that section locally. The canonical shared AI resource context now also surfaces policy routing and redaction hints from unified resources, so the Patrol page and resource drawer see the same governance posture that the runtime uses for export boundaries. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5fcf55d36..d2ced07db 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -101,6 +101,9 @@ That same change-presentation helper now also owns the one-line `FormatResourceChangeSummary` used by AI runtime recent-change sections and Patrol seed context, so the change wording itself stays canonical before any section-specific headings are applied. +The same helper also owns `FormatResourceRecentChangesContext`, so AI runtime +callers share the canonical recent-change section heading and resource +prefixing instead of rebuilding that wrapper locally. The backend AI and Patrol graph context renderers now derive their canonical relationship labels, direction, provenance, freshness, and metadata flags from `internal/unifiedresources/relationship_presentation.go`, so the graph diff --git a/internal/ai/intelligence.go b/internal/ai/intelligence.go index 915270a6a..36ce8fd65 100644 --- a/internal/ai/intelligence.go +++ b/internal/ai/intelligence.go @@ -518,7 +518,7 @@ func (i *Intelligence) buildRecentChangesContext(resourceID string, resourceTime if resourceTimelineStore != nil { if recent, err := resourceTimelineStore.GetRecentChanges(resourceID, since, limit); err == nil && len(recent) > 0 { - return formatCanonicalRecentChangesContext(recent, includeResourcePrefix) + return unifiedresources.FormatResourceRecentChangesContext(recent, includeResourcePrefix, "##") } } @@ -538,29 +538,6 @@ func (i *Intelligence) buildRecentChangesContext(resourceID string, resourceTime 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 := unifiedresources.FormatResourceChangeSummary(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 "" diff --git a/internal/ai/intelligence_test.go b/internal/ai/intelligence_test.go index 6598e67d5..da6e84d69 100644 --- a/internal/ai/intelligence_test.go +++ b/internal/ai/intelligence_test.go @@ -458,6 +458,33 @@ func TestIntelligence_FormatGlobalContext_FallsBackToChangeDetector(t *testing.T } } +func TestIntelligence_BuildRecentChangesContext_UsesCanonicalSectionFormatter(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + store := ur.NewMemoryStore() + if err := store.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) + } + + ctx := intel.buildRecentChangesContext("node-1", store, nil, true, 5) + for _, want := range []string{ + "## Recent Changes Across Infrastructure", + "node-1: **Restart**", + "platform_event/proxmox_adapter", + } { + if !strings.Contains(ctx, want) { + t.Fatalf("expected canonical recent-changes context %q to contain %q", ctx, want) + } + } +} + func TestIntelligence_CreatePredictionFinding_LowSeverity(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) diff --git a/internal/ai/service.go b/internal/ai/service.go index f09405961..eeb5115d9 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -4582,15 +4582,11 @@ func (s *Service) buildRecentResourceChangesContext(resourceID string) string { Str("resource_id", resourceID). Msg("failed to load canonical resource timeline context") } else if len(changes) > 0 { - var changeInfo []string - for _, change := range changes { - if len(changeInfo) >= 3 { - break - } - changeInfo = append(changeInfo, unifiedresources.FormatResourceChangeSummary(change)) + if len(changes) > 3 { + changes = changes[:3] } - if len(changeInfo) > 0 { - return "\n\n### Recent Changes\n" + strings.Join(changeInfo, "\n") + if section := unifiedresources.FormatResourceRecentChangesContext(changes, false, "###"); section != "" { + return section } } } diff --git a/internal/unifiedresources/change_presentation.go b/internal/unifiedresources/change_presentation.go index 859692e73..e83ca46ba 100644 --- a/internal/unifiedresources/change_presentation.go +++ b/internal/unifiedresources/change_presentation.go @@ -114,3 +114,31 @@ func FormatResourceChangeSummary(change ResourceChange) string { } return summary + fmt.Sprintf(" (%s ago)", ago) } + +// FormatResourceRecentChangesContext returns the canonical markdown section +// used by AI prompt surfaces for recent unified-resource changes. +func FormatResourceRecentChangesContext(changes []ResourceChange, includeResourcePrefix bool, headingLevel string) string { + if len(changes) == 0 { + return "" + } + if strings.TrimSpace(headingLevel) == "" { + headingLevel = "##" + } + + heading := fmt.Sprintf("\n%s Recent Changes", headingLevel) + if includeResourcePrefix { + heading = fmt.Sprintf("\n%s Recent Changes Across Infrastructure", headingLevel) + } + + lines := []string{heading, "What changed recently:"} + for _, change := range changes { + entry := FormatResourceChangeSummary(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") +} diff --git a/internal/unifiedresources/change_presentation_test.go b/internal/unifiedresources/change_presentation_test.go index aa1c2cb70..ca5a24782 100644 --- a/internal/unifiedresources/change_presentation_test.go +++ b/internal/unifiedresources/change_presentation_test.go @@ -92,3 +92,27 @@ func TestFormatResourceChangeSummary(t *testing.T) { } } } + +func TestFormatResourceRecentChangesContext(t *testing.T) { + changes := []ResourceChange{ + { + Kind: ChangeRestart, + ResourceID: "vm-1", + SourceType: SourcePlatformEvent, + SourceAdapter: AdapterProxmox, + Reason: "maintenance", + ObservedAt: time.Now().Add(-time.Hour), + }, + } + + ctx := FormatResourceRecentChangesContext(changes, true, "###") + for _, want := range []string{ + "### Recent Changes Across Infrastructure", + "vm-1: **Restart**", + "platform_event/proxmox_adapter", + } { + if !strings.Contains(ctx, want) { + t.Fatalf("expected recent changes context %q to contain %q", ctx, want) + } + } +} diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index e0dfa0afd..f78b3eb01 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -374,7 +374,7 @@ func TestResourceGraphContextUsesCanonicalRelationshipPresentation(t *testing.T) "func (s *Service) buildResourceGraphContext(resourceID string) string", "if graphContext := s.buildResourceGraphContext(resourceID); graphContext != \"\" {", "unifiedresources.FormatResourceGraphContext(resource, 3)", - "unifiedresources.FormatResourceChangeSummary(change)", + "unifiedresources.FormatResourceRecentChangesContext(changes, false, \"###\")", "type canonicalResourceGetter interface {", "correlationDetector.FormatForContext(resourceID)", } @@ -410,8 +410,7 @@ func TestIntelligenceRecentChangesUseCanonicalSummaryFormatter(t *testing.T) { } source := string(data) requiredSnippets := []string{ - "unifiedresources.FormatResourceChangeSummary(change)", - "formatCanonicalRecentChangesContext(recent, includeResourcePrefix)", + "unifiedresources.FormatResourceRecentChangesContext(recent, includeResourcePrefix, \"##\")", } for _, snippet := range requiredSnippets { if !strings.Contains(source, snippet) {