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 {
|
||||
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"`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue