mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Route correlations through intelligence facade
This commit is contained in:
parent
f12d6b2838
commit
c4c154efcf
13 changed files with 224 additions and 18 deletions
|
|
@ -171,6 +171,9 @@ That same boundary now also assumes the Patrol-backed recent-changes API
|
|||
surface reads through the canonical intelligence facade first, so adjacent
|
||||
fleet and install surfaces do not bypass the shared unified timeline through
|
||||
the old detector-only handler path.
|
||||
The Patrol-backed correlation API surface must follow the same canonical
|
||||
intelligence-facade path, so fleet and install surfaces do not need to know
|
||||
about the detector directly when they render learned relationship context.
|
||||
That same canonical /api/auto-register response must stay on one completion
|
||||
truth: caller-supplied Proxmox credentials complete registration with a
|
||||
direct-use action, and the runtime no longer preserves a dead pending-secret
|
||||
|
|
|
|||
|
|
@ -161,6 +161,11 @@ The AI correlation root-cause engine also consumes the canonical unified-
|
|||
resource relationship model directly, so cross-resource reasoning stays aligned
|
||||
with the same graph edges that back the resource API instead of maintaining a
|
||||
parallel relationship vocabulary inside AI correlation.
|
||||
The Patrol-backed correlation endpoint, resource-intelligence payload, and
|
||||
seed prompt correlations now flow through the shared AI intelligence facade
|
||||
first, so the detector remains an implementation detail behind one canonical
|
||||
correlation access path instead of being routed directly by handlers or prompt
|
||||
builders.
|
||||
AI-facing policy metadata must also be cloned through the shared unified-
|
||||
resource policy helper so chat and tools consumers do not maintain their own
|
||||
policy copy logic. Chat mention prefetch now calls that shared helper directly
|
||||
|
|
|
|||
|
|
@ -195,6 +195,10 @@ through the shared `frontend-modern/src/stores/aiIntelligence.ts` store for
|
|||
the Patrol intelligence page and the AI summary page, so the
|
||||
learned-correlation list is governed by the same API contract that backs the
|
||||
resource drawer's graph evidence instead of being fetched as page-local state.
|
||||
That correlations route now reads through the canonical AI intelligence
|
||||
facade first, so the handler and its payload keep the detector behind one
|
||||
shared access layer instead of routing directly to Patrol-local correlation
|
||||
state.
|
||||
That store now also owns the dashboard load bundle used by the Patrol page,
|
||||
so the page refresh path stays aligned on one store-owned orchestration layer
|
||||
instead of re-encoding the AI bundle inline.
|
||||
|
|
|
|||
|
|
@ -100,6 +100,11 @@ 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.
|
||||
Patrol-owned correlation context now also comes through the shared AI
|
||||
intelligence facade before reaching the detector, so the learned correlation
|
||||
surface is routed through the same canonical AI ownership boundary as recent
|
||||
changes and resource graph data instead of being pulled from the detector
|
||||
directly in each caller.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ That same shared dependency also assumes the Patrol-backed recent-changes
|
|||
API surface reads through the canonical intelligence facade first, so
|
||||
storage and recovery handlers do not bypass the shared unified timeline
|
||||
through the older detector-only path.
|
||||
The same shared boundary applies to the Patrol-backed correlation API
|
||||
surface, which must read through the canonical intelligence facade before it
|
||||
exposes learned relationship context to adjacent storage and recovery flows.
|
||||
The same shared API runtime also exposes unified-resource action, lifecycle,
|
||||
and export audit reads, but storage and recovery must continue to treat that
|
||||
as adjacent governed API ownership rather than timeline-store ownership. The
|
||||
|
|
|
|||
|
|
@ -768,6 +768,44 @@ func (i *Intelligence) getLearningStats() LearningStats {
|
|||
return stats
|
||||
}
|
||||
|
||||
// HasCorrelationsSource reports whether the intelligence layer can provide
|
||||
// learned correlation data.
|
||||
func (i *Intelligence) HasCorrelationsSource() bool {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
return i.correlations != nil
|
||||
}
|
||||
|
||||
// GetCorrelations returns canonical learned correlations for the optional
|
||||
// resource scope.
|
||||
func (i *Intelligence) GetCorrelations(resourceID string) []*correlation.Correlation {
|
||||
i.mu.RLock()
|
||||
detector := i.correlations
|
||||
i.mu.RUnlock()
|
||||
|
||||
if detector == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resourceID = strings.TrimSpace(resourceID)
|
||||
if resourceID != "" {
|
||||
return detector.GetCorrelationsForResource(resourceID)
|
||||
}
|
||||
return detector.GetCorrelations()
|
||||
}
|
||||
|
||||
// FormatCorrelationsContext returns the canonical AI prompt section for learned
|
||||
// correlations.
|
||||
func (i *Intelligence) FormatCorrelationsContext(resourceID string) string {
|
||||
i.mu.RLock()
|
||||
detector := i.correlations
|
||||
i.mu.RUnlock()
|
||||
if detector == nil {
|
||||
return ""
|
||||
}
|
||||
return detector.FormatForContext(resourceID)
|
||||
}
|
||||
|
||||
func (i *Intelligence) calculateOverallHealth(summary *IntelligenceSummary) HealthScore {
|
||||
health := HealthScore{
|
||||
Score: 100,
|
||||
|
|
|
|||
|
|
@ -389,6 +389,55 @@ func TestIntelligence_GetRecentChanges_FallsBackToMemoryDetector(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIntelligence_GetCorrelations_UsesCanonicalFacade(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
detector := correlation.NewDetector(correlation.Config{
|
||||
MaxEvents: 10,
|
||||
CorrelationWindow: 2 * time.Hour,
|
||||
MinOccurrences: 1,
|
||||
RetentionWindow: 24 * time.Hour,
|
||||
})
|
||||
base := time.Now().Add(-30 * time.Minute)
|
||||
detector.RecordEvent(correlation.Event{
|
||||
ResourceID: "node-1",
|
||||
ResourceName: "node-1",
|
||||
ResourceType: "node",
|
||||
EventType: correlation.EventHighCPU,
|
||||
Timestamp: base,
|
||||
})
|
||||
detector.RecordEvent(correlation.Event{
|
||||
ResourceID: "vm-1",
|
||||
ResourceName: "vm-1",
|
||||
ResourceType: "vm",
|
||||
EventType: correlation.EventRestart,
|
||||
Timestamp: base.Add(1 * time.Minute),
|
||||
})
|
||||
intel.SetSubsystems(nil, nil, detector, nil, nil, nil, nil, nil)
|
||||
|
||||
if !intel.HasCorrelationsSource() {
|
||||
t.Fatal("expected correlation source to be available")
|
||||
}
|
||||
|
||||
correlations := intel.GetCorrelations("vm-1")
|
||||
if len(correlations) != 1 {
|
||||
t.Fatalf("expected 1 canonical correlation, got %d", len(correlations))
|
||||
}
|
||||
if correlations[0].TargetID != "vm-1" {
|
||||
t.Fatalf("target id = %q, want vm-1", correlations[0].TargetID)
|
||||
}
|
||||
|
||||
ctx := intel.FormatCorrelationsContext("vm-1")
|
||||
for _, want := range []string{
|
||||
"## Resource Correlations",
|
||||
"node-1",
|
||||
"vm-1",
|
||||
} {
|
||||
if !strings.Contains(ctx, want) {
|
||||
t.Fatalf("expected canonical correlation context %q to contain %q", ctx, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntelligence_DescribeResource_UsesUnifiedProvider(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
intel.SetUnifiedResourceProvider(&mockUnifiedResourceProvider{
|
||||
|
|
|
|||
|
|
@ -1500,7 +1500,6 @@ func (p *PatrolService) seedPrecomputeIntelligenceState(snap patrolRuntimeState,
|
|||
mh := p.metricsHistory
|
||||
pd := p.patternDetector
|
||||
cd := p.changeDetector
|
||||
corrDet := p.correlationDetector
|
||||
p.mu.RUnlock()
|
||||
|
||||
var intel seedIntelligence
|
||||
|
|
@ -1619,8 +1618,8 @@ func (p *PatrolService) seedPrecomputeIntelligenceState(snap patrolRuntimeState,
|
|||
}
|
||||
|
||||
// Correlations
|
||||
if corrDet != nil {
|
||||
allCorrs := corrDet.GetCorrelations()
|
||||
if intelFacade := p.GetIntelligence(); intelFacade != nil && intelFacade.HasCorrelationsSource() {
|
||||
allCorrs := intelFacade.GetCorrelations("")
|
||||
for _, c := range allCorrs {
|
||||
if !seedIsInScope(scopedSet, c.SourceID) && !seedIsInScope(scopedSet, c.TargetID) {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -4827,10 +4827,9 @@ func (s *Service) buildEnrichedResourceContext(resourceID, _ string, currentMetr
|
|||
sections = append(sections, graphContext)
|
||||
}
|
||||
|
||||
// Get shared correlation detector context for learned related resources.
|
||||
correlationDetector := patrol.GetCorrelationDetector()
|
||||
if correlationDetector != nil {
|
||||
if ctx := correlationDetector.FormatForContext(resourceID); ctx != "" {
|
||||
// Get shared correlation context for learned related resources.
|
||||
if intel := patrol.GetIntelligence(); intel != nil {
|
||||
if ctx := intel.FormatCorrelationsContext(resourceID); ctx != "" {
|
||||
sections = append(sections, ctx)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,11 +289,11 @@ func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http
|
|||
return
|
||||
}
|
||||
|
||||
detector := patrol.GetCorrelationDetector()
|
||||
if detector == nil {
|
||||
intel := patrol.GetIntelligence()
|
||||
if intel == nil || !intel.HasCorrelationsSource() {
|
||||
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||
"correlations": []interface{}{},
|
||||
"message": "Correlation detector not initialized",
|
||||
"message": "Correlation intelligence not initialized",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write correlations response")
|
||||
}
|
||||
|
|
@ -302,13 +302,7 @@ func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http
|
|||
|
||||
// Get resource filter if provided
|
||||
resourceID := r.URL.Query().Get("resource_id")
|
||||
|
||||
var correlations []*ai.Correlation
|
||||
if resourceID != "" {
|
||||
correlations = detector.GetCorrelationsForResource(resourceID)
|
||||
} else {
|
||||
correlations = detector.GetCorrelations()
|
||||
}
|
||||
correlations := intel.GetCorrelations(resourceID)
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, corr := range correlations {
|
||||
|
|
|
|||
|
|
@ -232,6 +232,48 @@ func TestHandleGetCorrelations_ResourceIDFilter(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCorrelations_UsesCanonicalIntelligenceFacade(t *testing.T) {
|
||||
t.Setenv("PULSE_MOCK_MODE", "true")
|
||||
handler, _ := setupAIHandlerWithIntelligence(t)
|
||||
|
||||
handler.defaultAIService.SetCorrelationDetector(seedCorrelationDetector(time.Now()))
|
||||
intel := handler.defaultAIService.GetPatrolService().GetIntelligence()
|
||||
if intel == nil || !intel.HasCorrelationsSource() {
|
||||
t.Fatal("expected correlation source to be available through intelligence facade")
|
||||
}
|
||||
if correlations := intel.GetCorrelations("vm-1"); len(correlations) != 1 {
|
||||
t.Fatalf("expected 1 canonical correlation from intelligence facade, got %d", len(correlations))
|
||||
}
|
||||
if ctx := intel.FormatCorrelationsContext("vm-1"); !strings.Contains(ctx, "## Resource Correlations") {
|
||||
t.Fatalf("expected canonical correlation context, got %q", ctx)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/correlations?resource_id=vm-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCorrelations(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Correlations []struct {
|
||||
TargetID string `json:"target_id"`
|
||||
} `json:"correlations"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Count != 1 || len(resp.Correlations) != 1 {
|
||||
t.Fatalf("correlations count = %d, want 1", resp.Count)
|
||||
}
|
||||
if resp.Correlations[0].TargetID != "vm-1" {
|
||||
t.Fatalf("target_id = %s, want vm-1", resp.Correlations[0].TargetID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleGetRecentChanges tests the changes endpoint
|
||||
func TestHandleGetRecentChanges_MethodNotAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
|
|
|||
|
|
@ -527,6 +527,56 @@ func TestContract_RecentChangesEndpointUsesCanonicalTimeline(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestContract_AIIntelligenceCorrelationsJSONSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, 3, 18, 17, 30, 0, 0, time.UTC)
|
||||
payload := map[string]any{
|
||||
"correlations": []map[string]any{
|
||||
{
|
||||
"source_id": "node-1",
|
||||
"source_name": "node-1",
|
||||
"source_type": "node",
|
||||
"target_id": "vm-1",
|
||||
"target_name": "vm-1",
|
||||
"target_type": "vm",
|
||||
"event_pattern": "high_cpu -> restart",
|
||||
"occurrences": 1,
|
||||
"avg_delay": "1m0s",
|
||||
"confidence": 0.1,
|
||||
"last_seen": now,
|
||||
"description": "When node-1 experiences high_cpu, vm-1 often follows within 1m0s",
|
||||
},
|
||||
},
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
got, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal correlations response: %v", err)
|
||||
}
|
||||
|
||||
const want = `{
|
||||
"correlations":[
|
||||
{
|
||||
"avg_delay":"1m0s",
|
||||
"confidence":0.1,
|
||||
"description":"When node-1 experiences high_cpu, vm-1 often follows within 1m0s",
|
||||
"event_pattern":"high_cpu -\u003e restart",
|
||||
"last_seen":"2026-03-18T17:30:00Z",
|
||||
"occurrences":1,
|
||||
"source_id":"node-1",
|
||||
"source_name":"node-1",
|
||||
"source_type":"node",
|
||||
"target_id":"vm-1",
|
||||
"target_name":"vm-1",
|
||||
"target_type":"vm"
|
||||
}
|
||||
],
|
||||
"count":1
|
||||
}`
|
||||
|
||||
assertJSONSnapshot(t, got, want)
|
||||
}
|
||||
|
||||
func TestContract_ResolveAuthEnvPathUsesCanonicalRuntimeDataDir(t *testing.T) {
|
||||
envDir := t.TempDir()
|
||||
t.Setenv("PULSE_DATA_DIR", envDir)
|
||||
|
|
|
|||
|
|
@ -342,6 +342,21 @@ func TestResourcePolicyCloneHelperUsedByAIConsumers(t *testing.T) {
|
|||
|
||||
func TestResourcePolicyLabelHelpersUsedByAIConsumers(t *testing.T) {
|
||||
requiredSnippets := map[string][]string{
|
||||
filepath.Join("..", "ai", "intelligence.go"): {
|
||||
"func (i *Intelligence) HasCorrelationsSource() bool",
|
||||
"func (i *Intelligence) GetCorrelations(resourceID string) []*correlation.Correlation",
|
||||
"func (i *Intelligence) FormatCorrelationsContext(resourceID string) string",
|
||||
},
|
||||
filepath.Join("..", "ai", "service.go"): {
|
||||
"intel.FormatCorrelationsContext(resourceID)",
|
||||
},
|
||||
filepath.Join("..", "ai", "patrol_ai.go"): {
|
||||
"intelFacade.GetCorrelations(\"\")",
|
||||
},
|
||||
filepath.Join("..", "api", "ai_intelligence_handlers.go"): {
|
||||
"intel.HasCorrelationsSource()",
|
||||
"intel.GetCorrelations(resourceID)",
|
||||
},
|
||||
filepath.Join("..", "ai", "chat", "knowledge_extractor.go"): {
|
||||
"unifiedresources.ResourcePolicyLabel(",
|
||||
"unifiedresources.ResourcePolicyRedactedValue(",
|
||||
|
|
@ -566,7 +581,7 @@ func TestResourceGraphContextUsesCanonicalRelationshipPresentation(t *testing.T)
|
|||
"unifiedresources.FormatResourceGraphContext(resource, 3)",
|
||||
"unifiedresources.FormatResourceRecentChangesContext(changes, false, \"###\")",
|
||||
"type canonicalResourceGetter interface {",
|
||||
"correlationDetector.FormatForContext(resourceID)",
|
||||
"intel.FormatCorrelationsContext(resourceID)",
|
||||
}
|
||||
for _, snippet := range requiredSnippets {
|
||||
if !strings.Contains(source, snippet) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue