Phase 2 of Pulse AI differentiation: - Create internal/ai/baseline package for learned baselines - Implement statistical baseline learning with mean, stddev, percentiles - Add z-score based anomaly detection with severity classification (low, medium, high, critical based on standard deviations) - Integrate baseline provider into context builder - Wire baseline store into patrol service with adapters - Add anomaly enrichment to resource contexts Key features: - Learn computes baseline from historical metric data points - IsAnomaly and CheckAnomaly detect deviations from normal - Persists baselines to disk as JSON for durability - Formatted anomaly descriptions for AI consumption Example: 'Memory is high above normal (85.2% vs typical 42.1% ± 8.3%)' The baseline store needs to be initialized and triggered to learn from metrics history. Next step is adding the learning loop. All tests passing.
430 lines
11 KiB
Go
430 lines
11 KiB
Go
package context
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
// FormatResourceContext formats a single resource's context for AI consumption
|
|
func FormatResourceContext(ctx ResourceContext) string {
|
|
var sb strings.Builder
|
|
|
|
// Header with resource identity
|
|
typeLabel := formatResourceType(ctx.ResourceType)
|
|
sb.WriteString(fmt.Sprintf("### %s: %s", typeLabel, ctx.ResourceName))
|
|
if ctx.Node != "" && ctx.ResourceType != "node" {
|
|
sb.WriteString(fmt.Sprintf(" (on %s)", ctx.Node))
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
// Current state
|
|
sb.WriteString(fmt.Sprintf("**Status**: %s", ctx.Status))
|
|
if ctx.Uptime > 0 {
|
|
sb.WriteString(fmt.Sprintf(" | **Uptime**: %s", formatDuration(ctx.Uptime)))
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
// Current metrics
|
|
var metrics []string
|
|
if ctx.CurrentCPU >= 0 {
|
|
metrics = append(metrics, fmt.Sprintf("CPU: %.1f%%", ctx.CurrentCPU))
|
|
}
|
|
if ctx.CurrentMemory >= 0 {
|
|
metrics = append(metrics, fmt.Sprintf("Memory: %.1f%%", ctx.CurrentMemory))
|
|
}
|
|
if ctx.CurrentDisk >= 0 {
|
|
metrics = append(metrics, fmt.Sprintf("Disk: %.1f%%", ctx.CurrentDisk))
|
|
}
|
|
if len(metrics) > 0 {
|
|
sb.WriteString("**Current**: " + strings.Join(metrics, " | ") + "\n")
|
|
}
|
|
|
|
// Trends section (the differentiating context)
|
|
if len(ctx.Trends) > 0 {
|
|
var trendLines []string
|
|
for metric, trend := range ctx.Trends {
|
|
if trend.DataPoints < 3 {
|
|
continue // Skip if not enough data
|
|
}
|
|
line := formatTrendLine(metric, trend)
|
|
if line != "" {
|
|
trendLines = append(trendLines, line)
|
|
}
|
|
}
|
|
if len(trendLines) > 0 {
|
|
sb.WriteString("**Trends**: ")
|
|
sb.WriteString(strings.Join(trendLines, " | "))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Anomalies (high value - what's unusual)
|
|
if len(ctx.Anomalies) > 0 {
|
|
sb.WriteString("**⚠️ Anomalies**: ")
|
|
var anomalyDescs []string
|
|
for _, a := range ctx.Anomalies {
|
|
anomalyDescs = append(anomalyDescs, a.Description)
|
|
}
|
|
sb.WriteString(strings.Join(anomalyDescs, "; "))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Predictions (proactive value)
|
|
if len(ctx.Predictions) > 0 {
|
|
sb.WriteString("**⏰ Predictions**: ")
|
|
var predDescs []string
|
|
for _, p := range ctx.Predictions {
|
|
predDescs = append(predDescs, fmt.Sprintf("%s in ~%.0f days", p.Event, p.DaysUntil))
|
|
}
|
|
sb.WriteString(strings.Join(predDescs, "; "))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// User notes (context that only Pulse knows)
|
|
if len(ctx.UserNotes) > 0 {
|
|
sb.WriteString("**User Notes**: ")
|
|
sb.WriteString(strings.Join(ctx.UserNotes, "; "))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Past issues (operational memory)
|
|
if len(ctx.PastIssues) > 0 || ctx.LastRemediation != "" {
|
|
sb.WriteString("**History**: ")
|
|
if ctx.LastRemediation != "" {
|
|
sb.WriteString(ctx.LastRemediation)
|
|
}
|
|
if len(ctx.PastIssues) > 0 {
|
|
sb.WriteString(" Past issues: " + strings.Join(ctx.PastIssues, "; "))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// formatTrendLine creates a compact trend description
|
|
func formatTrendLine(metric string, trend Trend) string {
|
|
if trend.DataPoints < 3 {
|
|
return ""
|
|
}
|
|
|
|
metricLabel := strings.Title(metric)
|
|
|
|
// Direction with rate
|
|
var directionStr string
|
|
switch trend.Direction {
|
|
case TrendGrowing:
|
|
rate := formatRate(trend.RatePerDay)
|
|
directionStr = fmt.Sprintf("↑ %s", rate)
|
|
case TrendDeclining:
|
|
rate := formatRate(-trend.RatePerDay) // Make positive for display
|
|
directionStr = fmt.Sprintf("↓ %s", rate)
|
|
case TrendVolatile:
|
|
directionStr = "⚡ volatile"
|
|
case TrendStable:
|
|
directionStr = "→ stable"
|
|
default:
|
|
return ""
|
|
}
|
|
|
|
// Include range if interesting
|
|
rangeStr := ""
|
|
if trend.Max-trend.Min > 5 { // Only show range if variation is significant
|
|
rangeStr = fmt.Sprintf(" (%.0f-%.0f%%)", trend.Min, trend.Max)
|
|
}
|
|
|
|
return fmt.Sprintf("%s: %s%s", metricLabel, directionStr, rangeStr)
|
|
}
|
|
|
|
// formatRate formats a rate value appropriately
|
|
func formatRate(ratePerDay float64) string {
|
|
absRate := ratePerDay
|
|
if absRate < 0 {
|
|
absRate = -absRate
|
|
}
|
|
|
|
if absRate >= 1 {
|
|
return fmt.Sprintf("%.1f/day", absRate)
|
|
}
|
|
// Convert to per hour if < 1/day
|
|
ratePerHour := absRate / 24
|
|
if ratePerHour >= 0.1 {
|
|
return fmt.Sprintf("%.1f/hr", ratePerHour)
|
|
}
|
|
return "slow"
|
|
}
|
|
|
|
// FormatInfrastructureContext formats full infrastructure context for AI
|
|
func FormatInfrastructureContext(ctx *InfrastructureContext) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString("# Infrastructure State with Historical Context\n\n")
|
|
sb.WriteString(fmt.Sprintf("*Generated at %s | Monitoring %d resources*\n\n",
|
|
ctx.GeneratedAt.Format("2006-01-02 15:04"),
|
|
ctx.TotalResources))
|
|
|
|
// Global anomalies first (high priority)
|
|
if len(ctx.Anomalies) > 0 {
|
|
sb.WriteString("## ⚠️ Current Anomalies\n")
|
|
for _, a := range ctx.Anomalies {
|
|
sb.WriteString(fmt.Sprintf("- **%s**: %s\n", a.Metric, a.Description))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Predictions (proactive insights)
|
|
if len(ctx.Predictions) > 0 {
|
|
sb.WriteString("## ⏰ Predictions\n")
|
|
for _, p := range ctx.Predictions {
|
|
sb.WriteString(fmt.Sprintf("- **%s** on %s: %s (%.0f days, %.0f%% confidence)\n",
|
|
p.Event, p.ResourceID, p.Basis, p.DaysUntil, p.Confidence*100))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Recent changes (what's different)
|
|
if len(ctx.Changes) > 0 {
|
|
sb.WriteString("## 🔄 Recent Changes\n")
|
|
for _, c := range ctx.Changes {
|
|
sb.WriteString(fmt.Sprintf("- %s: %s\n", c.ResourceName, c.Description))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Resources by type
|
|
if len(ctx.Nodes) > 0 {
|
|
sb.WriteString("## Proxmox Nodes\n")
|
|
for _, r := range ctx.Nodes {
|
|
sb.WriteString(FormatResourceContext(r))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if len(ctx.VMs) > 0 {
|
|
sb.WriteString("## Virtual Machines\n")
|
|
for _, r := range ctx.VMs {
|
|
sb.WriteString(FormatResourceContext(r))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if len(ctx.Containers) > 0 {
|
|
sb.WriteString("## LXC Containers\n")
|
|
for _, r := range ctx.Containers {
|
|
sb.WriteString(FormatResourceContext(r))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if len(ctx.Storage) > 0 {
|
|
sb.WriteString("## Storage\n")
|
|
for _, r := range ctx.Storage {
|
|
sb.WriteString(FormatResourceContext(r))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if len(ctx.DockerHosts) > 0 {
|
|
sb.WriteString("## Docker Hosts\n")
|
|
for _, r := range ctx.DockerHosts {
|
|
sb.WriteString(FormatResourceContext(r))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if len(ctx.Hosts) > 0 {
|
|
sb.WriteString("## Agent Hosts\n")
|
|
for _, r := range ctx.Hosts {
|
|
sb.WriteString(FormatResourceContext(r))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// FormatCompactSummary creates a brief overview suitable for context-limited prompts
|
|
func FormatCompactSummary(ctx *InfrastructureContext) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(fmt.Sprintf("Infrastructure: %d resources\n", ctx.TotalResources))
|
|
|
|
// Count by status
|
|
var healthy, warning, critical int
|
|
countResource := func(resources []ResourceContext) {
|
|
for _, r := range resources {
|
|
switch {
|
|
case len(r.Anomalies) > 0:
|
|
critical++
|
|
case hasGrowingTrend(r):
|
|
warning++
|
|
default:
|
|
healthy++
|
|
}
|
|
}
|
|
}
|
|
countResource(ctx.Nodes)
|
|
countResource(ctx.VMs)
|
|
countResource(ctx.Containers)
|
|
countResource(ctx.Storage)
|
|
countResource(ctx.DockerHosts)
|
|
countResource(ctx.Hosts)
|
|
|
|
sb.WriteString(fmt.Sprintf("Health: %d healthy, %d warning, %d critical\n", healthy, warning, critical))
|
|
|
|
if len(ctx.Anomalies) > 0 {
|
|
sb.WriteString(fmt.Sprintf("Anomalies: %d active\n", len(ctx.Anomalies)))
|
|
}
|
|
|
|
if len(ctx.Predictions) > 0 {
|
|
// Show most urgent prediction
|
|
earliest := ctx.Predictions[0]
|
|
for _, p := range ctx.Predictions[1:] {
|
|
if p.DaysUntil < earliest.DaysUntil {
|
|
earliest = p
|
|
}
|
|
}
|
|
sb.WriteString(fmt.Sprintf("⏰ Nearest: %s in %.0f days\n", earliest.Event, earliest.DaysUntil))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// hasGrowingTrend checks if any metric trend is concerning
|
|
func hasGrowingTrend(r ResourceContext) bool {
|
|
for _, t := range r.Trends {
|
|
if t.Direction == TrendGrowing && t.RatePerDay > 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// formatResourceType converts internal type to display label
|
|
func formatResourceType(t string) string {
|
|
switch t {
|
|
case "node":
|
|
return "Node"
|
|
case "vm":
|
|
return "VM"
|
|
case "container":
|
|
return "Container"
|
|
case "storage":
|
|
return "Storage"
|
|
case "docker_host":
|
|
return "Docker Host"
|
|
case "docker_container":
|
|
return "Docker Container"
|
|
case "host":
|
|
return "Host"
|
|
default:
|
|
return strings.Title(t)
|
|
}
|
|
}
|
|
|
|
// formatDuration formats a duration in human-readable form
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
}
|
|
if d < 24*time.Hour {
|
|
hours := int(d.Hours())
|
|
mins := int(d.Minutes()) % 60
|
|
if mins > 0 {
|
|
return fmt.Sprintf("%dh%dm", hours, mins)
|
|
}
|
|
return fmt.Sprintf("%dh", hours)
|
|
}
|
|
days := int(d.Hours() / 24)
|
|
hours := int(d.Hours()) % 24
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%dd%dh", days, hours)
|
|
}
|
|
return fmt.Sprintf("%dd", days)
|
|
}
|
|
|
|
// FormatBackupStatus creates a human-readable backup status
|
|
func FormatBackupStatus(lastBackup time.Time) string {
|
|
if lastBackup.IsZero() {
|
|
return "never"
|
|
}
|
|
age := time.Since(lastBackup)
|
|
if age < 24*time.Hour {
|
|
return fmt.Sprintf("%.0fh ago", age.Hours())
|
|
}
|
|
days := age.Hours() / 24
|
|
return fmt.Sprintf("%.0fd ago", days)
|
|
}
|
|
|
|
// FormatNodeForContext creates context for a Proxmox node
|
|
func FormatNodeForContext(node models.Node, trends map[string]Trend) ResourceContext {
|
|
// Calculate memory percentage
|
|
memPct := 0.0
|
|
if node.Memory.Total > 0 {
|
|
memPct = float64(node.Memory.Used) / float64(node.Memory.Total) * 100
|
|
}
|
|
|
|
ctx := ResourceContext{
|
|
ResourceID: node.ID,
|
|
ResourceType: "node",
|
|
ResourceName: node.Name,
|
|
CurrentCPU: node.CPU * 100, // Convert from 0-1 to percentage
|
|
CurrentMemory: memPct,
|
|
Status: node.Status,
|
|
Uptime: time.Duration(node.Uptime) * time.Second,
|
|
Trends: trends,
|
|
}
|
|
|
|
return ctx
|
|
}
|
|
|
|
// FormatGuestForContext creates context for a VM or container
|
|
// Note: cpu is 0-1 ratio from Proxmox API, memUsage and diskUsage are already 0-100 percentages
|
|
func FormatGuestForContext(
|
|
id, name, node, guestType, status string,
|
|
cpu, memUsage, diskUsage float64,
|
|
uptime int64,
|
|
lastBackup time.Time,
|
|
trends map[string]Trend,
|
|
) ResourceContext {
|
|
ctx := ResourceContext{
|
|
ResourceID: id,
|
|
ResourceType: guestType,
|
|
ResourceName: name,
|
|
Node: node,
|
|
CurrentCPU: cpu * 100, // Convert from 0-1 to percentage
|
|
CurrentMemory: memUsage, // Already 0-100 percentage from Memory.Usage
|
|
CurrentDisk: diskUsage, // Already 0-100 percentage from Disk.Usage
|
|
Status: status,
|
|
Uptime: time.Duration(uptime) * time.Second,
|
|
Trends: trends,
|
|
}
|
|
|
|
return ctx
|
|
}
|
|
|
|
// FormatStorageForContext creates context for storage
|
|
func FormatStorageForContext(storage models.Storage, trends map[string]Trend) ResourceContext {
|
|
usagePct := storage.Usage
|
|
if usagePct == 0 && storage.Total > 0 {
|
|
usagePct = float64(storage.Used) / float64(storage.Total) * 100
|
|
}
|
|
|
|
ctx := ResourceContext{
|
|
ResourceID: storage.ID,
|
|
ResourceType: "storage",
|
|
ResourceName: storage.Name,
|
|
Node: storage.Node,
|
|
CurrentDisk: usagePct,
|
|
Status: storage.Status,
|
|
Trends: trends,
|
|
}
|
|
|
|
return ctx
|
|
}
|