Move resource graph formatting into unified resources

This commit is contained in:
rcourtman
2026-03-19 02:45:44 +00:00
parent 8f499b13d3
commit 064f2d2baa
8 changed files with 133 additions and 42 deletions
@@ -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
@@ -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
@@ -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
+3
View File
@@ -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)
}
+2 -40
View File
@@ -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
@@ -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)",
}
@@ -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)
}
@@ -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
}