Pulse/internal/ai/context/formatter.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- Add persistent volume mounts for Go/npm caches (faster rebuilds)
- Add shell config with helpful aliases and custom prompt
- Add comprehensive devcontainer documentation
- Add pre-commit hooks for Go formatting and linting
- Use go-version-file in CI workflows instead of hardcoded versions
- Simplify docker compose commands with --wait flag
- Add gitignore entries for devcontainer auth files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:29:15 +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.1 {
return fmt.Sprintf("%.1f/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
}