- 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)
135 lines
3.3 KiB
Go
135 lines
3.3 KiB
Go
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
|
|
}
|