Wave 1: go-openai dep, CreateDevice gap, AIClient interface + mock + config Wave 2: three-tier orchestrator, WAQ real handler, SearXNG stub Wave 3: POST /api/intake handler, router wiring, quick add mode Wave 4: oMLX integration test + memory checkpoint Covers requirements: AI-01 through AI-09 (AI-04 stub only; full impl Phase 7) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
608 lines
24 KiB
Markdown
608 lines
24 KiB
Markdown
---
|
||
phase: 02-ai-pipeline
|
||
plan: "01"
|
||
type: execute
|
||
wave: 1
|
||
depends_on: []
|
||
files_modified:
|
||
- go.mod
|
||
- go.sum
|
||
- internal/netbox/client.go
|
||
- internal/netbox/client_test.go
|
||
- internal/ai/types.go
|
||
- internal/ai/client.go
|
||
- internal/ai/mock.go
|
||
- internal/ai/prompts/intake.go
|
||
- internal/config/config.go
|
||
- internal/config/config_test.go
|
||
- ai_config.json
|
||
autonomous: true
|
||
requirements: [AI-01, AI-08, AI-09]
|
||
|
||
must_haves:
|
||
truths:
|
||
- "go-openai v1.x is in go.mod and the binary compiles"
|
||
- "AIClient interface has a single AnalyzePhotos method with domain types"
|
||
- "MockAIClient implements AIClient, returns HighConfidenceResult and LowConfidenceResult fixtures"
|
||
- "TierClient wraps go-openai with BaseURL override — tier routing is config-driven"
|
||
- "ai_config.json template exists and config.Load() unmarshals AIConfig fields"
|
||
- "NetBox client has CreateDevice method returning (int64, error)"
|
||
artifacts:
|
||
- path: "internal/ai/types.go"
|
||
provides: "IntakeRequest, IntakeResult, TierConfig, AIConfig domain types"
|
||
exports: [IntakeRequest, IntakeResult, TierConfig, AIConfig]
|
||
- path: "internal/ai/client.go"
|
||
provides: "AIClient interface + TierClient production implementation"
|
||
exports: [AIClient, TierClient, NewTierClient]
|
||
- path: "internal/ai/mock.go"
|
||
provides: "MockAIClient test double with fixture constructors"
|
||
exports: [MockAIClient, HighConfidenceResult, LowConfidenceResult]
|
||
- path: "internal/ai/prompts/intake.go"
|
||
provides: "BuildIntakePrompt() returning the JSON-extraction prompt template"
|
||
exports: [BuildIntakePrompt]
|
||
- path: "internal/config/config.go"
|
||
provides: "Config struct extended with AI AIConfig field and viper bindings"
|
||
- path: "ai_config.json"
|
||
provides: "Template config file with tier1/tier2/threshold/quick_add settings"
|
||
- path: "internal/netbox/client.go"
|
||
provides: "CreateDevice method on *Client"
|
||
contains: "func.*Client.*CreateDevice"
|
||
key_links:
|
||
- from: "internal/config/config.go"
|
||
to: "internal/ai/types.go"
|
||
via: "AIConfig embeds TierConfig — config unmarshals into AIConfig struct"
|
||
pattern: "AIConfig"
|
||
- from: "internal/ai/client.go"
|
||
to: "github.com/sashabaranov/go-openai"
|
||
via: "TierClient wraps openai.Client; BaseURL set from TierConfig.BaseURL"
|
||
pattern: "openai.DefaultConfig"
|
||
---
|
||
|
||
<objective>
|
||
Lay the AI package foundation: install go-openai, define the AIClient interface and domain types, write MockAIClient for tests, add the intake prompt template, extend config for AI tiers, and add CreateDevice to the NetBox client (Phase 1 gap).
|
||
|
||
Purpose: Every downstream plan (orchestrator, intake handler) builds against these contracts. Nothing else can run without this.
|
||
Output: go.mod updated, internal/ai/ package with types/interface/mock/prompts, config extended, NetBox CreateDevice added.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/phases/02-ai-pipeline/02-CONTEXT.md
|
||
@.planning/phases/02-ai-pipeline/02-RESEARCH.md
|
||
|
||
<interfaces>
|
||
<!-- Phase 1 contracts the executor needs. -->
|
||
|
||
From internal/netbox/client.go:
|
||
```go
|
||
type Client struct { /* wraps *nb.APIClient */ }
|
||
func NewClient(url, token string) (*Client, error)
|
||
func (c *Client) Ping(ctx context.Context) error
|
||
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error)
|
||
func (c *Client) GetDevice(ctx context.Context, id int64) (*Device, error)
|
||
func (c *Client) PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error
|
||
```
|
||
|
||
From internal/netbox/types.go:
|
||
```go
|
||
type Device struct {
|
||
ID int64
|
||
Name string
|
||
AssetTag string
|
||
CustomFields CustomFields
|
||
Created time.Time
|
||
LastUpdated time.Time
|
||
}
|
||
type CustomFields struct {
|
||
HWID string
|
||
CatalogStatus string
|
||
ProductURL string
|
||
FirmwareVersion string
|
||
TestDate string
|
||
TestData string
|
||
AINotes string
|
||
PhotoURLs []string
|
||
}
|
||
```
|
||
|
||
From internal/inventory/quality_gate.go:
|
||
```go
|
||
type CatalogStatus string
|
||
const (
|
||
StatusDraft CatalogStatus = "draft"
|
||
StatusIndexed CatalogStatus = "indexed"
|
||
StatusNeedsResearch CatalogStatus = "needs_research"
|
||
StatusResearched CatalogStatus = "researched"
|
||
StatusComplete CatalogStatus = "complete"
|
||
)
|
||
```
|
||
|
||
From internal/config/config.go (current):
|
||
```go
|
||
type Config struct {
|
||
Host string
|
||
Port int
|
||
LogLevel string
|
||
NetBoxURL string
|
||
NetBoxToken string
|
||
NetBoxTimeoutSeconds int
|
||
DragonflyURL string
|
||
WAQRetryIntervalSeconds int
|
||
WAQMaxAttempts int
|
||
QualityGateConfidenceThreshold float64
|
||
}
|
||
func Load() (*Config, error)
|
||
```
|
||
|
||
go-netbox v4 CreateDevice pattern (from go-netbox generated client):
|
||
```go
|
||
// WritableDeviceWithConfigContextRequest is the request body for POST /dcim/devices/
|
||
// Key fields: Name (string), DeviceType (int32), Role (int32), Site (int32), AssetTag (NullableString)
|
||
// After creation, returned DeviceWithConfigContext has .GetId() int32
|
||
|
||
req := nb.NewWritableDeviceWithConfigContextRequest("device-name", roleID, siteID, deviceTypeID)
|
||
req.SetAssetTag(nb.NewNullableString(&assetTag))
|
||
result, resp, err := c.api.DcimAPI.DcimDevicesCreate(ctx).
|
||
WritableDeviceWithConfigContextRequest(*req).Execute()
|
||
// result.GetId() returns int32 — cast to int64
|
||
```
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 1: Install go-openai and add CreateDevice to NetBox client</name>
|
||
<files>go.mod, go.sum, internal/netbox/client.go, internal/netbox/client_test.go</files>
|
||
<read_first>
|
||
- internal/netbox/client.go (full — understand Client struct and existing methods)
|
||
- internal/netbox/client_test.go (full — understand test patterns to follow)
|
||
- internal/netbox/types.go (full — Device struct)
|
||
</read_first>
|
||
<behavior>
|
||
- Test: TestCreateDeviceValidation — calling CreateDevice with empty name returns error, no NetBox call made
|
||
- Test: TestCreateDeviceLive — skipped unless HWLAB_NETBOX_TOKEN is 40 chars AND HWLAB_TEST_SITE_ID is set; when conditions met: creates a device, asserts returned ID > 0, deletes the device (cleanup)
|
||
</behavior>
|
||
<action>
|
||
1. Run: `cd /home/mikkel/homelabby && go get github.com/sashabaranov/go-openai@latest`
|
||
|
||
2. Add CreateDevice to internal/netbox/client.go. Follow the existing method style exactly.
|
||
|
||
```go
|
||
// CreateDevice creates a new device in NetBox with the given name and asset tag.
|
||
// deviceTypeID, roleID, and siteID must be valid NetBox IDs (pre-existing objects).
|
||
// Returns the new device's NetBox ID or error.
|
||
func (c *Client) CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error) {
|
||
if name == "" {
|
||
return 0, fmt.Errorf("device name must not be empty")
|
||
}
|
||
req := nb.NewWritableDeviceWithConfigContextRequest(name, roleID, siteID, deviceTypeID)
|
||
if assetTag != "" {
|
||
req.SetAssetTag(*nb.NewNullableString(&assetTag))
|
||
}
|
||
result, _, err := c.api.DcimAPI.DcimDevicesCreate(ctx).
|
||
WritableDeviceWithConfigContextRequest(*req).Execute()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("CreateDevice %q: %w", name, err)
|
||
}
|
||
return int64(result.GetId()), nil
|
||
}
|
||
```
|
||
|
||
3. Add to client_test.go:
|
||
- TestCreateDeviceValidation: calls CreateDevice with empty name, asserts err != nil, no NetBox token needed
|
||
- TestCreateDeviceLive: skip guard `if len(token) != 40 || os.Getenv("HWLAB_TEST_SITE_ID") == "" { t.Skip(...) }`
|
||
|
||
4. Run `go build ./...` and `go test ./internal/netbox/... -run TestCreateDevice` — both must pass.
|
||
</action>
|
||
<verify>
|
||
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/netbox/... -run TestCreateDevice -v 2>&1 | tail -20</automated>
|
||
</verify>
|
||
<done>go.mod contains github.com/sashabaranov/go-openai; `go build ./...` passes; TestCreateDeviceValidation PASS; TestCreateDeviceLive SKIP (no live token).</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 2: AI package — types, interface, mock, prompts, config extension</name>
|
||
<files>
|
||
internal/ai/types.go,
|
||
internal/ai/client.go,
|
||
internal/ai/mock.go,
|
||
internal/ai/prompts/intake.go,
|
||
internal/config/config.go,
|
||
internal/config/config_test.go,
|
||
ai_config.json
|
||
</files>
|
||
<read_first>
|
||
- internal/config/config.go (full — extend this file)
|
||
- internal/config/config_test.go (full — extend tests)
|
||
- .planning/phases/02-ai-pipeline/02-RESEARCH.md lines 270-455 (type definitions, patterns)
|
||
</read_first>
|
||
<behavior>
|
||
- Test: TestAIConfig — Load() with an ai_config.json (written to temp dir or CWD before test) correctly unmarshals Tier1.BaseURL, Tier1.Model, ConfidenceThreshold, QuickAddEnabled
|
||
- Test: TestMockAIClient — MockAIClient.AnalyzePhotos returns HighConfidenceResult fixture; Calls slice has length 1 after one call; FixedError path returns nil result and the error
|
||
- Test: TestTierClientConstruction — NewTierClient(cfg) does not panic; a TierClient created with a bogus URL returns an error when AnalyzePhotos is called (HTTP connection refused, not a panic)
|
||
</behavior>
|
||
<action>
|
||
Create these files:
|
||
|
||
**internal/ai/types.go** — Domain types only, no go-openai import:
|
||
```go
|
||
package ai
|
||
|
||
// IntakeRequest carries 1-3 photos (base64-encoded data URLs) for AI analysis.
|
||
type IntakeRequest struct {
|
||
PhotosBase64 []string // "data:image/jpeg;base64,..."
|
||
JobID string // UUID for tracing
|
||
}
|
||
|
||
// IntakeResult is the structured output from any AI tier's photo analysis.
|
||
// The model is instructed to return this JSON shape verbatim.
|
||
type IntakeResult struct {
|
||
SerialNumber string `json:"serial_number"`
|
||
Model string `json:"model"`
|
||
Manufacturer string `json:"manufacturer"`
|
||
Category string `json:"category"` // compute | networking | storage | cable | peripheral | component | unknown
|
||
Specs map[string]string `json:"specs"`
|
||
SuggestedTags []string `json:"suggested_tags"`
|
||
AINotes string `json:"ai_notes"`
|
||
Confidence float64 `json:"confidence"` // 0.0–1.0 self-reported
|
||
ConfidenceNote string `json:"confidence_note"` // reason if < threshold
|
||
}
|
||
|
||
// TierConfig holds provider configuration for one AI tier.
|
||
type TierConfig struct {
|
||
BaseURL string `json:"base_url" mapstructure:"base_url"`
|
||
APIKey string `json:"api_key" mapstructure:"api_key"`
|
||
Model string `json:"model" mapstructure:"model"`
|
||
TimeoutSeconds int `json:"timeout_seconds" mapstructure:"timeout_seconds"`
|
||
}
|
||
|
||
// AIConfig holds all AI tier configurations and orchestration settings.
|
||
type AIConfig struct {
|
||
Tier1 TierConfig `json:"tier1" mapstructure:"tier1"`
|
||
Tier2 TierConfig `json:"tier2" mapstructure:"tier2"`
|
||
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"`
|
||
}
|
||
```
|
||
|
||
**internal/ai/client.go** — AIClient interface + TierClient:
|
||
```go
|
||
package ai
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"time"
|
||
|
||
openai "github.com/sashabaranov/go-openai"
|
||
)
|
||
|
||
// AIClient is the single abstraction over any OpenAI-compatible inference backend.
|
||
// Production: TierClient wrapping sashabaranov/go-openai.
|
||
// Tests: MockAIClient with canned responses.
|
||
type AIClient interface {
|
||
AnalyzePhotos(ctx context.Context, req IntakeRequest) (*IntakeResult, error)
|
||
}
|
||
|
||
// TierClient is the production AIClient backed by go-openai.
|
||
type TierClient struct {
|
||
client *openai.Client
|
||
model string
|
||
timeout time.Duration
|
||
}
|
||
|
||
// NewTierClient creates a TierClient from a TierConfig.
|
||
// BaseURL is set directly on the openai.ClientConfig — this is the tier-routing mechanism.
|
||
func NewTierClient(cfg TierConfig) *TierClient {
|
||
config := openai.DefaultConfig(cfg.APIKey)
|
||
config.BaseURL = cfg.BaseURL
|
||
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
|
||
if timeout == 0 {
|
||
timeout = 30 * time.Second
|
||
}
|
||
return &TierClient{
|
||
client: openai.NewClientWithConfig(config),
|
||
model: cfg.Model,
|
||
timeout: timeout,
|
||
}
|
||
}
|
||
|
||
// AnalyzePhotos sends 1-3 base64-encoded photos to the configured model and
|
||
// parses the structured JSON response into an IntakeResult.
|
||
// Falls back gracefully: if the model returns malformed JSON, returns a
|
||
// zero-confidence IntakeResult (not an error) so the orchestrator can escalate.
|
||
func (c *TierClient) AnalyzePhotos(ctx context.Context, req IntakeRequest) (*IntakeResult, error) {
|
||
// Build vision message parts: text prompt first, then image URLs
|
||
parts := []openai.ChatMessagePart{
|
||
{
|
||
Type: openai.ChatMessagePartTypeText,
|
||
Text: buildIntakePromptWithCount(len(req.PhotosBase64)),
|
||
},
|
||
}
|
||
for _, b64 := range req.PhotosBase64 {
|
||
parts = append(parts, openai.ChatMessagePart{
|
||
Type: openai.ChatMessagePartTypeImageURL,
|
||
ImageURL: &openai.ChatMessageImageURL{
|
||
URL: b64,
|
||
Detail: openai.ImageURLDetailAuto,
|
||
},
|
||
})
|
||
}
|
||
|
||
tctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||
defer cancel()
|
||
|
||
resp, err := c.client.CreateChatCompletion(tctx, openai.ChatCompletionRequest{
|
||
Model: c.model,
|
||
Messages: []openai.ChatCompletionMessage{
|
||
{Role: openai.ChatMessageRoleUser, MultiContent: parts},
|
||
},
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chat completion: %w", err)
|
||
}
|
||
if len(resp.Choices) == 0 {
|
||
return nil, fmt.Errorf("no choices in response")
|
||
}
|
||
|
||
content := resp.Choices[0].Message.Content
|
||
var result IntakeResult
|
||
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||
// JSON parse failure — return zero-confidence result so orchestrator escalates
|
||
return &IntakeResult{
|
||
AINotes: fmt.Sprintf("JSON parse failed: %v | raw: %.200s", err, content),
|
||
Confidence: 0.0,
|
||
ConfidenceNote: "model returned non-JSON response",
|
||
}, nil
|
||
}
|
||
return &result, nil
|
||
}
|
||
```
|
||
|
||
**internal/ai/mock.go** — deterministic test double:
|
||
```go
|
||
package ai
|
||
|
||
import "context"
|
||
|
||
// MockAIClient is a test double for AIClient.
|
||
// Set FixedResult and/or FixedError before use.
|
||
type MockAIClient struct {
|
||
FixedResult *IntakeResult
|
||
FixedError error
|
||
Calls []IntakeRequest
|
||
}
|
||
|
||
func (m *MockAIClient) AnalyzePhotos(_ context.Context, req IntakeRequest) (*IntakeResult, error) {
|
||
m.Calls = append(m.Calls, req)
|
||
return m.FixedResult, m.FixedError
|
||
}
|
||
|
||
// HighConfidenceResult returns a fixture IntakeResult with confidence 0.95.
|
||
func HighConfidenceResult() *IntakeResult {
|
||
return &IntakeResult{
|
||
Model: "Raspberry Pi 4 Model B",
|
||
Manufacturer: "Raspberry Pi Foundation",
|
||
Category: "compute",
|
||
Specs: map[string]string{"ram": "4GB", "cpu": "BCM2711"},
|
||
SuggestedTags: []string{"raspberry-pi", "compute", "arm"},
|
||
Confidence: 0.95,
|
||
}
|
||
}
|
||
|
||
// LowConfidenceResult returns a fixture with confidence 0.40 (below threshold).
|
||
func LowConfidenceResult() *IntakeResult {
|
||
return &IntakeResult{
|
||
Model: "Unknown Device",
|
||
Category: "unknown",
|
||
Confidence: 0.40,
|
||
ConfidenceNote: "Cannot identify markings clearly",
|
||
}
|
||
}
|
||
```
|
||
|
||
**internal/ai/prompts/intake.go** — prompt template:
|
||
```go
|
||
package prompts
|
||
|
||
import "fmt"
|
||
|
||
// BuildIntakePrompt returns the vision prompt instructing the model to return
|
||
// structured JSON for hardware analysis. photoCount is 1-3.
|
||
func BuildIntakePrompt(photoCount int) string {
|
||
return fmt.Sprintf(`You are a hardware inventory assistant. Analyze the %d hardware photo(s) provided and return ONLY valid JSON matching this exact schema. Do not include markdown, code fences, or explanations — return only the raw JSON object.
|
||
|
||
{
|
||
"serial_number": "<string — exact serial number visible on label, or empty string if not visible>",
|
||
"model": "<string — product model name>",
|
||
"manufacturer": "<string — manufacturer/brand name>",
|
||
"category": "<one of: compute, networking, storage, cable, peripheral, component, unknown>",
|
||
"specs": {"<spec_key>": "<spec_value>"},
|
||
"suggested_tags": ["<tag1>", "<tag2>"],
|
||
"ai_notes": "<free-form observations about condition, notable features, or ambiguities>",
|
||
"confidence": <float between 0.0 and 1.0 — your confidence in the identification>,
|
||
"confidence_note": "<reason why confidence is below 0.75, or empty string if confidence >= 0.75>"
|
||
}`, photoCount)
|
||
}
|
||
```
|
||
|
||
Note: internal/ai/client.go imports internal/ai/prompts — add a helper shim in client.go:
|
||
```go
|
||
// buildIntakePromptWithCount is a package-internal shim to the prompts package.
|
||
func buildIntakePromptWithCount(n int) string {
|
||
return prompts.BuildIntakePrompt(n)
|
||
}
|
||
```
|
||
Add import `"git.georgsen.dk/hwlab/internal/ai/prompts"` in client.go.
|
||
|
||
**internal/config/config.go** — extend Config struct with AIConfig:
|
||
|
||
Add to the Config struct:
|
||
```go
|
||
AI AIConfig `mapstructure:"ai"`
|
||
```
|
||
|
||
Add AIConfig import alias and type reference. Since AIConfig is in internal/ai, import that package. Add defaults in Load():
|
||
```go
|
||
v.SetDefault("ai.tier1.base_url", "http://localhost:8000/v1")
|
||
v.SetDefault("ai.tier1.api_key", "local")
|
||
v.SetDefault("ai.tier1.model", "gemma-4-e4b")
|
||
v.SetDefault("ai.tier1.timeout_seconds", 30)
|
||
v.SetDefault("ai.tier2.base_url", "https://openrouter.ai/api/v1")
|
||
v.SetDefault("ai.tier2.api_key", "")
|
||
v.SetDefault("ai.tier2.model", "google/gemma-3-27b-it")
|
||
v.SetDefault("ai.tier2.timeout_seconds", 60)
|
||
v.SetDefault("ai.confidence_threshold", 0.75)
|
||
v.SetDefault("ai.quick_add_enabled", false)
|
||
v.SetDefault("ai.quick_add_threshold", 0.90)
|
||
```
|
||
|
||
Add viper bindings:
|
||
```go
|
||
_ = v.BindEnv("ai.tier1.base_url", "HWLAB_AI_TIER1_BASE_URL")
|
||
_ = v.BindEnv("ai.tier1.api_key", "HWLAB_AI_TIER1_API_KEY")
|
||
_ = v.BindEnv("ai.tier1.model", "HWLAB_AI_TIER1_MODEL")
|
||
_ = v.BindEnv("ai.tier2.base_url", "HWLAB_AI_TIER2_BASE_URL")
|
||
_ = v.BindEnv("ai.tier2.api_key", "HWLAB_AI_TIER2_API_KEY")
|
||
_ = v.BindEnv("ai.tier2.model", "HWLAB_AI_TIER2_MODEL")
|
||
_ = v.BindEnv("ai.confidence_threshold", "HWLAB_AI_CONFIDENCE_THRESHOLD")
|
||
_ = v.BindEnv("ai.quick_add_enabled", "HWLAB_AI_QUICK_ADD_ENABLED")
|
||
```
|
||
|
||
Also configure viper to optionally load ai_config.json as a merge override:
|
||
```go
|
||
// Optionally merge ai_config.json if present — overrides defaults
|
||
v.SetConfigName("ai_config")
|
||
v.SetConfigType("json")
|
||
if err := v.MergeInConfig(); err != nil {
|
||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||
return nil, fmt.Errorf("ai_config file: %w", err)
|
||
}
|
||
}
|
||
// Restore config name for the primary config file
|
||
v.SetConfigName("config")
|
||
_ = v.ReadInConfig()
|
||
```
|
||
|
||
Actually, the safest pattern for viper MergeInConfig with two config files is:
|
||
1. Create a second viper instance OR
|
||
2. Use AddConfigPath multiple times with different names — not directly supported
|
||
|
||
Simplest correct approach: read main config.json first (existing), then merge ai_config.json:
|
||
|
||
```go
|
||
// In Load(), after v.ReadInConfig() for config.json:
|
||
// Try to merge ai_config.json as an override
|
||
v2 := viper.New()
|
||
v2.SetConfigName("ai_config")
|
||
v2.SetConfigType("json")
|
||
v2.AddConfigPath(".")
|
||
v2.AddConfigPath("/etc/hwlab")
|
||
if err := v2.ReadInConfig(); err == nil {
|
||
// ai_config.json exists — merge AI section into main viper
|
||
if err := v.MergeConfigMap(v2.AllSettings()); err != nil {
|
||
return nil, fmt.Errorf("merge ai_config: %w", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
**ai_config.json** — template file committed to repo (safe defaults, no real API keys):
|
||
```json
|
||
{
|
||
"ai": {
|
||
"tier1": {
|
||
"base_url": "http://localhost:8000/v1",
|
||
"api_key": "local",
|
||
"model": "gemma-4-e4b",
|
||
"timeout_seconds": 30
|
||
},
|
||
"tier2": {
|
||
"base_url": "https://openrouter.ai/api/v1",
|
||
"api_key": "REPLACE_WITH_OPENROUTER_KEY",
|
||
"model": "google/gemma-3-27b-it",
|
||
"timeout_seconds": 60
|
||
},
|
||
"confidence_threshold": 0.75,
|
||
"quick_add_enabled": false,
|
||
"quick_add_threshold": 0.90
|
||
}
|
||
}
|
||
```
|
||
|
||
**Tests to add** (in internal/config/config_test.go and new internal/ai/client_test.go):
|
||
|
||
config_test.go — TestAIConfigDefaults: call Load() with no config file present (use t.TempDir() and os.Chdir); assert cfg.AI.Tier1.BaseURL == "http://localhost:8000/v1", cfg.AI.ConfidenceThreshold == 0.75.
|
||
|
||
internal/ai/client_test.go (new file):
|
||
- TestMockAIClient: create MockAIClient with HighConfidenceResult(); call AnalyzePhotos; assert result.Confidence == 0.95 and len(mock.Calls) == 1
|
||
- TestMockAIClientError: set FixedError = errors.New("timeout"); assert returned error is non-nil
|
||
- TestTierClientConstruction: NewTierClient(TierConfig{BaseURL: "http://localhost:9999/v1", APIKey: "x", Model: "m", TimeoutSeconds: 1}) — assert client is not nil; AnalyzePhotos returns non-nil error (connection refused)
|
||
</action>
|
||
<verify>
|
||
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/ai/... ./internal/config/... -v 2>&1 | tail -30</automated>
|
||
</verify>
|
||
<done>
|
||
- `go build ./...` passes
|
||
- TestMockAIClient PASS, TestMockAIClientError PASS
|
||
- TestTierClientConstruction PASS (connection refused error returned, not panic)
|
||
- TestAIConfigDefaults PASS (defaults unmarshalled correctly)
|
||
- internal/ai/ package has types.go, client.go, mock.go, prompts/intake.go
|
||
- ai_config.json exists in project root
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
## Trust Boundaries
|
||
|
||
| Boundary | Description |
|
||
|----------|-------------|
|
||
| config→TierClient | API keys from ai_config.json reach go-openai client; must not be logged |
|
||
| TierClient→oMLX | HTTP to localhost:8000 — trusted network, but response is untrusted JSON from model |
|
||
| TierClient→OpenRouter | HTTPS to openrouter.ai — TLS protects key in transit; response is untrusted |
|
||
|
||
## STRIDE Threat Register
|
||
|
||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||
|-----------|----------|-----------|-------------|-----------------|
|
||
| T-02-01 | Information Disclosure | ai_config.json | mitigate | Add ai_config.json to .gitignore (contains OPENROUTER_KEY); commit only template with placeholder value |
|
||
| T-02-02 | Tampering | TierClient JSON parse | mitigate | json.Unmarshal into typed struct — unrecognized keys ignored; numeric fields clamped by orchestrator in Plan 02 |
|
||
| T-02-03 | Denial of Service | TierClient timeout | mitigate | context.WithTimeout(ctx, c.timeout) wraps every CreateChatCompletion call — oMLX hangs cannot block handler indefinitely |
|
||
| T-02-04 | Information Disclosure | API key logging | accept | No log.Printf of TierConfig.APIKey anywhere; dev/test uses "local" key (not secret) |
|
||
</threat_model>
|
||
|
||
<verification>
|
||
After plan completion:
|
||
1. `go build ./...` — zero errors
|
||
2. `go test ./internal/ai/... -v` — all unit tests pass
|
||
3. `go test ./internal/config/... -v` — including new AI config defaults test
|
||
4. `go test ./internal/netbox/... -run TestCreateDevice -v` — validation test passes, live test skips
|
||
5. `grep "sashabaranov/go-openai" go.mod` — confirms dependency present
|
||
6. `ls internal/ai/` — shows types.go, client.go, mock.go, client_test.go
|
||
7. `ls internal/ai/prompts/` — shows intake.go
|
||
8. `ls ai_config.json` — file exists
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- go-openai is in go.mod and the module compiles cleanly
|
||
- AIClient interface defined in internal/ai/client.go with AnalyzePhotos(ctx, IntakeRequest) signature
|
||
- MockAIClient implements AIClient and records calls
|
||
- TierClient wraps go-openai with BaseURL override
|
||
- Config.AI.Tier1.BaseURL defaults to "http://localhost:8000/v1" when no config file present
|
||
- NetBox client has CreateDevice method that returns (int64, error)
|
||
- All new tests pass; go build clean
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/02-ai-pipeline/02-01-SUMMARY.md`
|
||
</output>
|