Pulse/internal/ai/context/formatter.go
rcourtman 9e339957c6 fix: Update runtime config when toggling Docker update actions setting
The DisableDockerUpdateActions setting was being saved to disk but not
updated in h.config, causing the UI toggle to appear to revert on page
refresh since the API returned the stale runtime value.

Related to #1023
2026-01-03 11:14:17 +00:00

518 lines
14 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.VMID > 0 {
sb.WriteString(fmt.Sprintf(" (VMID %d)", ctx.VMID))
}
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 (computed summaries - kept for backwards compatibility)
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")
}
}
// Raw metric samples - let the LLM interpret patterns directly
// This is more reliable than pre-computed trends for edge cases
if len(ctx.MetricSamples) > 0 {
sb.WriteString("**History (24h sampled, oldest→newest)**: ")
var sampleLines []string
for metric, points := range ctx.MetricSamples {
if len(points) >= 3 {
line := formatMetricSamples(metric, points)
if line != "" {
sampleLines = append(sampleLines, line)
}
}
}
if len(sampleLines) > 0 {
sb.WriteString(strings.Join(sampleLines, " | "))
}
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("(rising %s)", rate)
case TrendDeclining:
rate := formatRate(-trend.RatePerDay) // Make positive for display
directionStr = fmt.Sprintf("(falling %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.01 {
return fmt.Sprintf("%.2f/hr", ratePerHour)
}
return "slow"
}
// formatMetricSamples creates a compact representation of sampled values
// Example output: "Disk: 26→26→26→31→31→31" (shows step change visually)
// This lets the LLM interpret patterns directly rather than relying on computed rates
func formatMetricSamples(metric string, points []MetricPoint) string {
if len(points) < 3 {
return ""
}
metricLabel := strings.Title(metric)
// Build compact arrow-separated value list
var values []string
prevValue := -1.0
for _, p := range points {
roundedValue := float64(int(p.Value + 0.5)) // Round to nearest integer
// Skip consecutive duplicates for compactness
if roundedValue == prevValue && len(values) > 0 {
continue
}
values = append(values, fmt.Sprintf("%.0f", roundedValue))
prevValue = roundedValue
}
// If all values are the same, just show "stable at X%"
if len(values) == 1 {
return fmt.Sprintf("%s: stable at %.0f%%", metricLabel, prevValue)
}
// Join with arrows to show progression
return fmt.Sprintf("%s: %s%%", metricLabel, strings.Join(values, "→"))
}
// DownsampleMetrics takes raw metric points and returns a smaller set for LLM consumption
// It aims for about 10-15 samples across the time range, picking representative values
func DownsampleMetrics(points []MetricPoint, targetSamples int) []MetricPoint {
if len(points) <= targetSamples {
return points
}
if targetSamples < 3 {
targetSamples = 3
}
// Calculate step size
step := len(points) / targetSamples
if step < 1 {
step = 1
}
var sampled []MetricPoint
for i := 0; i < len(points); i += step {
sampled = append(sampled, points[i])
}
// Always include the last point (current value)
if len(sampled) > 0 && sampled[len(sampled)-1].Timestamp != points[len(points)-1].Timestamp {
sampled = append(sampled, points[len(points)-1])
}
return sampled
}
// 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/OCI 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 prediction: %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 "oci_container":
return "OCI 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,
vmid int,
cpu, memUsage, diskUsage float64,
uptime int64,
lastBackup time.Time,
trends map[string]Trend,
) ResourceContext {
ctx := ResourceContext{
ResourceID: id,
ResourceType: guestType,
ResourceName: name,
Node: node,
VMID: vmid,
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
}