cleanup: remove legacy AI context fallback (buildInfrastructureContext)

The AI service now uses only buildUnifiedResourceContext() for
infrastructure context, since the resourceProvider is always set
during router initialization.

Removed:
- buildInfrastructureContext() function (~288 lines of dead code)
- Legacy fallback path in buildSystemPrompt()

The unified resource context provides a cleaner, deduplicated view
of infrastructure that includes:
- All resources grouped by platform and type
- Top CPU/Memory/Disk consumers
- Active alerts on resources
- Infrastructure summary statistics

This completes the AI service migration to unified resources.
This commit is contained in:
rcourtman 2025-12-08 09:21:03 +00:00
parent 7e8611e0ae
commit f7eed7b052
2 changed files with 3 additions and 294 deletions

View file

@ -548,8 +548,8 @@ The dedicated `/resources` unified view was abandoned in favor of migrating exis
- [x] Remove debug console.log statements from frontend routes
- [x] Remove legacy fallback code from route components (Docker, Hosts, Dashboard)
- [x] Simplify route components to use centralized `useResourcesAsLegacy()` hook
- [x] Remove legacy AI context fallback (`buildInfrastructureContext` removed, 288 lines)
- [ ] Remove unused legacy arrays from backend StateFrontend (optional - still broadcast for API compatibility)
- [ ] Remove legacy AI context fallback (optional - verify AI uses unified model first)
---

View file

