package advisor import ( "context" "fmt" "strings" "sync" "time" "git.georgsen.dk/hwlab/internal/netbox" ) const ( defaultTTL = 60 * time.Second maxContextChars = 2000 maxRecentItems = 20 listDevicesLimit = 200 ) // InventoryContextBuilder assembles a compact NetBox inventory summary for use as // the system prompt context in advisor chat. Results are cached for ttl duration // (default 60s) to avoid hammering NetBox on every message. type InventoryContextBuilder struct { nb *netbox.Client mu sync.Mutex cached string cachedAt time.Time ttl time.Duration } // NewInventoryContextBuilder creates an InventoryContextBuilder with the default // 60-second cache TTL. func NewInventoryContextBuilder(nb *netbox.Client) *InventoryContextBuilder { return &InventoryContextBuilder{ nb: nb, ttl: defaultTTL, } } // BuildContext returns a compact text summary of the current NetBox inventory. // The result is cached for ttl to reduce NetBox API load. Thread-safe. // // Format: // // Inventory: N items total // Categories: category1 (N), category2 (N), ... // Recent items: // - HW-ID name (category) // - ... func (b *InventoryContextBuilder) BuildContext(ctx context.Context) (string, error) { b.mu.Lock() defer b.mu.Unlock() if b.cached != "" && time.Since(b.cachedAt) < b.ttl { return b.cached, nil } devices, err := b.nb.ListDevices(ctx, listDevicesLimit) if err != nil { // If the cache has a stale entry, return it rather than failing the request. if b.cached != "" { return b.cached, nil } return "", fmt.Errorf("advisor: build inventory context: %w", err) } summary := buildSummary(devices) b.cached = summary b.cachedAt = time.Now() return summary, nil } // buildSummary constructs the compact text summary from a slice of devices. // Extracted for testability. func buildSummary(devices []netbox.Device) string { if len(devices) == 0 { return "Inventory: 0 items total\n" } // Count by category using CustomFields.HWID as asset tag source. catCounts := make(map[string]int) for _, d := range devices { cat := categoryOf(d) catCounts[cat]++ } var sb strings.Builder fmt.Fprintf(&sb, "Inventory: %d items total\n", len(devices)) // Categories line sb.WriteString("Categories: ") first := true for cat, n := range catCounts { if !first { sb.WriteString(", ") } fmt.Fprintf(&sb, "%s (%d)", cat, n) first = false } sb.WriteString("\nRecent items:\n") limit := maxRecentItems if len(devices) < limit { limit = len(devices) } for _, d := range devices[:limit] { cat := categoryOf(d) assetTag := d.AssetTag if assetTag == "" { assetTag = d.CustomFields.HWID } line := fmt.Sprintf("- %s %s (%s)\n", assetTag, d.Name, cat) // Guard against exceeding maxContextChars. if sb.Len()+len(line) > maxContextChars { sb.WriteString("- ...(truncated)\n") break } sb.WriteString(line) } return sb.String() } // categoryOf returns the category label for a device, falling back to "unknown". func categoryOf(d netbox.Device) string { // CustomFields has no Category field; fall back to checking AINotes patterns // or just use "device" as default category. // The catalog_status field is the closest we have to category grouping. status := d.CustomFields.CatalogStatus if status == "" { return "device" } return status }