From 7fc705ba0726c3f2ce0ddd50ebaffa316730b2ba Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Dec 2025 14:49:46 +0000 Subject: [PATCH] feat(api): Add AI intelligence API endpoints Expose learned AI intelligence data via REST API: New endpoints: - GET /api/ai/intelligence/patterns - Detected failure patterns - GET /api/ai/intelligence/predictions - Failure predictions - GET /api/ai/intelligence/correlations - Resource correlations - GET /api/ai/intelligence/changes - Recent infrastructure changes - GET /api/ai/intelligence/baselines - Learned baselines All endpoints support ?resource_id filter for per-resource queries. Changes endpoint supports ?hours filter (default: 24). Backend additions: - ai_intelligence_handlers.go - Handler implementations - baseline.Store.GetAllBaselines() - Flat baseline export - patrol.GetChangeDetector() - Access change detector This enables frontend to display: - 'OOM expected in 3 days based on pattern' - 'When storage-1 is full, database VM restarts' - 'VM memory baseline: 60-75%' All tests passing. --- internal/ai/baseline/store.go | 44 ++++ internal/ai/patrol.go | 7 + internal/api/ai_intelligence_handlers.go | 321 +++++++++++++++++++++++ internal/api/router.go | 7 + 4 files changed, 379 insertions(+) create mode 100644 internal/api/ai_intelligence_handlers.go diff --git a/internal/ai/baseline/store.go b/internal/ai/baseline/store.go index 81ecf0a..9b96e10 100644 --- a/internal/ai/baseline/store.go +++ b/internal/ai/baseline/store.go @@ -278,6 +278,50 @@ func (s *Store) ResourceCount() int { return len(s.baselines) } +// FlatBaseline is a flattened representation of a single metric baseline for API responses +type FlatBaseline struct { + ResourceID string `json:"resource_id"` + Metric string `json:"metric"` + Mean float64 `json:"mean"` + StdDev float64 `json:"std_dev"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Samples int `json:"samples"` + LastUpdate time.Time `json:"last_update"` +} + +// GetAllBaselines returns all baselines as a flat map for API access +func (s *Store) GetAllBaselines() map[string]*FlatBaseline { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make(map[string]*FlatBaseline) + for resourceID, rb := range s.baselines { + for metric, mb := range rb.Metrics { + key := resourceID + ":" + metric + fb := &FlatBaseline{ + ResourceID: resourceID, + Metric: metric, + Mean: mb.Mean, + StdDev: mb.StdDev, + Samples: mb.SampleCount, + LastUpdate: rb.LastUpdated, + } + // Set min/max from percentiles if available + if mb.Percentiles != nil { + if p5, ok := mb.Percentiles[5]; ok { + fb.Min = p5 + } + if p95, ok := mb.Percentiles[95]; ok { + fb.Max = p95 + } + } + result[key] = fb + } + } + return result +} + // Save persists baselines to disk func (s *Store) Save() error { if s.dataDir == "" { diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 31a3ae5..23fa0f2 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -415,6 +415,13 @@ func (p *PatrolService) GetCorrelationDetector() *CorrelationDetector { return p.correlationDetector } +// GetChangeDetector returns the change detector +func (p *PatrolService) GetChangeDetector() *ChangeDetector { + p.mu.RLock() + defer p.mu.RUnlock() + return p.changeDetector +} + // GetConfig returns the current patrol configuration func (p *PatrolService) GetConfig() PatrolConfig { p.mu.RLock() diff --git a/internal/api/ai_intelligence_handlers.go b/internal/api/ai_intelligence_handlers.go new file mode 100644 index 0000000..6058fa6 --- /dev/null +++ b/internal/api/ai_intelligence_handlers.go @@ -0,0 +1,321 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rs/zerolog/log" +) + +// HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns) +func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "patterns": []interface{}{}, + "message": "Patrol service not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write patterns response") + } + return + } + + detector := patrol.GetPatternDetector() + if detector == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "patterns": []interface{}{}, + "message": "Pattern detector not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write patterns response") + } + return + } + + // Get resource filter if provided + resourceID := r.URL.Query().Get("resource_id") + + patterns := detector.GetPatterns() + var result []map[string]interface{} + + for key, pattern := range patterns { + if resourceID != "" && pattern.ResourceID != resourceID { + continue + } + result = append(result, map[string]interface{}{ + "key": key, + "resource_id": pattern.ResourceID, + "event_type": pattern.EventType, + "occurrences": pattern.Occurrences, + "average_interval": pattern.AverageInterval.String(), + "average_duration": pattern.AverageDuration.String(), + "last_occurrence": pattern.LastOccurrence, + "confidence": pattern.Confidence, + }) + } + + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "patterns": result, + "count": len(result), + }); err != nil { + log.Error().Err(err).Msg("Failed to write patterns response") + } +} + +// HandleGetPredictions returns failure predictions (GET /api/ai/intelligence/predictions) +func (h *AISettingsHandler) HandleGetPredictions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "predictions": []interface{}{}, + "message": "Patrol service not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write predictions response") + } + return + } + + detector := patrol.GetPatternDetector() + if detector == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "predictions": []interface{}{}, + "message": "Pattern detector not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write predictions response") + } + return + } + + // Get resource filter if provided + resourceID := r.URL.Query().Get("resource_id") + + var predictions []ai.FailurePrediction + if resourceID != "" { + predictions = detector.GetPredictionsForResource(resourceID) + } else { + predictions = detector.GetPredictions() + } + + var result []map[string]interface{} + for _, pred := range predictions { + isOverdue := pred.DaysUntil < 0 + result = append(result, map[string]interface{}{ + "resource_id": pred.ResourceID, + "event_type": pred.EventType, + "predicted_at": pred.PredictedAt, + "days_until": pred.DaysUntil, + "confidence": pred.Confidence, + "basis": pred.Basis, + "is_overdue": isOverdue, + }) + } + + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "predictions": result, + "count": len(result), + }); err != nil { + log.Error().Err(err).Msg("Failed to write predictions response") + } +} + +// HandleGetCorrelations returns detected resource correlations (GET /api/ai/intelligence/correlations) +func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "correlations": []interface{}{}, + "message": "Patrol service not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write correlations response") + } + return + } + + detector := patrol.GetCorrelationDetector() + if detector == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "correlations": []interface{}{}, + "message": "Correlation detector not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write correlations response") + } + return + } + + // 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() + } + + var result []map[string]interface{} + for _, corr := range correlations { + result = append(result, map[string]interface{}{ + "source_id": corr.SourceID, + "source_name": corr.SourceName, + "source_type": corr.SourceType, + "target_id": corr.TargetID, + "target_name": corr.TargetName, + "target_type": corr.TargetType, + "event_pattern": corr.EventPattern, + "occurrences": corr.Occurrences, + "avg_delay": corr.AvgDelay.String(), + "confidence": corr.Confidence, + "last_seen": corr.LastSeen, + "description": corr.Description, + }) + } + + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "correlations": result, + "count": len(result), + }); err != nil { + log.Error().Err(err).Msg("Failed to write correlations response") + } +} + +// HandleGetRecentChanges returns recent infrastructure changes (GET /api/ai/intelligence/changes) +func (h *AISettingsHandler) HandleGetRecentChanges(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "changes": []interface{}{}, + "message": "Patrol service not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write changes response") + } + return + } + + detector := patrol.GetChangeDetector() + if detector == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "changes": []interface{}{}, + "message": "Change detector not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write changes response") + } + return + } + + // Get time range - default to 24 hours + hoursStr := r.URL.Query().Get("hours") + hours := 24 + if hoursStr != "" { + if h, err := strconv.Atoi(hoursStr); err == nil && h > 0 { + hours = h + } + } + + since := time.Now().Add(-time.Duration(hours) * time.Hour) + changes := detector.GetRecentChanges(100, since) + + var result []map[string]interface{} + for _, change := range changes { + result = append(result, map[string]interface{}{ + "id": change.ID, + "resource_id": change.ResourceID, + "resource_name": change.ResourceName, + "resource_type": change.ResourceType, + "change_type": change.ChangeType, + "before": change.Before, + "after": change.After, + "detected_at": change.DetectedAt, + "description": change.Description, + }) + } + + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "changes": result, + "count": len(result), + "hours": hours, + }); err != nil { + log.Error().Err(err).Msg("Failed to write changes response") + } +} + +// HandleGetBaselines returns learned resource baselines (GET /api/ai/intelligence/baselines) +func (h *AISettingsHandler) HandleGetBaselines(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "baselines": []interface{}{}, + "message": "Patrol service not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write baselines response") + } + return + } + + store := patrol.GetBaselineStore() + if store == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "baselines": []interface{}{}, + "message": "Baseline store not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write baselines response") + } + return + } + + // Get resource filter if provided + resourceID := r.URL.Query().Get("resource_id") + + baselines := store.GetAllBaselines() + var result []map[string]interface{} + + for key, baseline := range baselines { + if resourceID != "" && baseline.ResourceID != resourceID { + continue + } + result = append(result, map[string]interface{}{ + "key": key, + "resource_id": baseline.ResourceID, + "metric": baseline.Metric, + "mean": baseline.Mean, + "std_dev": baseline.StdDev, + "min": baseline.Min, + "max": baseline.Max, + "samples": baseline.Samples, + "last_update": baseline.LastUpdate, + }) + } + + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "baselines": result, + "count": len(result), + }); err != nil { + log.Error().Err(err).Msg("Failed to write baselines response") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index c7df749..a9b0bd3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1130,6 +1130,13 @@ func (r *Router) setupRoutes() { })) r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, r.aiSettingsHandler.HandleDeleteSuppressionRule)) r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, r.aiSettingsHandler.HandleGetDismissedFindings)) + + // AI Intelligence endpoints - expose learned patterns, correlations, and predictions + r.mux.HandleFunc("/api/ai/intelligence/patterns", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatterns)) + r.mux.HandleFunc("/api/ai/intelligence/predictions", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPredictions)) + r.mux.HandleFunc("/api/ai/intelligence/correlations", RequireAuth(r.config, r.aiSettingsHandler.HandleGetCorrelations)) + r.mux.HandleFunc("/api/ai/intelligence/changes", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRecentChanges)) + r.mux.HandleFunc("/api/ai/intelligence/baselines", RequireAuth(r.config, r.aiSettingsHandler.HandleGetBaselines)) // Agent WebSocket for AI command execution r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)