@ -1760,18 +1760,15 @@ Pulse manages LXC containers agentlessly from the PVE host.
prompt += cfg.CustomContext
}
// Add connected infrastructure info
// Try unified resource context first (Phase 2), fall back to legacy
// Add connected infrastructure info via unified resource model
s.mu.RLock()
hasResourceProvider := s.resourceProvider != nil
s.mu.RUnlock()
if hasResourceProvider {
// Use the new unified resource model for cleaner, deduplicated context
prompt += s.buildUnifiedResourceContext()
} else {
// Fall back to legacy state-based context
prompt += s.buildInfrastructureContext()
log.Warn().Msg("AI context: resource provider not available, infrastructure context will be limited")
}
// Add user annotations from all resources (global context)
@ -1920,294 +1917,6 @@ func formatContextKey(key string) string {
return key
}
// buildInfrastructureContext returns detailed info about what Pulse can see and control
// This enables the AI to reason about cross-system troubleshooting paths
func (s *Service) buildInfrastructureContext() string {
var sections []string
// Load nodes config for infrastructure info
nodesConfig, err := s.persistence.LoadNodesConfig()
if err != nil {
log.Debug().Err(err).Msg("Failed to load nodes config for AI context")
return ""
}
// Build agent lookup map for matching
agentsByHostname := make(map[string]string) // hostname -> agentID
var connectedAgents []string
if s.agentServer != nil {
agents := s.agentServer.GetConnectedAgents()
for _, agent := range agents {
agentsByHostname[strings.ToLower(agent.Hostname)] = agent.AgentID
connectedAgents = append(connectedAgents, agent.Hostname)
}
}
// List PVE instances with agent status and cluster info
if nodesConfig != nil && len(nodesConfig.PVEInstances) > 0 {
sections = append(sections, "### Proxmox VE Nodes")
// Group nodes by cluster for better context
clusterNodes := make(map[string][]string) // clusterName -> list of node names
nodeHasAgent := make(map[string]bool)
for _, pve := range nodesConfig.PVEInstances {
hasAgent := false
for _, hostname := range connectedAgents {
// Check if agent hostname matches or contains PVE name
if strings.Contains(strings.ToLower(hostname), strings.ToLower(pve.Name)) ||
strings.Contains(strings.ToLower(pve.Name), strings.ToLower(hostname)) {
hasAgent = true
break
}
}
nodeHasAgent[pve.Name] = hasAgent
agentStatus := "NO AGENT"
if hasAgent {
agentStatus = "HAS AGENT"
}
// Build cluster membership info
clusterInfo := ""
if pve.IsCluster && pve.ClusterName != "" {
clusterInfo = fmt.Sprintf(" [cluster: %s]", pve.ClusterName)
clusterNodes[pve.ClusterName] = append(clusterNodes[pve.ClusterName], pve.Name)
// Also add cluster endpoints as members
for _, ep := range pve.ClusterEndpoints {
if ep.NodeName != pve.Name {
clusterNodes[pve.ClusterName] = append(clusterNodes[pve.ClusterName], ep.NodeName)
}
}
}
sections = append(sections, fmt.Sprintf("- **%s** (%s): %s%s", pve.Name, pve.Host, agentStatus, clusterInfo))
}
// Add cluster-aware execution hints
for clusterName, nodes := range clusterNodes {
// Check if any node in the cluster has an agent
var agentNodes []string
for _, node := range nodes {
if nodeHasAgent[node] {
agentNodes = append(agentNodes, node)
}
}
if len(agentNodes) > 0 {
// Remove duplicates from nodes list
uniqueNodes := make(map[string]bool)
for _, n := range nodes {
uniqueNodes[n] = true
}
var nodeList []string
for n := range uniqueNodes {
nodeList = append(nodeList, n)
}
sections = append(sections, fmt.Sprintf("\n**Cluster %s**: Nodes %v share storage and can manage each other's VMs/containers. Agent on %v can run pct/qm commands for guests on ANY node in this cluster.",
clusterName, nodeList, agentNodes))
}
}
}
// List PBS instances
if nodesConfig != nil && len(nodesConfig.PBSInstances) > 0 {
sections = append(sections, "\n### Proxmox Backup Servers (PBS)")
sections = append(sections, "Pulse automatically fetches backup data from these PBS servers. Backup status in guest context comes from here.")
for _, pbs := range nodesConfig.PBSInstances {
sections = append(sections, fmt.Sprintf("- **%s** (%s)", pbs.Name, pbs.Host))
}
} else {
sections = append(sections, "\n### Proxmox Backup Servers: NONE")
sections = append(sections, "No PBS connected - backup_status will show 'NEVER' for all guests")
}
// List connected agents with their capabilities
if len(connectedAgents) > 0 {
sections = append(sections, "\n### Connected Agents (Command Execution)")
sections = append(sections, "These hosts have the pulse-agent installed. You can run commands on them.")
for _, hostname := range connectedAgents {
// Check if this agent is a Docker host by looking at docker metadata
dockerMeta, _ := s.persistence.LoadDockerMetadata()
hasDocker := false
if dockerMeta != nil {
for id := range dockerMeta {
if strings.Contains(strings.ToLower(id), strings.ToLower(hostname)) {
hasDocker = true
break
}
}
}
capabilities := "shell commands, file reads"
if hasDocker {
capabilities = "shell commands, file reads, Docker (docker ps, docker exec, docker logs)"
}
sections = append(sections, fmt.Sprintf("- **%s**: %s", hostname, capabilities))
}
} else {
sections = append(sections, "\n### Connected Agents: NONE")
sections = append(sections, "No agents connected - cannot execute commands on any host")
}
// Add full guest inventory if state provider is available
s.mu.RLock()
stateProvider := s.stateProvider
s.mu.RUnlock()
if stateProvider != nil {
state := stateProvider.GetState()
// Group guests by node for better readability
guestsByNode := make(map[string][]string)
// Add containers
for _, ct := range state.Containers {
if ct.Template {
continue // Skip templates
}
ips := ""
if len(ct.IPAddresses) > 0 {
ips = " - " + strings.Join(ct.IPAddresses, ", ")
}
entry := fmt.Sprintf(" - **%s** (LXC %d)%s [%s]", ct.Name, ct.VMID, ips, ct.Status)
guestsByNode[ct.Node] = append(guestsByNode[ct.Node], entry)
}
// Add VMs
for _, vm := range state.VMs {
if vm.Template {
continue // Skip templates
}
ips := ""
if len(vm.IPAddresses) > 0 {
ips = " - " + strings.Join(vm.IPAddresses, ", ")
}
entry := fmt.Sprintf(" - **%s** (VM %d)%s [%s]", vm.Name, vm.VMID, ips, vm.Status)
guestsByNode[vm.Node] = append(guestsByNode[vm.Node], entry)
}
if len(guestsByNode) > 0 {
sections = append(sections, "\n### All Guests (VMs & Containers)")
sections = append(sections, "Complete list of guests Pulse knows about. Use VMID with pct/qm commands.")
for node, guests := range guestsByNode {
sections = append(sections, fmt.Sprintf("\n**Node %s:**", node))
for _, guest := range guests {
sections = append(sections, guest)
}
}
}
// Add standalone hosts (Linux/Windows servers with host agents)
if len(state.Hosts) > 0 {
sections = append(sections, "\n### Standalone Hosts")
sections = append(sections, "Linux/Windows servers monitored via Pulse host agent. Commands can be run directly on these.")
for _, host := range state.Hosts {
ips := ""
for _, iface := range host.NetworkInterfaces {
if len(iface.Addresses) > 0 {
ips = " - " + strings.Join(iface.Addresses, ", ")
break
}
}
osInfo := ""
if host.OSName != "" {
osInfo = fmt.Sprintf(" (%s)", host.OSName)
}
cpuMem := ""
if host.CPUCount > 0 {
cpuMem = fmt.Sprintf(", %d CPU, %.0f%% mem", host.CPUCount, host.Memory.Usage)
}
entry := fmt.Sprintf(" - **%s**%s%s%s [%s]", host.Hostname, osInfo, ips, cpuMem, host.Status)
sections = append(sections, entry)
}
}
// Add Docker hosts and their containers
if len(state.DockerHosts) > 0 {
sections = append(sections, "\n### Docker Hosts")
sections = append(sections, "Hosts running Docker/Podman. Use docker ps, docker exec, docker logs to manage containers.")
for _, dh := range state.DockerHosts {
if dh.Hidden || dh.PendingUninstall {
continue // Skip hidden or pending uninstall hosts
}
ips := ""
for _, iface := range dh.NetworkInterfaces {
if len(iface.Addresses) > 0 {
ips = " - " + strings.Join(iface.Addresses, ", ")
break
}
}
runtime := dh.Runtime
if runtime == "" {
runtime = "Docker"
}
containerCount := len(dh.Containers)
runningCount := 0
for _, c := range dh.Containers {
if c.State == "running" {
runningCount++
}
}
hostEntry := fmt.Sprintf("\n**%s** (%s, %d/%d containers running)%s [%s]",
dh.DisplayName, runtime, runningCount, containerCount, ips, dh.Status)
sections = append(sections, hostEntry)
// List containers on this host
for _, c := range dh.Containers {
// Build port info
portInfo := ""
if len(c.Ports) > 0 {
var ports []string
for _, p := range c.Ports {
if p.PublicPort > 0 {
ports = append(ports, fmt.Sprintf("%d->%d", p.PublicPort, p.PrivatePort))
}
}
if len(ports) > 0 {
portInfo = " ports:" + strings.Join(ports, ",")
}
}
// Truncate image name for brevity
image := c.Image
if idx := strings.LastIndex(image, "/"); idx > 0 {
image = image[idx+1:]
}
if len(image) > 30 {
image = image[:27] + "..."
}
healthInfo := ""
if c.Health != "" && c.Health != "none" {
healthInfo = fmt.Sprintf(" (%s)", c.Health)
}
entry := fmt.Sprintf(" - **%s** [%s]%s - %s%s", c.Name, c.State, healthInfo, image, portInfo)
sections = append(sections, entry)
}
}
}
}
if len(sections) == 0 {
return ""
}
result := "\n\n## Infrastructure Map\n" + strings.Join(sections, "\n")
// Limit context size to prevent overwhelming the AI (max ~50KB of infrastructure context)
const maxContextSize = 50000
if len(result) > maxContextSize {
log.Warn().
Int("original_size", len(result)).
Int("max_size", maxContextSize).
Msg("Infrastructure context truncated - too many resources")
result = result[:maxContextSize] + "\n\n[... Infrastructure context truncated due to size ...]"
}
log.Debug().Int("infrastructure_context_size", len(result)).Msg("Built infrastructure context")
return result
}
// buildUserAnnotationsContext gathers all user annotations from guests and docker containers
// These provide infrastructure context that the AI should know about for any query
func (s *Service) buildUserAnnotationsContext() string {