mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
e2b6fe3b16
Filter canonical history before selecting occurrences, preserve source evidence and expose bounded reads and failures. Reconcile duplicate saved shells without splitting one alert lifecycle, and keep note identity and canonical risk intact. Carry attributed operator notes into Assistant. Preserve mobile investigations across layout changes, transfer composer focus on handoff and keep long event text readable. Record scoped qualification and its unresolved wider limits. Refs #1782
1419 lines
40 KiB
Go
1419 lines
40 KiB
Go
// 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"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
// 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 {
|
|
IncidentHistory *memory.IncidentHistoryCoverage `json:"incident_history,omitempty"`
|
|
IncidentHistoryError string `json:"incident_history_error,omitempty"`
|
|
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"`
|
|
RecentChanges []unifiedresources.ResourceChange `json:"recent_changes,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"`
|
|
RecentChanges []unifiedresources.ResourceChange `json:"recent_changes,omitempty"`
|
|
RecentRemediations []memory.RemediationRecord `json:"recent_remediations,omitempty"`
|
|
|
|
// Data governance posture
|
|
PolicyPosture *unifiedresources.PolicyPostureSummary `json:"policy_posture,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
|
|
runHistoryStore *PatrolRunHistoryStore
|
|
resourceTimelineStore unifiedresources.ResourceStore
|
|
resourceTimelineStoreOrgID string
|
|
unifiedResourceProvider UnifiedResourceProvider
|
|
|
|
// State access
|
|
stateProvider StateProvider
|
|
|
|
// Optional hook for anomaly detection (used by patrol integration/tests)
|
|
anomalyDetector func(resourceID string) []AnomalyReport
|
|
|
|
// Configuration
|
|
dataDir string
|
|
}
|
|
|
|
const (
|
|
intelligencePatrolCoverageWindow = 24 * time.Hour
|
|
intelligenceRecentRunLimit = 10
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetResourceTimelineStore wires the canonical unified-resource timeline used
|
|
// for recent change summaries when available.
|
|
func (i *Intelligence) SetResourceTimelineStore(store unifiedresources.ResourceStore, orgID string) {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
i.resourceTimelineStore = store
|
|
i.resourceTimelineStoreOrgID = strings.TrimSpace(orgID)
|
|
}
|
|
|
|
// SetRunHistoryStore wires Patrol run history so intelligence summaries can
|
|
// distinguish broad successful coverage from recent scoped or incomplete runs.
|
|
func (i *Intelligence) SetRunHistoryStore(store *PatrolRunHistoryStore) {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
i.runHistoryStore = store
|
|
}
|
|
|
|
// SetUnifiedResourceProvider wires the canonical unified resource provider used
|
|
// for infrastructure-wide posture summaries.
|
|
func (i *Intelligence) SetUnifiedResourceProvider(urp UnifiedResourceProvider) {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
i.unifiedResourceProvider = urp
|
|
}
|
|
|
|
// 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 {
|
|
summary := &IntelligenceSummary{
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
i.mu.RLock()
|
|
findings := i.findings
|
|
patternsDetector := i.patterns
|
|
remediations := i.remediations
|
|
runHistoryStore := i.runHistoryStore
|
|
unifiedResourceProvider := i.unifiedResourceProvider
|
|
i.mu.RUnlock()
|
|
|
|
// Aggregate findings
|
|
if findings != nil {
|
|
all := findings.GetActive(FindingSeverityInfo) // Get all active findings
|
|
summary.FindingsCount = i.countFindings(all)
|
|
summary.TopFindings = i.getTopFindings(all, 5)
|
|
}
|
|
|
|
// Aggregate predictions
|
|
if patternsDetector != nil {
|
|
predictions := patternsDetector.GetPredictions()
|
|
summary.PredictionsCount = len(predictions)
|
|
summary.UpcomingRisks = i.getUpcomingRisks(predictions, 5)
|
|
}
|
|
|
|
// Aggregate recent activity
|
|
if recent := i.GetRecentChanges(time.Now().Add(-24*time.Hour), 100); len(recent) > 0 {
|
|
summary.RecentChangesCount = len(recent)
|
|
summary.RecentChanges = append([]unifiedresources.ResourceChange{}, recent[:min(len(recent), 5)]...)
|
|
}
|
|
|
|
if remediations != nil {
|
|
recent := remediations.GetRecentRemediations(5, time.Now().Add(-24*time.Hour))
|
|
summary.RecentRemediations = recent
|
|
}
|
|
|
|
if unifiedResourceProvider != nil {
|
|
summary.PolicyPosture = unifiedresources.SummarizePolicyPosture(
|
|
unifiedresources.RefreshCanonicalMetadataSlice(unifiedResourceProvider.GetAll()),
|
|
)
|
|
}
|
|
|
|
// Learning stats
|
|
summary.Learning = i.getLearningStats()
|
|
|
|
// Calculate overall health
|
|
summary.OverallHealth = i.calculateOverallHealth(summary, runHistoryStore)
|
|
|
|
// 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 {
|
|
page, err := i.incidents.QueryIncidents(memory.IncidentQuery{ResourceID: resourceID, Limit: 5})
|
|
if err != nil {
|
|
intel.IncidentHistoryError = "Canonical incident history unavailable"
|
|
} else {
|
|
intel.RecentIncidents = page.Incidents
|
|
intel.IncidentHistory = &page.History
|
|
}
|
|
}
|
|
|
|
// Recent changes
|
|
if recentChanges := i.getRecentChangesForResource(resourceID, 5); len(recentChanges) > 0 {
|
|
intel.RecentChanges = recentChanges
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// HasRecentChangesSource reports whether the intelligence layer has any source
|
|
// available for recent infrastructure changes.
|
|
func (i *Intelligence) HasRecentChangesSource() bool {
|
|
i.mu.RLock()
|
|
defer i.mu.RUnlock()
|
|
return i.resourceTimelineStore != nil || i.changes != nil
|
|
}
|
|
|
|
// DescribeResource returns the canonical display name and type for a resource
|
|
// when the unified resource provider is available.
|
|
func (i *Intelligence) DescribeResource(resourceID string) (string, string) {
|
|
resourceID = strings.TrimSpace(resourceID)
|
|
if resourceID == "" {
|
|
return "", ""
|
|
}
|
|
|
|
i.mu.RLock()
|
|
unifiedResourceProvider := i.unifiedResourceProvider
|
|
i.mu.RUnlock()
|
|
if unifiedResourceProvider == nil {
|
|
return "", ""
|
|
}
|
|
|
|
for _, resource := range unifiedresources.RefreshCanonicalMetadataSlice(unifiedResourceProvider.GetAll()) {
|
|
if strings.TrimSpace(resource.ID) != resourceID {
|
|
continue
|
|
}
|
|
return unifiedresources.ResourceDisplayName(resource), string(resource.Type)
|
|
}
|
|
|
|
return "", ""
|
|
}
|
|
|
|
// GetRecentChanges returns the most recent infrastructure changes across the
|
|
// canonical unified-resource timeline, with patrol-local memory as fallback.
|
|
func (i *Intelligence) GetRecentChanges(since time.Time, limit int) []unifiedresources.ResourceChange {
|
|
if limit <= 0 {
|
|
return nil
|
|
}
|
|
|
|
i.mu.RLock()
|
|
resourceTimelineStore := i.resourceTimelineStore
|
|
changesDetector := i.changes
|
|
i.mu.RUnlock()
|
|
|
|
if resourceTimelineStore != nil {
|
|
if recent, err := resourceTimelineStore.GetRecentChanges("", since, limit); err == nil && len(recent) > 0 {
|
|
return recent
|
|
}
|
|
}
|
|
|
|
if changesDetector == nil {
|
|
return nil
|
|
}
|
|
|
|
recent := changesDetector.GetRecentChanges(limit, since)
|
|
if len(recent) == 0 {
|
|
return nil
|
|
}
|
|
|
|
converted := make([]unifiedresources.ResourceChange, 0, len(recent))
|
|
for _, change := range recent {
|
|
converted = append(converted, memory.ResourceChangeFromMemoryChange(change))
|
|
}
|
|
return converted
|
|
}
|
|
|
|
// FormatContext builds a comprehensive context string for AI prompts
|
|
func (i *Intelligence) FormatContext(resourceID string) string {
|
|
i.mu.RLock()
|
|
knowledgeStore := i.knowledge
|
|
baselinesStore := i.baselines
|
|
patternsDetector := i.patterns
|
|
correlationsDetector := i.correlations
|
|
incidentsStore := i.incidents
|
|
resourceTimelineStore := i.resourceTimelineStore
|
|
changesDetector := i.changes
|
|
anomalyDetector := i.anomalyDetector
|
|
i.mu.RUnlock()
|
|
|
|
var sections []string
|
|
|
|
// Knowledge (most important - what we've learned)
|
|
if knowledgeStore != nil {
|
|
if ctx := knowledgeStore.FormatForContext(resourceID); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
// Baselines (what's normal for this resource)
|
|
if baselinesStore != nil {
|
|
if ctx := i.formatBaselinesForContext(resourceID); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
// Current anomalies
|
|
if anomalyDetector != nil {
|
|
if anomalies := anomalyDetector(resourceID); len(anomalies) > 0 {
|
|
sections = append(sections, i.formatAnomaliesForContext(anomalies))
|
|
}
|
|
}
|
|
|
|
// Canonical recent changes (preferred) with patrol-local fallback
|
|
if recentChanges := i.buildRecentChangesContext(
|
|
resourceID,
|
|
resourceTimelineStore,
|
|
changesDetector,
|
|
false,
|
|
5,
|
|
); recentChanges != "" {
|
|
sections = append(sections, recentChanges)
|
|
}
|
|
|
|
// Patterns/Predictions
|
|
if patternsDetector != nil {
|
|
if ctx := patternsDetector.FormatForContext(resourceID); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
// Correlations
|
|
if correlationsDetector != nil {
|
|
if ctx := correlationsDetector.FormatForContext(resourceID); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
// Incidents
|
|
if incidentsStore != nil {
|
|
if ctx := incidentsStore.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()
|
|
knowledgeStore := i.knowledge
|
|
incidentsStore := i.incidents
|
|
correlationsDetector := i.correlations
|
|
patternsDetector := i.patterns
|
|
resourceTimelineStore := i.resourceTimelineStore
|
|
changesDetector := i.changes
|
|
i.mu.RUnlock()
|
|
|
|
var sections []string
|
|
|
|
// All saved knowledge (limited)
|
|
if knowledgeStore != nil {
|
|
if ctx := knowledgeStore.FormatAllForContext(); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
// Canonical recent changes across infrastructure (preferred) with patrol-local fallback
|
|
if recentChanges := i.buildRecentChangesContext(
|
|
"",
|
|
resourceTimelineStore,
|
|
changesDetector,
|
|
true,
|
|
5,
|
|
); recentChanges != "" {
|
|
sections = append(sections, recentChanges)
|
|
}
|
|
|
|
// Recent incidents across infrastructure
|
|
if incidentsStore != nil {
|
|
if ctx := incidentsStore.FormatForPatrol(8); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
// Top correlations
|
|
if correlationsDetector != nil {
|
|
if ctx := correlationsDetector.FormatForContext(""); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
// Top predictions
|
|
if patternsDetector != nil {
|
|
if ctx := patternsDetector.FormatForContext(""); ctx != "" {
|
|
sections = append(sections, ctx)
|
|
}
|
|
}
|
|
|
|
return strings.Join(sections, "\n")
|
|
}
|
|
|
|
func (i *Intelligence) buildRecentChangesContext(resourceID string, resourceTimelineStore unifiedresources.ResourceStore, changesDetector *memory.ChangeDetector, includeResourcePrefix bool, limit int) string {
|
|
since := time.Now().Add(-24 * time.Hour)
|
|
|
|
if resourceTimelineStore != nil {
|
|
if recent, err := resourceTimelineStore.GetRecentChanges(resourceID, since, limit); err == nil && len(recent) > 0 {
|
|
return unifiedresources.FormatResourceRecentChangesContext(recent, includeResourcePrefix, "##")
|
|
}
|
|
}
|
|
|
|
if changesDetector == nil {
|
|
return ""
|
|
}
|
|
|
|
var recent []memory.Change
|
|
if resourceID != "" {
|
|
recent = changesDetector.GetChangesForResource(resourceID, limit)
|
|
} else {
|
|
recent = changesDetector.GetRecentChanges(limit, since)
|
|
}
|
|
if len(recent) == 0 {
|
|
return ""
|
|
}
|
|
return memory.FormatRecentChangesContext(recent, includeResourcePrefix, "##")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, runHistoryStore *PatrolRunHistoryStore) 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",
|
|
})
|
|
}
|
|
}
|
|
|
|
if factor, ok := summarizeRecentPatrolCoverage(runHistoryStore, time.Now()); ok {
|
|
health.Score -= factor.impact
|
|
health.Factors = append(health.Factors, HealthFactor{
|
|
Name: factor.name,
|
|
Impact: -factor.impact / 100,
|
|
Description: factor.description,
|
|
Category: "coverage",
|
|
})
|
|
}
|
|
|
|
// 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("Pulse Patrol 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 {
|
|
for _, factor := range health.Factors {
|
|
if factor.Category == "coverage" {
|
|
return factor.Description
|
|
}
|
|
}
|
|
|
|
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."
|
|
}
|
|
|
|
type patrolCoverageFactor struct {
|
|
name string
|
|
description string
|
|
impact float64
|
|
}
|
|
|
|
func summarizeRecentPatrolCoverage(
|
|
runHistoryStore *PatrolRunHistoryStore,
|
|
now time.Time,
|
|
) (patrolCoverageFactor, bool) {
|
|
if runHistoryStore == nil {
|
|
return patrolCoverageFactor{}, false
|
|
}
|
|
|
|
recentRuns := limitPatrolRunRecords(
|
|
filterPatrolRunRecordsForRuntimeEvidence(runHistoryStore.GetAll()),
|
|
intelligenceRecentRunLimit,
|
|
)
|
|
if len(recentRuns) == 0 {
|
|
return patrolCoverageFactor{}, false
|
|
}
|
|
|
|
cutoff := now.Add(-intelligencePatrolCoverageWindow)
|
|
relevant := make([]PatrolRunRecord, 0, len(recentRuns))
|
|
for _, run := range recentRuns {
|
|
if run.CompletedAt.IsZero() || run.CompletedAt.Before(cutoff) {
|
|
continue
|
|
}
|
|
relevant = append(relevant, run)
|
|
}
|
|
if len(relevant) == 0 {
|
|
return patrolCoverageFactor{}, false
|
|
}
|
|
|
|
var recentErrors int
|
|
var hasSuccessfulFullRun bool
|
|
var hasRecentFullRun bool
|
|
var limitedActivityRuns int
|
|
for _, run := range relevant {
|
|
if run.ErrorCount > 0 || strings.EqualFold(strings.TrimSpace(run.Status), "error") {
|
|
recentErrors++
|
|
}
|
|
if isFullPatrolRun(run) {
|
|
hasRecentFullRun = true
|
|
} else {
|
|
limitedActivityRuns++
|
|
}
|
|
if isSuccessfulFullPatrolRun(run) {
|
|
hasSuccessfulFullRun = true
|
|
}
|
|
}
|
|
|
|
// Count trailing successful full Patrol runs starting from the most
|
|
// recent (relevant is newest-first per GetAll), skipping non-full
|
|
// runs. When Pulse has recovered from a run of errors — N
|
|
// consecutive successful full runs at the leading edge of the
|
|
// window — the score should reflect current reality, not drag stale
|
|
// failures forward. Without this, fixing a Patrol-affecting bug
|
|
// keeps the grade depressed for ~9 hours (the time it takes 3
|
|
// scheduled runs at 3-hour cadence to age out the failures).
|
|
trailingFullSuccesses := 0
|
|
for _, r := range relevant {
|
|
if !isFullPatrolRun(r) {
|
|
continue
|
|
}
|
|
if isSuccessfulFullPatrolRun(r) {
|
|
trailingFullSuccesses++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
|
|
limitedActivityLabel := describeLimitedPatrolActivity(relevant)
|
|
|
|
switch {
|
|
case !hasSuccessfulFullRun && hasRecentFullRun && recentErrors > 0:
|
|
return patrolCoverageFactor{
|
|
name: "Patrol coverage incomplete",
|
|
description: "Patrol needs a clean check: a recent check ended with errors, so current health may be incomplete.",
|
|
impact: 35,
|
|
}, true
|
|
case !hasSuccessfulFullRun && recentErrors > 0:
|
|
return patrolCoverageFactor{
|
|
name: "Patrol coverage incomplete",
|
|
description: fmt.Sprintf("Recent Patrol activity only covered %s and ended with errors. Run Patrol to check everything.", limitedActivityLabel),
|
|
impact: 35,
|
|
}, true
|
|
case !hasSuccessfulFullRun && limitedActivityRuns == len(relevant):
|
|
return patrolCoverageFactor{
|
|
name: "Patrol coverage incomplete",
|
|
description: fmt.Sprintf("Recent Patrol activity only covered %s. Run Patrol to check everything.", limitedActivityLabel),
|
|
impact: 20,
|
|
}, true
|
|
case recentErrors > 0:
|
|
// Recovery suppression: if Pulse has had a clean stretch of
|
|
// consecutive successful full Patrol runs at the END of the
|
|
// window, the score should reflect current reality rather than
|
|
// drag forward stale failures. Three successful full runs is
|
|
// roughly a 9-hour window at the default 3-hour cadence — long
|
|
// enough that "we've genuinely stabilized" beats "we still
|
|
// have errors in the recent-10 ratio." Without this, fixing a
|
|
// Patrol-affecting bug kept the assessment depressed for hours
|
|
// after the actual problem was resolved.
|
|
const recoveredTrailingSuccessThreshold = 3
|
|
if trailingFullSuccesses >= recoveredTrailingSuccessThreshold {
|
|
return patrolCoverageFactor{}, false
|
|
}
|
|
// Tier the impact by error ratio so the score actually reflects how
|
|
// unreliable recent runs have been. The previous flat 10-point impact
|
|
// could not separate "1 error in 10 runs" (mostly healthy) from
|
|
// "8 errors in 10 runs" (mostly broken) — both produced grade A and
|
|
// contradicted the "recent Patrol runs encountered errors" warning
|
|
// the same factor surfaces. Dominant-error periods now drop the
|
|
// score out of the A band so the grade and the warning agree.
|
|
ratio := float64(recentErrors) / float64(len(relevant))
|
|
var impact float64
|
|
var description string
|
|
switch {
|
|
case ratio > 0.5:
|
|
impact = 30
|
|
description = fmt.Sprintf("Most recent Patrol runs encountered errors (%d of %d); the current health summary is not reliable until coverage stabilizes.", recentErrors, len(relevant))
|
|
case ratio > 0.25:
|
|
impact = 20
|
|
description = fmt.Sprintf("Recent Patrol runs encountered errors (%d of %d), so the current health summary may be incomplete.", recentErrors, len(relevant))
|
|
default:
|
|
impact = 10
|
|
description = "Recent Patrol runs encountered errors, so the current health summary may be incomplete."
|
|
}
|
|
return patrolCoverageFactor{
|
|
name: "Recent Patrol errors",
|
|
description: description,
|
|
impact: impact,
|
|
}, true
|
|
default:
|
|
return patrolCoverageFactor{}, false
|
|
}
|
|
}
|
|
|
|
func normalizePatrolRunType(run PatrolRunRecord) string {
|
|
return strings.ToLower(strings.TrimSpace(run.Type))
|
|
}
|
|
|
|
func isFullPatrolRun(run PatrolRunRecord) bool {
|
|
switch normalizePatrolRunType(run) {
|
|
case "", "full", "patrol":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isScopedPatrolRun(run PatrolRunRecord) bool {
|
|
return normalizePatrolRunType(run) == "scoped"
|
|
}
|
|
|
|
func isVerificationPatrolRun(run PatrolRunRecord) bool {
|
|
return normalizePatrolRunType(run) == "verification"
|
|
}
|
|
|
|
func describeLimitedPatrolActivity(runs []PatrolRunRecord) string {
|
|
hasScoped := false
|
|
hasVerification := false
|
|
hasOther := false
|
|
|
|
for _, run := range runs {
|
|
if isFullPatrolRun(run) {
|
|
continue
|
|
}
|
|
switch {
|
|
case isScopedPatrolRun(run):
|
|
hasScoped = true
|
|
case isVerificationPatrolRun(run):
|
|
hasVerification = true
|
|
default:
|
|
hasOther = true
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case hasScoped && !hasVerification && !hasOther:
|
|
return "targeted checks"
|
|
case hasVerification && !hasScoped && !hasOther:
|
|
return "follow-up checks"
|
|
case hasScoped && hasVerification && !hasOther:
|
|
return "targeted and follow-up checks"
|
|
default:
|
|
return "targeted checks"
|
|
}
|
|
}
|
|
|
|
func isSuccessfulFullPatrolRun(run PatrolRunRecord) bool {
|
|
return isFullPatrolRun(run) &&
|
|
run.ErrorCount == 0 &&
|
|
!strings.EqualFold(strings.TrimSpace(run.Status), "error")
|
|
}
|
|
|
|
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) {
|
|
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 {
|
|
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) getRecentChangesForResource(resourceID string, limit int) []unifiedresources.ResourceChange {
|
|
resourceID = strings.TrimSpace(resourceID)
|
|
if resourceID == "" || limit <= 0 {
|
|
return nil
|
|
}
|
|
|
|
since := time.Now().Add(-24 * time.Hour)
|
|
|
|
i.mu.RLock()
|
|
resourceTimelineStore := i.resourceTimelineStore
|
|
changesDetector := i.changes
|
|
i.mu.RUnlock()
|
|
|
|
if resourceTimelineStore != nil {
|
|
if recent, err := resourceTimelineStore.GetRecentChanges(resourceID, since, limit); err == nil && len(recent) > 0 {
|
|
return recent
|
|
}
|
|
}
|
|
|
|
if changesDetector == nil {
|
|
return nil
|
|
}
|
|
|
|
recent := changesDetector.GetChangesForResource(resourceID, limit)
|
|
if len(recent) == 0 {
|
|
return nil
|
|
}
|
|
|
|
converted := make([]unifiedresources.ResourceChange, 0, len(recent))
|
|
for _, change := range recent {
|
|
converted = append(converted, memory.ResourceChangeFromMemoryChange(change))
|
|
}
|
|
return converted
|
|
}
|
|
|
|
func (i *Intelligence) formatBaselinesForContext(resourceID string) string {
|
|
i.mu.RLock()
|
|
store := i.baselines
|
|
i.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
return ""
|
|
}
|
|
|
|
rb, ok := store.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(_ 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
|
|
}
|