Route correlations through intelligence facade

This commit is contained in:
rcourtman 2026-03-19 04:15:26 +00:00
parent f12d6b2838
commit c4c154efcf
13 changed files with 224 additions and 18 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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{

View file

@ -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

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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()

View file

@ -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)

View file

@ -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) {