Files
rcourtman e2b6fe3b16 Preserve canonical incident history and Assistant handoffs
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
2026-09-08 02:08:15 +01:00

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
}