mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Move resource graph formatting into unified resources
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user