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)
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:35:43 +00:00
parent 8237077728
commit 7b02e67365
2 changed files with 136 additions and 0 deletions

135
internal/advisor/context.go Normal file
View file

@ -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
}

View file

@ -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"`