From 064f2d2baa3793f233cbf0c0142883ba2f667ec5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 19 Mar 2026 02:45:44 +0000 Subject: [PATCH] Move resource graph formatting into unified resources --- .../v6/internal/subsystems/ai-runtime.md | 4 ++ .../subsystems/patrol-intelligence.md | 4 ++ .../internal/subsystems/unified-resources.md | 4 ++ internal/ai/coverage_increase_test.go | 3 + internal/ai/service.go | 42 +----------- .../unifiedresources/code_standards_test.go | 3 +- .../relationship_presentation.go | 66 +++++++++++++++++++ .../relationship_presentation_test.go | 49 ++++++++++++++ 8 files changed, 133 insertions(+), 42 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 7cc8c9595..5fb45e706 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -164,6 +164,10 @@ AI resource and incident context now also surfaces a canonical resource-graph section from unified-resource relationships, so relationship wording and edge provenance stay aligned with the same shared resource model instead of being reconstructed from the drawer or prompt helpers. +That graph section is now rendered by the shared +`internal/unifiedresources.FormatResourceGraphContext` helper, so the service +layer only resolves the canonical resource and does not rebuild the section +format 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 5dedabdef..3bc9ff88a 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -85,6 +85,10 @@ Those same Patrol-owned prompt contexts now also surface a canonical resource-graph section from unified-resource relationships, so edge labels, directionality, and provenance stay aligned with the shared graph model instead of being reconstructed locally. +That graph section is now rendered by the shared +`internal/unifiedresources.FormatResourceGraphContext` helper, so the Patrol +runtime only resolves the canonical resource graph rather than formatting the +relationship section itself. The Patrol seed context and AI runtime prompt path now also share the same correlation summary formatter from `internal/ai/correlation`, so learned-edge wording and confidence/count annotations stay canonical across the prompt diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 55cfeb83a..c7d33f505 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -102,6 +102,10 @@ relationship labels, direction, provenance, freshness, and metadata flags from `internal/unifiedresources/relationship_presentation.go`, so the graph semantics live with the resource model instead of being duplicated in prompt helpers or drawer-specific markdown. +That same resource model now also owns the canonical +`FormatResourceGraphContext` helper, so service-layer callers only resolve the +resource and hand the model the relationship list instead of rebuilding the +graph section header, ordering, or freshness wording locally. The same AI resource-intelligence payload now also carries canonical correlation evidence from the shared detector, so the drawer can show learned edge patterns alongside the dependency graph without rebuilding correlation diff --git a/internal/ai/coverage_increase_test.go b/internal/ai/coverage_increase_test.go index 4e57868e2..dbf478eb8 100644 --- a/internal/ai/coverage_increase_test.go +++ b/internal/ai/coverage_increase_test.go @@ -533,6 +533,9 @@ func TestService_BuildResourceGraphContext_UsesCanonicalReadState(t *testing.T) if !strings.Contains(resourceCtx, "discoverer proxmox_adapter") { t.Fatalf("expected enriched resource context to include provenance, got %q", resourceCtx) } + if !strings.Contains(resourceCtx, "metadata present") { + t.Fatalf("expected enriched resource context to include shared graph metadata marker, got %q", resourceCtx) + } if !strings.Contains(resourceCtx, "Resource Correlations") { t.Fatalf("expected enriched resource context to include correlation section, got %q", resourceCtx) } diff --git a/internal/ai/service.go b/internal/ai/service.go index 431ac39a9..c98c4ba15 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -4647,48 +4647,10 @@ func (s *Service) buildResourceGraphContext(resourceID string) string { } resource, ok := getter.Get(resourceID) - if !ok || resource == nil || len(resource.Relationships) == 0 { + if !ok || resource == nil { return "" } - - relationshipLimit := len(resource.Relationships) - if relationshipLimit > 3 { - relationshipLimit = 3 - } - lines := make([]string, 0, relationshipLimit) - for _, rel := range resource.Relationships { - if len(lines) >= 3 { - break - } - presentation := unifiedresources.DescribeRelationship(rel) - parts := []string{ - fmt.Sprintf("**%s** %s", presentation.TypeLabel, presentation.Direction), - } - if presentation.StateLabel != "" { - parts = append(parts, presentation.StateLabel) - } - if presentation.Provenance != "" { - parts = append(parts, fmt.Sprintf("discoverer %s", presentation.Provenance)) - } - if presentation.Confidence != "" { - parts = append(parts, fmt.Sprintf("confidence %s", presentation.Confidence)) - } - if !rel.ObservedAt.IsZero() { - parts = append(parts, fmt.Sprintf("observed %s ago", formatDuration(time.Since(rel.ObservedAt).Truncate(time.Minute)))) - } - if !rel.LastSeenAt.IsZero() { - parts = append(parts, fmt.Sprintf("last seen %s ago", formatDuration(time.Since(rel.LastSeenAt).Truncate(time.Minute)))) - } - if presentation.HasMetadata { - parts = append(parts, "metadata present") - } - lines = append(lines, strings.Join(parts, "; ")) - } - - if len(lines) == 0 { - return "" - } - return "\n\n### Resource Graph\n" + strings.Join(lines, "\n") + return unifiedresources.FormatResourceGraphContext(resource, 3) } // truncateString truncates a string to maxLen characters diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 10ccf60b4..804361ef6 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -372,8 +372,7 @@ func TestResourceGraphContextUsesCanonicalRelationshipPresentation(t *testing.T) requiredSnippets := []string{ "func (s *Service) buildResourceGraphContext(resourceID string) string", "if graphContext := s.buildResourceGraphContext(resourceID); graphContext != \"\" {", - "unifiedresources.DescribeRelationship(rel)", - "### Resource Graph", + "unifiedresources.FormatResourceGraphContext(resource, 3)", "type canonicalResourceGetter interface {", "correlationDetector.FormatForContext(resourceID)", } diff --git a/internal/unifiedresources/relationship_presentation.go b/internal/unifiedresources/relationship_presentation.go index 8b0bd1728..480ee6c1e 100644 --- a/internal/unifiedresources/relationship_presentation.go +++ b/internal/unifiedresources/relationship_presentation.go @@ -3,6 +3,7 @@ package unifiedresources import ( "fmt" "strings" + "time" ) // RelationshipPresentation captures the canonical human-readable fragments for @@ -64,3 +65,68 @@ func DescribeRelationship(rel ResourceRelationship) RelationshipPresentation { return presentation } + +// FormatResourceGraphContext returns the canonical AI prompt section for a +// resource's learned relationships. +func FormatResourceGraphContext(resource *Resource, limit int) string { + if resource == nil || limit <= 0 || len(resource.Relationships) == 0 { + return "" + } + + if limit > len(resource.Relationships) { + limit = len(resource.Relationships) + } + + lines := make([]string, 0, limit) + for _, rel := range resource.Relationships { + if len(lines) >= limit { + break + } + presentation := DescribeRelationship(rel) + parts := []string{ + fmt.Sprintf("**%s** %s", presentation.TypeLabel, presentation.Direction), + } + if presentation.StateLabel != "" { + parts = append(parts, presentation.StateLabel) + } + if presentation.Provenance != "" { + parts = append(parts, fmt.Sprintf("discoverer %s", presentation.Provenance)) + } + if presentation.Confidence != "" { + parts = append(parts, fmt.Sprintf("confidence %s", presentation.Confidence)) + } + if !rel.ObservedAt.IsZero() { + parts = append(parts, fmt.Sprintf("observed %s ago", formatDuration(time.Since(rel.ObservedAt).Truncate(time.Minute)))) + } + if !rel.LastSeenAt.IsZero() { + parts = append(parts, fmt.Sprintf("last seen %s ago", formatDuration(time.Since(rel.LastSeenAt).Truncate(time.Minute)))) + } + if presentation.HasMetadata { + parts = append(parts, "metadata present") + } + lines = append(lines, strings.Join(parts, "; ")) + } + + if len(lines) == 0 { + return "" + } + return "\n\n### Resource Graph\n" + strings.Join(lines, "\n") +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return "seconds" + } + if d < time.Hour { + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", mins) + } + hours := int(d.Hours()) + if hours == 1 { + return "1 hour" + } + return fmt.Sprintf("%d hours", hours) +} diff --git a/internal/unifiedresources/relationship_presentation_test.go b/internal/unifiedresources/relationship_presentation_test.go index 9c02e523b..2dbbcdc46 100644 --- a/internal/unifiedresources/relationship_presentation_test.go +++ b/internal/unifiedresources/relationship_presentation_test.go @@ -50,3 +50,52 @@ func TestDescribeRelationship(t *testing.T) { t.Fatalf("expected metadata flag to be set") } } + +func TestFormatResourceGraphContext(t *testing.T) { + resource := &Resource{ + Relationships: []ResourceRelationship{ + { + SourceID: "node-1", + TargetID: "vm-1", + Type: RelRunsOn, + Confidence: 0.85, + Active: true, + Discoverer: "docker_adapter", + Metadata: map[string]any{"region": "lab"}, + }, + { + SourceID: "node-1", + TargetID: "storage-1", + Type: RelDependsOn, + Confidence: 0.5, + Active: false, + }, + }, + } + + ctx := FormatResourceGraphContext(resource, 1) + if ctx == "" { + t.Fatal("expected graph context") + } + if want := "### Resource Graph"; !contains(ctx, want) { + t.Fatalf("expected %q in graph context, got %q", want, ctx) + } + if !contains(ctx, "Runs on") { + t.Fatalf("expected canonical relationship label, got %q", ctx) + } + if !contains(ctx, "discoverer docker_adapter") { + t.Fatalf("expected provenance in graph context, got %q", ctx) + } + if contains(ctx, "Depends on") { + t.Fatalf("expected graph limit to truncate entries, got %q", ctx) + } +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}