diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index a590da8..7548fa4 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -16,6 +16,8 @@ import type { ChangesResponse, BaselinesResponse, RemediationsResponse, + IntelligenceSummary, + ResourceIntelligence, } from '@/types/aiIntelligence'; export class AIAPI { @@ -119,6 +121,23 @@ export class AIAPI { return apiFetchJSON(`${this.baseUrl}/ai/intelligence/remediations${query ? `?${query}` : ''}`) as Promise; } + // ============================================ + // Unified Intelligence API + // Aggregates all AI subsystems into a single view + // ============================================ + + // Get system-wide intelligence summary + // Returns overall health, findings, predictions, recent activity, and learning progress + static async getIntelligenceSummary(): Promise { + return apiFetchJSON(`${this.baseUrl}/ai/intelligence`) as Promise; + } + + // Get intelligence for a specific resource + // Returns health score, findings, predictions, correlations, and baselines for the resource + static async getResourceIntelligence(resourceId: string): Promise { + return apiFetchJSON(`${this.baseUrl}/ai/intelligence?resource_id=${encodeURIComponent(resourceId)}`) as Promise; + } + // Start OAuth flow for Claude Pro/Max subscription // Returns the authorization URL to redirect the user to @@ -235,7 +254,7 @@ export class AIAPI { let lastEventTime = Date.now(); try { - for (;;) { + for (; ;) { if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) { console.warn('[AI] Alert investigation stream timeout'); break; @@ -330,7 +349,7 @@ export class AIAPI { logger.debug('[AI SSE] Starting to read stream'); try { - for (;;) { + for (; ;) { // Check for stream timeout if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) { logger.warn('[AI SSE] Stream timeout', { seconds: STREAM_TIMEOUT_MS / 1000 }); diff --git a/frontend-modern/src/types/aiIntelligence.ts b/frontend-modern/src/types/aiIntelligence.ts index 7c8e3e2..03e8ef9 100644 --- a/frontend-modern/src/types/aiIntelligence.ts +++ b/frontend-modern/src/types/aiIntelligence.ts @@ -125,3 +125,118 @@ export interface RemediationsResponse extends LicenseGatedResponse { count: number; stats?: RemediationStats; } + +// ============================================================================ +// Unified Intelligence Types (for /api/ai/intelligence endpoint) +// ============================================================================ + +export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F'; +export type HealthTrend = 'improving' | 'stable' | 'declining'; + +export interface HealthFactor { + name: string; + impact: number; // -1 to 1, negative is bad, positive is good + description: string; + category: string; // "finding", "prediction", "baseline", "learning" +} + +export interface HealthScore { + score: number; // 0-100 + grade: HealthGrade; + trend: HealthTrend; + factors: HealthFactor[]; + prediction?: string; // AI-predicted future state +} + +export interface FindingsCounts { + total: number; + critical: number; + warning: number; + watch: number; + info: number; +} + +export interface LearningStats { + resources_with_knowledge: number; + total_notes: number; + resources_with_baselines: number; + patterns_detected: number; + correlations_learned: number; + incidents_tracked: number; +} + +export interface ResourceRiskSummary { + resource_id: string; + resource_name: string; + resource_type: string; + health: HealthScore; + top_issue: string; +} + +// System-wide intelligence summary +export interface IntelligenceSummary { + timestamp: string; + overall_health: HealthScore; + + // Findings overview + findings_count: FindingsCounts; + top_findings?: unknown[]; // Top N findings by severity + + // Predictions overview + predictions_count: number; + upcoming_risks?: FailurePrediction[]; + + // Recent activity + recent_changes_count: number; + recent_remediations?: RemediationRecord[]; + + // Learning progress + learning: LearningStats; + + // Resources needing attention + resources_at_risk?: ResourceRiskSummary[]; +} + +// Per-resource intelligence +export interface ResourceIntelligence { + resource_id: string; + resource_name: string; + resource_type: string; + + // Health score for this resource + health: HealthScore; + + // Active findings for this resource + active_findings?: unknown[]; + + // Predictions for this resource + predictions?: FailurePrediction[]; + + // Correlations involving this resource + correlations?: ResourceCorrelation[]; + dependencies?: string[]; // Resources this depends on + dependents?: string[]; // Resources that depend on this + + // Baselines for this resource + baselines?: Record; + + // Current anomalies + anomalies?: AnomalyReport[]; + + // Recent incidents + recent_incidents?: unknown[]; + + // Knowledge/notes + knowledge?: unknown; + note_count: number; +} + +export interface AnomalyReport { + metric: string; + current_value: number; + baseline_mean: number; + z_score: number; + severity: string; // "critical", "high", "medium", "low" + description: string; +} + diff --git a/internal/ai/intelligence.go b/internal/ai/intelligence.go new file mode 100644 index 0000000..ec20e42 --- /dev/null +++ b/internal/ai/intelligence.go @@ -0,0 +1,945 @@ +// Package ai provides AI-powered infrastructure monitoring and investigation. +// This file contains the unified AIIntelligence orchestrator that ties together +// all AI subsystems into one coherent intelligence layer. +package ai + +import ( + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/correlation" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/patterns" +) + +// HealthGrade represents the overall health assessment +type HealthGrade string + +const ( + HealthGradeA HealthGrade = "A" // Excellent - no issues + HealthGradeB HealthGrade = "B" // Good - minor issues + HealthGradeC HealthGrade = "C" // Fair - some concerns + HealthGradeD HealthGrade = "D" // Poor - needs attention + HealthGradeF HealthGrade = "F" // Critical - immediate action needed +) + +// HealthTrend indicates the direction of health over time +type HealthTrend string + +const ( + HealthTrendImproving HealthTrend = "improving" + HealthTrendStable HealthTrend = "stable" + HealthTrendDeclining HealthTrend = "declining" +) + +// HealthFactor represents a single component affecting health +type HealthFactor struct { + Name string `json:"name"` + Impact float64 `json:"impact"` // -1 to 1, negative is bad + Description string `json:"description"` + Category string `json:"category"` // finding, prediction, baseline, incident +} + +// HealthScore represents the overall health of a resource or system +type HealthScore struct { + Score float64 `json:"score"` // 0-100 + Grade HealthGrade `json:"grade"` // A, B, C, D, F + Trend HealthTrend `json:"trend"` // improving, stable, declining + Factors []HealthFactor `json:"factors"` // What's affecting the score + Prediction string `json:"prediction"` // Human-readable outlook +} + +// ResourceIntelligence aggregates all AI knowledge about a single resource +type ResourceIntelligence struct { + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + Health HealthScore `json:"health"` + ActiveFindings []*Finding `json:"active_findings,omitempty"` + Predictions []patterns.FailurePrediction `json:"predictions,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` // Resources this depends on + Dependents []string `json:"dependents,omitempty"` // Resources that depend on this + Correlations []*correlation.Correlation `json:"correlations,omitempty"` + Baselines map[string]*baseline.FlatBaseline `json:"baselines,omitempty"` + Anomalies []AnomalyReport `json:"anomalies,omitempty"` + RecentIncidents []*memory.Incident `json:"recent_incidents,omitempty"` + Knowledge *knowledge.GuestKnowledge `json:"knowledge,omitempty"` + NoteCount int `json:"note_count"` +} + +// AnomalyReport describes a metric that's deviating from baseline +type AnomalyReport struct { + Metric string `json:"metric"` + CurrentValue float64 `json:"current_value"` + BaselineMean float64 `json:"baseline_mean"` + ZScore float64 `json:"z_score"` + Severity baseline.AnomalySeverity `json:"severity"` + Description string `json:"description"` +} + +// IntelligenceSummary provides a system-wide intelligence overview +type IntelligenceSummary struct { + Timestamp time.Time `json:"timestamp"` + OverallHealth HealthScore `json:"overall_health"` + + // Findings summary + FindingsCount FindingsCounts `json:"findings_count"` + TopFindings []*Finding `json:"top_findings,omitempty"` // Most critical + + // Predictions + PredictionsCount int `json:"predictions_count"` + UpcomingRisks []patterns.FailurePrediction `json:"upcoming_risks,omitempty"` + + // Recent activity + RecentChangesCount int `json:"recent_changes_count"` + RecentRemediations []memory.RemediationRecord `json:"recent_remediations,omitempty"` + + // Learning progress + Learning LearningStats `json:"learning"` + + // Resources needing attention + ResourcesAtRisk []ResourceRiskSummary `json:"resources_at_risk,omitempty"` +} + +// FindingsCounts provides a breakdown of findings by severity +type FindingsCounts struct { + Critical int `json:"critical"` + Warning int `json:"warning"` + Watch int `json:"watch"` + Info int `json:"info"` + Total int `json:"total"` +} + +// LearningStats shows how much the AI has learned +type LearningStats struct { + ResourcesWithKnowledge int `json:"resources_with_knowledge"` + TotalNotes int `json:"total_notes"` + ResourcesWithBaselines int `json:"resources_with_baselines"` + PatternsDetected int `json:"patterns_detected"` + CorrelationsLearned int `json:"correlations_learned"` + IncidentsTracked int `json:"incidents_tracked"` +} + +// ResourceRiskSummary is a brief summary of a resource at risk +type ResourceRiskSummary struct { + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name"` + ResourceType string `json:"resource_type"` + Health HealthScore `json:"health"` + TopIssue string `json:"top_issue"` +} + +// Intelligence orchestrates all AI subsystems into a unified system +type Intelligence struct { + mu sync.RWMutex + + // Core subsystems + findings *FindingsStore + patterns *patterns.Detector + correlations *correlation.Detector + baselines *baseline.Store + incidents *memory.IncidentStore + knowledge *knowledge.Store + changes *memory.ChangeDetector + remediations *memory.RemediationLog + + // State access + stateProvider StateProvider + + // Configuration + dataDir string +} + +// IntelligenceConfig configures the unified intelligence layer +type IntelligenceConfig struct { + DataDir string +} + +// NewIntelligence creates a new unified intelligence orchestrator +func NewIntelligence(cfg IntelligenceConfig) *Intelligence { + return &Intelligence{ + dataDir: cfg.DataDir, + } +} + +// SetSubsystems wires up all the AI subsystems +func (i *Intelligence) SetSubsystems( + findings *FindingsStore, + patternsDetector *patterns.Detector, + correlationsDetector *correlation.Detector, + baselinesStore *baseline.Store, + incidentsStore *memory.IncidentStore, + knowledgeStore *knowledge.Store, + changesDetector *memory.ChangeDetector, + remediationsLog *memory.RemediationLog, +) { + i.mu.Lock() + defer i.mu.Unlock() + + i.findings = findings + i.patterns = patternsDetector + i.correlations = correlationsDetector + i.baselines = baselinesStore + i.incidents = incidentsStore + i.knowledge = knowledgeStore + i.changes = changesDetector + i.remediations = remediationsLog +} + +// SetStateProvider sets the state provider for current metrics +func (i *Intelligence) SetStateProvider(sp StateProvider) { + i.mu.Lock() + defer i.mu.Unlock() + i.stateProvider = sp +} + +// GetSummary returns a comprehensive intelligence summary +func (i *Intelligence) GetSummary() *IntelligenceSummary { + i.mu.RLock() + defer i.mu.RUnlock() + + summary := &IntelligenceSummary{ + Timestamp: time.Now(), + } + + // Aggregate findings + if i.findings != nil { + all := i.findings.GetActive(FindingSeverityInfo) // Get all active findings + summary.FindingsCount = i.countFindings(all) + summary.TopFindings = i.getTopFindings(all, 5) + } + + // Aggregate predictions + if i.patterns != nil { + predictions := i.patterns.GetPredictions() + summary.PredictionsCount = len(predictions) + summary.UpcomingRisks = i.getUpcomingRisks(predictions, 5) + } + + // Aggregate recent activity + if i.changes != nil { + recent := i.changes.GetRecentChanges(100, time.Now().Add(-24*time.Hour)) + summary.RecentChangesCount = len(recent) + } + + if i.remediations != nil { + recent := i.remediations.GetRecentRemediations(5, time.Now().Add(-24*time.Hour)) + summary.RecentRemediations = recent + } + + // Learning stats + summary.Learning = i.getLearningStats() + + // Calculate overall health + summary.OverallHealth = i.calculateOverallHealth(summary) + + // Resources at risk + summary.ResourcesAtRisk = i.getResourcesAtRisk(5) + + return summary +} + +// GetResourceIntelligence returns aggregated intelligence for a specific resource +func (i *Intelligence) GetResourceIntelligence(resourceID string) *ResourceIntelligence { + i.mu.RLock() + defer i.mu.RUnlock() + + intel := &ResourceIntelligence{ + ResourceID: resourceID, + } + + // Active findings + if i.findings != nil { + intel.ActiveFindings = i.findings.GetByResource(resourceID) + if len(intel.ActiveFindings) > 0 { + intel.ResourceName = intel.ActiveFindings[0].ResourceName + intel.ResourceType = intel.ActiveFindings[0].ResourceType + } + } + + // Predictions + if i.patterns != nil { + intel.Predictions = i.patterns.GetPredictionsForResource(resourceID) + } + + // Correlations and dependencies + if i.correlations != nil { + intel.Correlations = i.correlations.GetCorrelationsForResource(resourceID) + intel.Dependencies = i.correlations.GetDependsOn(resourceID) + intel.Dependents = i.correlations.GetDependencies(resourceID) + } + + // Baselines + if i.baselines != nil { + if rb, ok := i.baselines.GetResourceBaseline(resourceID); ok { + intel.Baselines = make(map[string]*baseline.FlatBaseline) + for metric, mb := range rb.Metrics { + intel.Baselines[metric] = &baseline.FlatBaseline{ + ResourceID: resourceID, + Metric: metric, + Mean: mb.Mean, + StdDev: mb.StdDev, + Samples: mb.SampleCount, + LastUpdate: rb.LastUpdated, + } + } + } + } + + // Recent incidents + if i.incidents != nil { + intel.RecentIncidents = i.incidents.ListIncidentsByResource(resourceID, 5) + } + + // Knowledge + if i.knowledge != nil { + if k, err := i.knowledge.GetKnowledge(resourceID); err == nil && k != nil { + intel.Knowledge = k + intel.NoteCount = len(k.Notes) + if intel.ResourceName == "" && k.GuestName != "" { + intel.ResourceName = k.GuestName + } + if intel.ResourceType == "" && k.GuestType != "" { + intel.ResourceType = k.GuestType + } + } + } + + // Calculate health score + intel.Health = i.calculateResourceHealth(intel) + + return intel +} + +// FormatContext builds a comprehensive context string for AI prompts +func (i *Intelligence) FormatContext(resourceID string) string { + i.mu.RLock() + defer i.mu.RUnlock() + + var sections []string + + // Knowledge (most important - what we've learned) + if i.knowledge != nil { + if ctx := i.knowledge.FormatForContext(resourceID); ctx != "" { + sections = append(sections, ctx) + } + } + + // Baselines (what's normal for this resource) + if i.baselines != nil { + if ctx := i.formatBaselinesForContext(resourceID); ctx != "" { + sections = append(sections, ctx) + } + } + + // Current anomalies + if anomalies := i.detectCurrentAnomalies(resourceID); len(anomalies) > 0 { + sections = append(sections, i.formatAnomaliesForContext(anomalies)) + } + + // Patterns/Predictions + if i.patterns != nil { + if ctx := i.patterns.FormatForContext(resourceID); ctx != "" { + sections = append(sections, ctx) + } + } + + // Correlations + if i.correlations != nil { + if ctx := i.correlations.FormatForContext(resourceID); ctx != "" { + sections = append(sections, ctx) + } + } + + // Incidents + if i.incidents != nil { + if ctx := i.incidents.FormatForResource(resourceID, 5); ctx != "" { + sections = append(sections, ctx) + } + } + + return strings.Join(sections, "\n") +} + +// FormatGlobalContext builds context for infrastructure-wide analysis +func (i *Intelligence) FormatGlobalContext() string { + i.mu.RLock() + defer i.mu.RUnlock() + + var sections []string + + // All saved knowledge (limited) + if i.knowledge != nil { + if ctx := i.knowledge.FormatAllForContext(); ctx != "" { + sections = append(sections, ctx) + } + } + + // Recent incidents across infrastructure + if i.incidents != nil { + if ctx := i.incidents.FormatForPatrol(8); ctx != "" { + sections = append(sections, ctx) + } + } + + // Top correlations + if i.correlations != nil { + if ctx := i.correlations.FormatForContext(""); ctx != "" { + sections = append(sections, ctx) + } + } + + // Top predictions + if i.patterns != nil { + if ctx := i.patterns.FormatForContext(""); ctx != "" { + sections = append(sections, ctx) + } + } + + return strings.Join(sections, "\n") +} + +// RecordLearning saves a learning to the knowledge store after a fix +func (i *Intelligence) RecordLearning(resourceID, resourceName, resourceType, title, content string) error { + i.mu.RLock() + defer i.mu.RUnlock() + + if i.knowledge == nil { + return nil + } + + return i.knowledge.SaveNote(resourceID, resourceName, resourceType, "learning", title, content) +} + +// CheckBaselinesForResource checks current metrics against baselines and returns anomalies +func (i *Intelligence) CheckBaselinesForResource(resourceID string, metrics map[string]float64) []AnomalyReport { + i.mu.RLock() + defer i.mu.RUnlock() + + if i.baselines == nil { + return nil + } + + var anomalies []AnomalyReport + for metric, value := range metrics { + severity, zScore, bl := i.baselines.CheckAnomaly(resourceID, metric, value) + if severity != baseline.AnomalyNone && bl != nil { + anomalies = append(anomalies, AnomalyReport{ + Metric: metric, + CurrentValue: value, + BaselineMean: bl.Mean, + ZScore: zScore, + Severity: severity, + Description: i.formatAnomalyDescription(metric, value, bl, zScore), + }) + } + } + + return anomalies +} + +// CreatePredictionFinding creates a finding from a prediction that's imminent +func (i *Intelligence) CreatePredictionFinding(pred patterns.FailurePrediction) *Finding { + severity := FindingSeverityWatch + if pred.DaysUntil < 1 { + severity = FindingSeverityWarning + } + if pred.Confidence > 0.8 && pred.DaysUntil < 1 { + severity = FindingSeverityCritical + } + + return &Finding{ + ID: fmt.Sprintf("pred-%s-%s", pred.ResourceID, pred.EventType), + Key: fmt.Sprintf("prediction:%s:%s", pred.ResourceID, pred.EventType), + Severity: severity, + Category: FindingCategoryReliability, + ResourceID: pred.ResourceID, + Title: fmt.Sprintf("Predicted: %s", pred.EventType), + Description: pred.Basis, + DetectedAt: time.Now(), + LastSeenAt: time.Now(), + } +} + +// Helper methods + +func (i *Intelligence) countFindings(findings []*Finding) FindingsCounts { + counts := FindingsCounts{} + for _, f := range findings { + if f == nil { + continue + } + counts.Total++ + switch f.Severity { + case FindingSeverityCritical: + counts.Critical++ + case FindingSeverityWarning: + counts.Warning++ + case FindingSeverityWatch: + counts.Watch++ + case FindingSeverityInfo: + counts.Info++ + } + } + return counts +} + +func (i *Intelligence) getTopFindings(findings []*Finding, limit int) []*Finding { + if len(findings) == 0 { + return nil + } + + // Sort by severity (critical first) then by detection time (newest first) + sorted := make([]*Finding, len(findings)) + copy(sorted, findings) + sort.Slice(sorted, func(a, b int) bool { + sevA := severityOrder(sorted[a].Severity) + sevB := severityOrder(sorted[b].Severity) + if sevA != sevB { + return sevA < sevB + } + return sorted[a].DetectedAt.After(sorted[b].DetectedAt) + }) + + if len(sorted) > limit { + sorted = sorted[:limit] + } + return sorted +} + +func severityOrder(s FindingSeverity) int { + switch s { + case FindingSeverityCritical: + return 0 + case FindingSeverityWarning: + return 1 + case FindingSeverityWatch: + return 2 + case FindingSeverityInfo: + return 3 + default: + return 4 + } +} + +func (i *Intelligence) getUpcomingRisks(predictions []patterns.FailurePrediction, limit int) []patterns.FailurePrediction { + if len(predictions) == 0 { + return nil + } + + // Filter to next 7 days and sort by days until + var upcoming []patterns.FailurePrediction + for _, p := range predictions { + if p.DaysUntil <= 7 && p.Confidence >= 0.5 { + upcoming = append(upcoming, p) + } + } + + sort.Slice(upcoming, func(a, b int) bool { + return upcoming[a].DaysUntil < upcoming[b].DaysUntil + }) + + if len(upcoming) > limit { + upcoming = upcoming[:limit] + } + return upcoming +} + +func (i *Intelligence) getLearningStats() LearningStats { + stats := LearningStats{} + + if i.knowledge != nil { + guests, _ := i.knowledge.ListGuests() + for _, guestID := range guests { + if k, err := i.knowledge.GetKnowledge(guestID); err == nil && k != nil && len(k.Notes) > 0 { + stats.ResourcesWithKnowledge++ + stats.TotalNotes += len(k.Notes) + } + } + } + + if i.baselines != nil { + stats.ResourcesWithBaselines = i.baselines.ResourceCount() + } + + if i.patterns != nil { + p := i.patterns.GetPatterns() + stats.PatternsDetected = len(p) + } + + if i.correlations != nil { + c := i.correlations.GetCorrelations() + stats.CorrelationsLearned = len(c) + } + + if i.incidents != nil { + // Count is not available, so we skip this stat for now + // Could be added to IncidentStore if needed + stats.IncidentsTracked = 0 + } + + return stats +} + +func (i *Intelligence) calculateOverallHealth(summary *IntelligenceSummary) HealthScore { + health := HealthScore{ + Score: 100, + Grade: HealthGradeA, + Trend: HealthTrendStable, + Factors: []HealthFactor{}, + } + + // Deduct for findings + if summary.FindingsCount.Critical > 0 { + impact := float64(summary.FindingsCount.Critical) * 20 + if impact > 40 { + impact = 40 + } + health.Score -= impact + health.Factors = append(health.Factors, HealthFactor{ + Name: "Critical findings", + Impact: -impact / 100, + Description: fmt.Sprintf("%d critical issues need immediate attention", summary.FindingsCount.Critical), + Category: "finding", + }) + } + + if summary.FindingsCount.Warning > 0 { + impact := float64(summary.FindingsCount.Warning) * 10 + if impact > 20 { + impact = 20 + } + health.Score -= impact + health.Factors = append(health.Factors, HealthFactor{ + Name: "Warnings", + Impact: -impact / 100, + Description: fmt.Sprintf("%d warnings need attention soon", summary.FindingsCount.Warning), + Category: "finding", + }) + } + + // Deduct for imminent predictions + for _, pred := range summary.UpcomingRisks { + if pred.DaysUntil < 3 && pred.Confidence > 0.7 { + impact := 10.0 + health.Score -= impact + health.Factors = append(health.Factors, HealthFactor{ + Name: "Predicted issue", + Impact: -impact / 100, + Description: fmt.Sprintf("%s predicted within %.1f days", pred.EventType, pred.DaysUntil), + Category: "prediction", + }) + } + } + + // Bonus for learning progress + if summary.Learning.ResourcesWithKnowledge > 5 { + bonus := 5.0 + health.Score += bonus + health.Factors = append(health.Factors, HealthFactor{ + Name: "Knowledge learned", + Impact: bonus / 100, + Description: fmt.Sprintf("AI has learned about %d resources", summary.Learning.ResourcesWithKnowledge), + Category: "learning", + }) + } + + // Clamp score + if health.Score < 0 { + health.Score = 0 + } + if health.Score > 100 { + health.Score = 100 + } + + // Assign grade + health.Grade = scoreToGrade(health.Score) + + // Generate prediction text + health.Prediction = i.generateHealthPrediction(health, summary) + + return health +} + +func (i *Intelligence) calculateResourceHealth(intel *ResourceIntelligence) HealthScore { + health := HealthScore{ + Score: 100, + Grade: HealthGradeA, + Trend: HealthTrendStable, + Factors: []HealthFactor{}, + } + + // Deduct for active findings + for _, f := range intel.ActiveFindings { + if f == nil { + continue + } + var impact float64 + switch f.Severity { + case FindingSeverityCritical: + impact = 30 + case FindingSeverityWarning: + impact = 15 + case FindingSeverityWatch: + impact = 5 + case FindingSeverityInfo: + impact = 2 + } + health.Score -= impact + health.Factors = append(health.Factors, HealthFactor{ + Name: f.Title, + Impact: -impact / 100, + Description: f.Description, + Category: "finding", + }) + } + + // Deduct for predictions + for _, p := range intel.Predictions { + if p.DaysUntil < 7 && p.Confidence > 0.5 { + impact := 10.0 * p.Confidence + health.Score -= impact + health.Factors = append(health.Factors, HealthFactor{ + Name: "Predicted: " + string(p.EventType), + Impact: -impact / 100, + Description: p.Basis, + Category: "prediction", + }) + } + } + + // Deduct for anomalies + for _, a := range intel.Anomalies { + var impact float64 + switch a.Severity { + case baseline.AnomalyCritical: + impact = 20 + case baseline.AnomalyHigh: + impact = 10 + case baseline.AnomalyMedium: + impact = 5 + case baseline.AnomalyLow: + impact = 2 + } + health.Score -= impact + health.Factors = append(health.Factors, HealthFactor{ + Name: a.Metric + " anomaly", + Impact: -impact / 100, + Description: a.Description, + Category: "baseline", + }) + } + + // Bonus for having knowledge + if intel.NoteCount > 0 { + bonus := 2.0 + health.Score += bonus + health.Factors = append(health.Factors, HealthFactor{ + Name: "Documented", + Impact: bonus / 100, + Description: fmt.Sprintf("%d notes saved for this resource", intel.NoteCount), + Category: "learning", + }) + } + + // Clamp + if health.Score < 0 { + health.Score = 0 + } + if health.Score > 100 { + health.Score = 100 + } + + health.Grade = scoreToGrade(health.Score) + + return health +} + +func scoreToGrade(score float64) HealthGrade { + switch { + case score >= 90: + return HealthGradeA + case score >= 75: + return HealthGradeB + case score >= 60: + return HealthGradeC + case score >= 40: + return HealthGradeD + default: + return HealthGradeF + } +} + +func (i *Intelligence) generateHealthPrediction(health HealthScore, summary *IntelligenceSummary) string { + if health.Grade == HealthGradeA { + return "Infrastructure is healthy with no significant issues detected." + } + + if summary.FindingsCount.Critical > 0 { + return fmt.Sprintf("Immediate attention required: %d critical issues.", summary.FindingsCount.Critical) + } + + if len(summary.UpcomingRisks) > 0 { + risk := summary.UpcomingRisks[0] + return fmt.Sprintf("Predicted %s event on resource within %.1f days (%.0f%% confidence).", + risk.EventType, risk.DaysUntil, risk.Confidence*100) + } + + if summary.FindingsCount.Warning > 0 { + return fmt.Sprintf("%d warnings should be addressed soon to maintain stability.", summary.FindingsCount.Warning) + } + + return "Infrastructure is stable with minor issues to monitor." +} + +func (i *Intelligence) getResourcesAtRisk(limit int) []ResourceRiskSummary { + if i.findings == nil { + return nil + } + + // Group findings by resource + byResource := make(map[string][]*Finding) + for _, f := range i.findings.GetActive(FindingSeverityInfo) { + if f == nil { + continue + } + byResource[f.ResourceID] = append(byResource[f.ResourceID], f) + } + + // Calculate risk for each resource + type resourceRisk struct { + id string + name string + rtype string + score float64 + top string + } + + var risks []resourceRisk + for id, findings := range byResource { + if len(findings) == 0 { + continue + } + + score := 0.0 + var topFinding *Finding + for _, f := range findings { + switch f.Severity { + case FindingSeverityCritical: + score += 30 + case FindingSeverityWarning: + score += 15 + case FindingSeverityWatch: + score += 5 + case FindingSeverityInfo: + score += 2 + } + if topFinding == nil || severityOrder(f.Severity) < severityOrder(topFinding.Severity) { + topFinding = f + } + } + + if score > 0 && topFinding != nil { + risks = append(risks, resourceRisk{ + id: id, + name: topFinding.ResourceName, + rtype: topFinding.ResourceType, + score: score, + top: topFinding.Title, + }) + } + } + + // Sort by risk score descending + sort.Slice(risks, func(a, b int) bool { + return risks[a].score > risks[b].score + }) + + if len(risks) > limit { + risks = risks[:limit] + } + + // Convert to summaries + var summaries []ResourceRiskSummary + for _, r := range risks { + health := HealthScore{ + Score: 100 - r.score, + Grade: scoreToGrade(100 - r.score), + } + summaries = append(summaries, ResourceRiskSummary{ + ResourceID: r.id, + ResourceName: r.name, + ResourceType: r.rtype, + Health: health, + TopIssue: r.top, + }) + } + + return summaries +} + +func (i *Intelligence) detectCurrentAnomalies(resourceID string) []AnomalyReport { + // This would be called with current metrics from state + // For now, return empty - will be integrated with patrol + return nil +} + +func (i *Intelligence) formatBaselinesForContext(resourceID string) string { + if i.baselines == nil { + return "" + } + + rb, ok := i.baselines.GetResourceBaseline(resourceID) + if !ok || len(rb.Metrics) == 0 { + return "" + } + + var lines []string + lines = append(lines, "\n## Learned Baselines") + lines = append(lines, "Normal operating ranges for this resource:") + + for metric, mb := range rb.Metrics { + lines = append(lines, fmt.Sprintf("- %s: mean %.1f, stddev %.1f (samples: %d)", + metric, mb.Mean, mb.StdDev, mb.SampleCount)) + } + + return strings.Join(lines, "\n") +} + +func (i *Intelligence) formatAnomaliesForContext(anomalies []AnomalyReport) string { + if len(anomalies) == 0 { + return "" + } + + var lines []string + lines = append(lines, "\n## Current Anomalies") + lines = append(lines, "Metrics deviating from normal:") + + for _, a := range anomalies { + lines = append(lines, fmt.Sprintf("- %s: %s", a.Metric, a.Description)) + } + + return strings.Join(lines, "\n") +} + +func (i *Intelligence) formatAnomalyDescription(metric string, value float64, bl *baseline.MetricBaseline, zScore float64) string { + direction := "above" + if zScore < 0 { + direction = "below" + } + return fmt.Sprintf("%.1f is %.1f std devs %s baseline (mean: %.1f)", + value, absFloatIntel(zScore), direction, bl.Mean) +} + +// absFloatIntel is a local helper (service.go has its own) +func absFloatIntel(f float64) float64 { + if f < 0 { + return -f + } + return f +} diff --git a/internal/ai/intelligence_test.go b/internal/ai/intelligence_test.go new file mode 100644 index 0000000..f5a8276 --- /dev/null +++ b/internal/ai/intelligence_test.go @@ -0,0 +1,170 @@ +package ai + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/patterns" +) + +func TestNewIntelligence(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{DataDir: "/tmp/test"}) + if intel == nil { + t.Fatal("Expected non-nil Intelligence") + } +} + +func TestIntelligence_GetSummary_NoSubsystems(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + summary := intel.GetSummary() + if summary == nil { + t.Fatal("Expected non-nil summary") + } + + // Should have default healthy state + if summary.OverallHealth.Score != 100 { + t.Errorf("Expected health score 100, got %f", summary.OverallHealth.Score) + } + if summary.OverallHealth.Grade != HealthGradeA { + t.Errorf("Expected grade A, got %s", summary.OverallHealth.Grade) + } + if summary.Timestamp.IsZero() { + t.Error("Expected non-zero timestamp") + } +} + +func TestIntelligence_GetResourceIntelligence_NoSubsystems(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + resourceIntel := intel.GetResourceIntelligence("test-resource") + if resourceIntel == nil { + t.Fatal("Expected non-nil resource intelligence") + } + + if resourceIntel.ResourceID != "test-resource" { + t.Errorf("Expected resource ID 'test-resource', got %s", resourceIntel.ResourceID) + } + + // Should have default healthy state + if resourceIntel.Health.Score != 100 { + t.Errorf("Expected health score 100, got %f", resourceIntel.Health.Score) + } +} + +func TestIntelligence_FormatContext_NoSubsystems(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // With no subsystems, context should be empty + ctx := intel.FormatContext("test-resource") + if ctx != "" { + t.Errorf("Expected empty context with no subsystems, got: %s", ctx) + } +} + +func TestIntelligence_CreatePredictionFinding(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + pred := patterns.FailurePrediction{ + ResourceID: "vm-100", + EventType: patterns.EventHighCPU, // Use the constant instead of cast + DaysUntil: 0.5, // Less than 1 day + Confidence: 0.85, // High confidence + Basis: "Pattern detected", + } + + finding := intel.CreatePredictionFinding(pred) + if finding == nil { + t.Fatal("Expected non-nil finding") + } + + // High confidence + < 1 day should be critical + if finding.Severity != FindingSeverityCritical { + t.Errorf("Expected critical severity for imminent high-confidence prediction, got %s", finding.Severity) + } + + if finding.ResourceID != "vm-100" { + t.Errorf("Expected resource ID 'vm-100', got %s", finding.ResourceID) + } +} + +func TestIntelligence_SetSubsystems(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Create a findings store + findings := NewFindingsStore() + + // Add a finding + findings.Add(&Finding{ + ID: "test-finding", + Key: "test:finding", + Severity: FindingSeverityWarning, + Category: FindingCategoryPerformance, + ResourceID: "vm-100", + ResourceName: "test-vm", + ResourceType: "vm", + Title: "Test Finding", + DetectedAt: time.Now(), + LastSeenAt: time.Now(), + Source: "test", + }) + + // Set subsystems with just findings + intel.SetSubsystems(findings, nil, nil, nil, nil, nil, nil, nil) + + // Get summary + summary := intel.GetSummary() + + // Should have 1 warning + if summary.FindingsCount.Warning != 1 { + t.Errorf("Expected 1 warning, got %d", summary.FindingsCount.Warning) + } + if summary.FindingsCount.Total != 1 { + t.Errorf("Expected 1 total finding, got %d", summary.FindingsCount.Total) + } + + // Health should be reduced due to warning + if summary.OverallHealth.Score >= 100 { + t.Error("Expected health score < 100 due to warning finding") + } +} + +func TestIntelligence_HealthGrades(t *testing.T) { + tests := []struct { + score float64 + grade HealthGrade + }{ + {100, HealthGradeA}, + {95, HealthGradeA}, + {90, HealthGradeA}, + {85, HealthGradeB}, + {75, HealthGradeB}, + {70, HealthGradeC}, + {60, HealthGradeC}, + {55, HealthGradeD}, + {40, HealthGradeD}, + {30, HealthGradeF}, + {0, HealthGradeF}, + } + + for _, tt := range tests { + grade := scoreToGrade(tt.score) + if grade != tt.grade { + t.Errorf("scoreToGrade(%f) = %s, expected %s", tt.score, grade, tt.grade) + } + } +} + +func TestIntelligence_CheckBaselinesForResource(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // With no baseline store, should return nil + anomalies := intel.CheckBaselinesForResource("vm-100", map[string]float64{ + "cpu": 85.0, + "memory": 90.0, + }) + + if anomalies != nil { + t.Error("Expected nil anomalies when baseline store not configured") + } +} diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index e2af2df..033632f 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -220,6 +220,9 @@ type PatrolService struct { correlationDetector *CorrelationDetector // For multi-resource correlation incidentStore *memory.IncidentStore // For incident timeline capture + // Unified intelligence facade - aggregates all subsystems for unified view + intelligence *Intelligence + // Cached thresholds (recalculated when thresholdProvider changes) thresholds PatrolThresholds @@ -459,6 +462,37 @@ func (p *PatrolService) GetFindings() *FindingsStore { return p.findings } +// GetIntelligence returns the unified intelligence facade that aggregates all AI subsystems. +// This provides a single entry point for getting system-wide and resource-specific AI insights. +// The facade is lazily initialized and wires together existing subsystems. +func (p *PatrolService) GetIntelligence() *Intelligence { + p.mu.Lock() + defer p.mu.Unlock() + + // Lazy initialization - build facade from existing subsystems + if p.intelligence == nil { + p.intelligence = NewIntelligence(IntelligenceConfig{}) + } + + // Always refresh subsystem pointers (they may have been set after intelligence was created) + p.intelligence.SetSubsystems( + p.findings, + p.patternDetector, + p.correlationDetector, + p.baselineStore, + p.incidentStore, + p.knowledgeStore, + p.changeDetector, + p.remediationLog, + ) + + if p.stateProvider != nil { + p.intelligence.SetStateProvider(p.stateProvider) + } + + return p.intelligence +} + // GetStatus returns the current patrol status func (p *PatrolService) GetStatus() PatrolStatus { p.mu.RLock() diff --git a/internal/ai/service_extended_test.go b/internal/ai/service_extended_test.go index 1bf1f66..b6d6f47 100644 --- a/internal/ai/service_extended_test.go +++ b/internal/ai/service_extended_test.go @@ -1920,7 +1920,7 @@ func TestService_LogRemediation_WithPatrolService(t *testing.T) { UseCase: "patrol", } - svc.logRemediation(req, "top -bn1", "output data", true) + svc.logRemediation(req, "systemctl restart nginx", "output data", true) // Verify the log was created history := remLog.GetForResource("vm-100-with-patrol", 10) @@ -1964,7 +1964,7 @@ func TestService_LogRemediation_LongPromptTruncation(t *testing.T) { Prompt: longPrompt, } - svc.logRemediation(req, "command", "output", false) + svc.logRemediation(req, "docker restart app", "output", false) history := remLog.GetForResource("vm-100-truncation-test", 10) if len(history) != 1 { diff --git a/internal/ai/service_remediation_test.go b/internal/ai/service_remediation_test.go index 19960ac..46b3f00 100644 --- a/internal/ai/service_remediation_test.go +++ b/internal/ai/service_remediation_test.go @@ -26,21 +26,21 @@ func TestService_Remediation(t *testing.T) { remLog := NewRemediationLog(RemediationLogConfig{DataDir: tmpDir}) svc.SetRemediationLog(remLog) - // Test logRemediation + // Test logRemediation - use an actionable command (not diagnostic) req := ExecuteRequest{ TargetID: "vm-101", TargetType: "vm", Prompt: "High CPU", } - svc.logRemediation(req, "top", "output", true) + svc.logRemediation(req, "systemctl restart myservice", "output", true) // Verify it was logged history := remLog.GetForResource("vm-101", 5) if len(history) != 1 { t.Fatalf("Expected 1 remediation record, got %d", len(history)) } - if history[0].Action != "top" { - t.Errorf("Expected action 'top', got %s", history[0].Action) + if history[0].Action != "systemctl restart myservice" { + t.Errorf("Expected action 'systemctl restart myservice', got %s", history[0].Action) } // Test buildRemediationContext @@ -48,7 +48,7 @@ func TestService_Remediation(t *testing.T) { if !containsString(ctx, "Remediation History for This Resource") { t.Error("Expected remediation history section in context") } - if !containsString(ctx, "top") { + if !containsString(ctx, "systemctl restart myservice") { t.Error("Expected logged action in context") } diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index fb5ca20..3cab638 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2212,6 +2212,64 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http } } +// HandleGetIntelligence returns the unified AI intelligence summary (GET /api/ai/intelligence) +// This provides a single endpoint for system-wide AI insights including: +// - Overall health score and grade +// - Active findings summary +// - Upcoming predictions +// - Recent activity +// - Learning progress +// - Resources at risk +func (h *AISettingsHandler) HandleGetIntelligence(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 { + // Return empty intelligence when not initialized + response := map[string]interface{}{ + "error": "AI patrol service not available", + } + w.WriteHeader(http.StatusServiceUnavailable) + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to write intelligence response") + } + return + } + + // Get unified intelligence facade + intel := patrol.GetIntelligence() + if intel == nil { + response := map[string]interface{}{ + "error": "Intelligence not initialized", + } + w.WriteHeader(http.StatusServiceUnavailable) + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to write intelligence response") + } + return + } + + // Check for resource_id query parameter for resource-specific intelligence + resourceID := r.URL.Query().Get("resource_id") + if resourceID != "" { + // Return resource-specific intelligence + resourceIntel := intel.GetResourceIntelligence(resourceID) + if err := utils.WriteJSONResponse(w, resourceIntel); err != nil { + log.Error().Err(err).Msg("Failed to write resource intelligence response") + } + return + } + + // Return system-wide intelligence summary + summary := intel.GetSummary() + if err := utils.WriteJSONResponse(w, summary); err != nil { + log.Error().Err(err).Msg("Failed to write intelligence summary response") + } +} + // HandlePatrolStream streams real-time patrol analysis via SSE (GET /api/ai/patrol/stream) func (h *AISettingsHandler) HandlePatrolStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/api/router.go b/internal/api/router.go index 59aca54..1efac41 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1207,6 +1207,9 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, LicenseGatedEmptyResponse(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleGetDismissedFindings))) // AI Intelligence endpoints - expose learned patterns, correlations, and predictions + // Unified intelligence endpoint - aggregates all AI subsystems into a single view + r.mux.HandleFunc("/api/ai/intelligence", RequireAuth(r.config, r.aiSettingsHandler.HandleGetIntelligence)) + // Individual sub-endpoints for specific intelligence layers 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))