From 7b02e673657c4c75bebbbabf8d40b03c735df570 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 07:35:43 +0000 Subject: [PATCH] feat(06-lab-advisor-02): add InventoryContextBuilder with 60s cache - internal/advisor/context.go: BuildContext assembles compact NetBox summary (item count, category breakdown, recent 20 items) - Caches result under sync.Mutex for defaultTTL=60s - Stale cache returned on NetBox error rather than propagating failure - internal/ai/types.go: add Tier3 TierConfig field to AIConfig (required by AdvisorHandler for OpenRouter Claude Opus access) --- internal/advisor/context.go | 135 ++++++++++++++++++++++++++++++++++++ internal/ai/types.go | 1 + 2 files changed, 136 insertions(+) create mode 100644 internal/advisor/context.go diff --git a/internal/advisor/context.go b/internal/advisor/context.go new file mode 100644 index 0000000..a2e1182 --- /dev/null +++ b/internal/advisor/context.go @@ -0,0 +1,135 @@ +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 +} diff --git a/internal/ai/types.go b/internal/ai/types.go index a4ad41c..aa987e3 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -32,6 +32,7 @@ type TierConfig struct { type AIConfig struct { Tier1 TierConfig `json:"tier1" mapstructure:"tier1"` Tier2 TierConfig `json:"tier2" mapstructure:"tier2"` + Tier3 TierConfig `json:"tier3" mapstructure:"tier3"` ConfidenceThreshold float64 `json:"confidence_threshold" mapstructure:"confidence_threshold"` QuickAddEnabled bool `json:"quick_add_enabled" mapstructure:"quick_add_enabled"` QuickAddThreshold float64 `json:"quick_add_threshold" mapstructure:"quick_add_threshold"`