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:
parent
8237077728
commit
7b02e67365
2 changed files with 136 additions and 0 deletions
135
internal/advisor/context.go
Normal file
135
internal/advisor/context.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ type TierConfig struct {
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
Tier1 TierConfig `json:"tier1" mapstructure:"tier1"`
|
Tier1 TierConfig `json:"tier1" mapstructure:"tier1"`
|
||||||
Tier2 TierConfig `json:"tier2" mapstructure:"tier2"`
|
Tier2 TierConfig `json:"tier2" mapstructure:"tier2"`
|
||||||
|
Tier3 TierConfig `json:"tier3" mapstructure:"tier3"`
|
||||||
ConfidenceThreshold float64 `json:"confidence_threshold" mapstructure:"confidence_threshold"`
|
ConfidenceThreshold float64 `json:"confidence_threshold" mapstructure:"confidence_threshold"`
|
||||||
QuickAddEnabled bool `json:"quick_add_enabled" mapstructure:"quick_add_enabled"`
|
QuickAddEnabled bool `json:"quick_add_enabled" mapstructure:"quick_add_enabled"`
|
||||||
QuickAddThreshold float64 `json:"quick_add_threshold" mapstructure:"quick_add_threshold"`
|
QuickAddThreshold float64 `json:"quick_add_threshold" mapstructure:"quick_add_threshold"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue