docs(02): create phase 2 AI pipeline plans (4 plans, 4 waves)

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>
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:40:22 +00:00
parent 6460b27bfc
commit 7bebe2ed93
5 changed files with 1841 additions and 3 deletions

View file

@ -51,8 +51,13 @@ Plans:
3. Items with AI confidence below threshold are automatically set to needs_research; high-confidence items advance to indexed
4. Quick add mode skips review for high-confidence items and creates the NetBox record in one step
5. Any AI tier (local oMLX, OpenRouter) can be swapped by changing a config JSON value with no code changes
**Plans**: TBD
**UI hint**: no
**Plans**: 4 plans
Plans:
- [ ] 02-01-PLAN.md — go-openai dep, CreateDevice on NetBox client, AIClient interface, MockAIClient, TierClient, ai_config.json
- [ ] 02-02-PLAN.md — Three-tier orchestrator, WAQ real NetBox op handler, SearXNG ResearchClient stub
- [ ] 02-03-PLAN.md — POST /api/intake handler, router wiring, quick add mode, main.go real WAQ handler
- [ ] 02-04-PLAN.md — oMLX integration test, memory measurement checkpoint
### Phase 3: Dashboard & Intake UI
**Goal**: Users can browse their full inventory, run intake for new items, and view item detail — all through the React SPA served by the Go binary
@ -124,7 +129,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Foundation | 5/5 | Complete | 2026-04-10 |
| 2. AI Pipeline | 0/TBD | Not started | - |
| 2. AI Pipeline | 0/4 | Not started | - |
| 3. Dashboard & Intake UI | 0/TBD | Not started | - |
| 4. USB Manager & Label Printing | 0/TBD | Not started | - |
| 5. Cable Test Integration | 0/TBD | Not started | - |

View file

@ -0,0 +1,608 @@
---
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.01.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>

View file

@ -0,0 +1,418 @@
---
phase: 02-ai-pipeline
plan: "02"
type: execute
wave: 2
depends_on: [02-01]
files_modified:
- internal/ai/orchestrator.go
- internal/ai/orchestrator_test.go
- internal/ai/research.go
- internal/queue/handler.go
- internal/queue/handler_test.go
autonomous: true
requirements: [AI-04, AI-05, AI-06]
must_haves:
truths:
- "Orchestrator calls tier1; if confidence < threshold it escalates to tier2"
- "Orchestrator maps confidence to CatalogStatus: high -> indexed, low -> needs_research"
- "Both tier1 and tier2 use the same AIClient interface — swap by config"
- "WAQ handler processes netbox.create_device and netbox.patch_custom_fields ops and retries on failure"
- "ResearchClient interface exists with NoOpResearchClient stub (AI-04 deferred impl)"
artifacts:
- path: "internal/ai/orchestrator.go"
provides: "Orchestrator with Analyze(ctx, IntakeRequest) → (*IntakeResult, CatalogStatus, error)"
exports: [Orchestrator, NewOrchestrator]
- path: "internal/ai/orchestrator_test.go"
provides: "Unit tests covering tier1-only, tier1-low-confidence-escalates, tier2-fallback"
- path: "internal/ai/research.go"
provides: "ResearchClient interface + NoOpResearchClient stub"
exports: [ResearchClient, NoOpResearchClient]
- path: "internal/queue/handler.go"
provides: "NetBoxOpHandler that processes create_device and patch_custom_fields WAQ ops"
exports: [NewNetBoxOpHandler, NetBoxOpHandler]
- path: "internal/queue/handler_test.go"
provides: "Tests for handler routing, JSON decode, and error propagation"
key_links:
- from: "internal/ai/orchestrator.go"
to: "internal/inventory/quality_gate.go"
via: "Orchestrator.Analyze returns inventory.CatalogStatus — StatusIndexed or StatusNeedsResearch"
pattern: "inventory\\.CatalogStatus"
- from: "internal/queue/handler.go"
to: "internal/netbox/client.go"
via: "NetBoxOpHandler calls client.CreateDevice or client.PatchCustomFields based on op.Type"
pattern: "netbox\\.create_device|netbox\\.patch_custom_fields"
---
<objective>
Build the three-tier orchestrator with confidence-based tier escalation and CatalogStatus mapping, the WAQ real handler replacing NoOpHandler, and the SearXNG ResearchClient stub.
Purpose: The orchestrator is the AI decision engine. The WAQ handler closes the Phase 1 gap where NoOpHandler silently dropped all queued NetBox ops. The research stub satisfies AI-04 interface without Phase 7 scope creep.
Output: internal/ai/orchestrator.go, internal/ai/research.go, internal/queue/handler.go — all tested.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/02-ai-pipeline/02-CONTEXT.md
@.planning/phases/02-ai-pipeline/02-RESEARCH.md
@.planning/phases/02-ai-pipeline/02-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 output — contracts executor needs -->
From internal/ai/client.go:
```go
type AIClient interface {
AnalyzePhotos(ctx context.Context, req IntakeRequest) (*IntakeResult, error)
}
type MockAIClient struct {
FixedResult *IntakeResult
FixedError error
Calls []IntakeRequest
}
func HighConfidenceResult() *IntakeResult // confidence: 0.95
func LowConfidenceResult() *IntakeResult // confidence: 0.40
```
From internal/ai/types.go:
```go
type IntakeRequest struct { PhotosBase64 []string; JobID string }
type IntakeResult struct {
SerialNumber, Model, Manufacturer, Category string
Specs map[string]string; SuggestedTags []string
AINotes string; Confidence float64; ConfidenceNote string
}
type AIConfig struct {
Tier1, Tier2 TierConfig
ConfidenceThreshold float64
QuickAddEnabled bool
QuickAddThreshold float64
}
```
From internal/inventory/quality_gate.go:
```go
type CatalogStatus string
const StatusIndexed CatalogStatus = "indexed"
const StatusNeedsResearch CatalogStatus = "needs_research"
```
From internal/queue/waq.go:
```go
type PendingOp struct {
ID string
Type string // op type string e.g. "netbox.create_device"
Payload json.RawMessage
CreatedAt time.Time
Attempts int
}
type OpHandler func(ctx context.Context, op PendingOp) error
```
From internal/netbox/client.go:
```go
func (c *Client) CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error)
func (c *Client) PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Three-tier orchestrator with confidence routing</name>
<files>internal/ai/orchestrator.go, internal/ai/orchestrator_test.go, internal/ai/research.go</files>
<read_first>
- internal/ai/types.go (full)
- internal/ai/client.go (full)
- internal/ai/mock.go (full)
- internal/inventory/quality_gate.go (full — CatalogStatus constants)
- .planning/phases/02-ai-pipeline/02-RESEARCH.md lines 324-410 (orchestrator pattern)
</read_first>
<behavior>
- Test: TestOrchestratorHighConfidence — tier1 returns confidence 0.95 (above 0.75 threshold); tier2 never called; result status == StatusIndexed
- Test: TestOrchestratorLowConfidenceEscalates — tier1 returns confidence 0.40; tier2 called once; tier2 returns confidence 0.85; final status == StatusIndexed
- Test: TestOrchestratorBothTiersFail — tier1 returns error; tier2 returns error; result is non-nil (zero IntakeResult), status == StatusNeedsResearch, err == nil (orchestrator does not propagate tier errors; it degrades gracefully)
- Test: TestOrchestratorTier1NilResult — tier1 returns nil result with nil error; orchestrator escalates to tier2
- Test: TestOrchestratorNeedsResearch — tier1 returns confidence 0.40; tier2 also returns confidence 0.40; final status == StatusNeedsResearch
</behavior>
<action>
**internal/ai/orchestrator.go:**
```go
package ai
import (
"context"
"log"
"git.georgsen.dk/hwlab/internal/inventory"
)
// Orchestrator manages the three-tier AI pipeline.
// Tier1 is local oMLX (fast, low cost). Tier2 is OpenRouter (slower, better).
// Tier3 (Lab Advisor) is out of scope for Phase 2.
type Orchestrator struct {
tier1 AIClient
tier2 AIClient
threshold float64 // confidence threshold for escalation; default 0.75
}
// NewOrchestrator creates an Orchestrator. Both tier1 and tier2 must be non-nil.
func NewOrchestrator(tier1, tier2 AIClient, threshold float64) *Orchestrator {
if threshold <= 0 {
threshold = 0.75
}
return &Orchestrator{tier1: tier1, tier2: tier2, threshold: threshold}
}
// Analyze runs tier1 and escalates to tier2 if confidence is below threshold.
// Never returns an error from individual tier failures — tier errors cause escalation.
// Returns a non-nil IntakeResult in all cases (may be zero-value on total failure).
// The returned CatalogStatus is either StatusIndexed or StatusNeedsResearch.
func (o *Orchestrator) Analyze(ctx context.Context, req IntakeRequest) (*IntakeResult, inventory.CatalogStatus, error) {
result, err := o.tier1.AnalyzePhotos(ctx, req)
if err != nil {
log.Printf("orchestrator: tier1 error (escalating to tier2): %v", err)
result = nil
}
// Escalate if tier1 result is missing, nil, or low confidence
if result == nil || result.Confidence < o.threshold {
log.Printf("orchestrator: tier1 confidence=%.2f < threshold=%.2f escalating to tier2",
confidenceOf(result), o.threshold)
result2, err2 := o.tier2.AnalyzePhotos(ctx, req)
if err2 != nil {
log.Printf("orchestrator: tier2 error: %v", err2)
} else if result2 != nil {
result = result2
}
}
// Map confidence to CatalogStatus
if result == nil {
return &IntakeResult{
AINotes: "all AI tiers failed",
Confidence: 0.0,
ConfidenceNote: "no result from any tier",
}, inventory.StatusNeedsResearch, nil
}
status := inventory.StatusIndexed
if result.Confidence < o.threshold {
status = inventory.StatusNeedsResearch
}
return result, status, nil
}
// confidenceOf returns 0.0 for nil results, otherwise result.Confidence.
func confidenceOf(r *IntakeResult) float64 {
if r == nil {
return 0.0
}
return r.Confidence
}
```
**internal/ai/research.go** — SearXNG stub (AI-04 Phase 7 interface):
```go
package ai
import "context"
// SearchResult is a single result from a SearXNG research query.
type SearchResult struct {
Title string
URL string
Snippet string
}
// ResearchClient abstracts the SearXNG search backend.
// Phase 7 will provide a real implementation.
type ResearchClient interface {
Search(ctx context.Context, query string) ([]SearchResult, error)
}
// NoOpResearchClient is a Phase 2 stub that returns empty results.
// Replace with SearXNG HTTP client in Phase 7.
type NoOpResearchClient struct{}
func (n *NoOpResearchClient) Search(_ context.Context, _ string) ([]SearchResult, error) {
return nil, nil
}
```
**internal/ai/orchestrator_test.go** — five tests using MockAIClient:
Write all five tests from the behavior block above. Use table-driven style where practical. Key patterns:
- Create `MockAIClient` with `FixedResult` / `FixedError` for tier1 and tier2
- Call `NewOrchestrator(tier1, tier2, 0.75).Analyze(context.Background(), IntakeRequest{PhotosBase64: []string{"data:image/jpeg;base64,/9j/"}})`
- Assert returned status and confirm tier2 Calls length via mock.Calls
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/ai/... -run TestOrchestrator -v 2>&1</automated>
</verify>
<done>
All 5 TestOrchestrator* tests pass.
`go build ./...` clean.
internal/ai/research.go exists with ResearchClient interface and NoOpResearchClient.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: WAQ real NetBox op handler (replaces NoOpHandler)</name>
<files>internal/queue/handler.go, internal/queue/handler_test.go</files>
<read_first>
- internal/queue/waq.go (full — PendingOp struct, OpHandler type)
- internal/queue/worker.go (full — NoOpHandler is what we're replacing; understand RunWorker signature)
- internal/netbox/client.go (full — CreateDevice and PatchCustomFields signatures)
- internal/netbox/types.go (full — understand what types are available)
- .planning/phases/01-foundation/01-05-SUMMARY.md lines 45-70 (WAQ op structure)
</read_first>
<behavior>
- Test: TestNetBoxOpHandlerRouting — handler receives op with Type="netbox.create_device", asserts CreateDevice is called (use a mock netbox client interface)
- Test: TestNetBoxOpHandlerPatchCustomFields — handler receives op with Type="netbox.patch_custom_fields", asserts PatchCustomFields called
- Test: TestNetBoxOpHandlerUnknownType — handler receives op with Type="unknown.op", returns a non-nil error (unknown ops are re-queued, not silently dropped)
- Test: TestNetBoxOpHandlerBadJSON — handler receives op with malformed payload JSON, returns non-nil error
- Test: TestCreateDevicePayloadDecode — a JSON payload `{"name":"test","asset_tag":"HW-00001","device_type_id":1,"role_id":2,"site_id":3}` decodes correctly
</behavior>
<action>
Define two payload types and the handler in internal/queue/handler.go.
Op type string constants:
```go
const (
OpNetBoxCreateDevice = "netbox.create_device"
OpNetBoxPatchCustomFields = "netbox.patch_custom_fields"
)
```
Payload types:
```go
// CreateDevicePayload is the JSON payload for OpNetBoxCreateDevice ops.
type CreateDevicePayload struct {
Name string `json:"name"`
AssetTag string `json:"asset_tag"`
DeviceTypeID int32 `json:"device_type_id"`
RoleID int32 `json:"role_id"`
SiteID int32 `json:"site_id"`
}
// PatchCustomFieldsPayload is the JSON payload for OpNetBoxPatchCustomFields ops.
type PatchCustomFieldsPayload struct {
DeviceID int64 `json:"device_id"`
Patch map[string]interface{} `json:"patch"`
}
```
Handler interface for testability (so tests don't need a real NetBox client):
```go
// NetBoxOpsClient is the subset of netbox.Client that the WAQ handler needs.
type NetBoxOpsClient interface {
CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error)
PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error
}
// NewNetBoxOpHandler returns an OpHandler that processes netbox WAQ operations.
// Pass a *netbox.Client as the client argument.
func NewNetBoxOpHandler(client NetBoxOpsClient) OpHandler {
return func(ctx context.Context, op PendingOp) error {
switch op.Type {
case OpNetBoxCreateDevice:
var p CreateDevicePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return fmt.Errorf("decode create_device payload: %w", err)
}
_, err := client.CreateDevice(ctx, p.Name, p.AssetTag, p.DeviceTypeID, p.RoleID, p.SiteID)
return err
case OpNetBoxPatchCustomFields:
var p PatchCustomFieldsPayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return fmt.Errorf("decode patch_custom_fields payload: %w", err)
}
return client.PatchCustomFields(ctx, p.DeviceID, p.Patch)
default:
return fmt.Errorf("unknown op type: %q", op.Type)
}
}
}
```
For tests, define a MockNetBoxOpsClient in handler_test.go (not exported):
```go
type mockNetBoxOpsClient struct {
createCalls []CreateDevicePayload
patchCalls []PatchCustomFieldsPayload
createErr error
patchErr error
}
func (m *mockNetBoxOpsClient) CreateDevice(ctx context.Context, name, assetTag string, dtID, roleID, siteID int32) (int64, error) {
m.createCalls = append(m.createCalls, CreateDevicePayload{Name: name, AssetTag: assetTag, DeviceTypeID: dtID, RoleID: roleID, SiteID: siteID})
return 42, m.createErr
}
func (m *mockNetBoxOpsClient) PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error {
m.patchCalls = append(m.patchCalls, PatchCustomFieldsPayload{DeviceID: deviceID, Patch: patch})
return m.patchErr
}
```
NOTE: Do NOT remove NoOpHandler from worker.go — it is used in main.go. The intake handler (Plan 03) will switch main.go to use NewNetBoxOpHandler. For now, NoOpHandler stays; the new handler lives alongside it in handler.go.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/queue/... -v 2>&1</automated>
</verify>
<done>
All 5 TestNetBoxOpHandler* tests pass.
`go build ./...` clean.
internal/queue/handler.go exports NewNetBoxOpHandler, OpNetBoxCreateDevice, OpNetBoxPatchCustomFields constants.
NoOpHandler remains untouched in worker.go.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| AI response → IntakeResult | JSON from model is untrusted; parsed into typed struct |
| WAQ payload → NetBox call | Queued JSON payload decoded before NetBox API call |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-05 | Tampering | Orchestrator result | mitigate | IntakeResult.Confidence clamped to 0.0 by json.Unmarshal zero-value on invalid JSON; orchestrator treats nil/zero result as needs_research |
| T-02-06 | Denial of Service | Orchestrator both-tiers-timeout | mitigate | Each TierClient.AnalyzePhotos wraps call in context.WithTimeout — maximum total wait is tier1.timeout + tier2.timeout |
| T-02-07 | Tampering | WAQ payload injection | mitigate | PendingOp.Payload decoded via json.Unmarshal into typed structs (CreateDevicePayload / PatchCustomFieldsPayload) — arbitrary fields ignored; unknown op types return error and are re-queued, not executed |
| T-02-08 | Elevation of Privilege | WAQ unknown op type | mitigate | NewNetBoxOpHandler returns error on unknown Type — op re-queued up to maxAttempts then dropped; no code execution from op type |
</threat_model>
<verification>
After plan completion:
1. `go build ./...` — zero errors
2. `go test ./internal/ai/... -v` — all orchestrator tests pass
3. `go test ./internal/queue/... -v` — all handler tests pass (including pre-existing WAQ tests)
4. `grep -r "NoOpHandler" internal/` — still present in worker.go; main.go still compiles
5. `grep "ResearchClient" internal/ai/research.go` — interface present
</verification>
<success_criteria>
- Orchestrator escalates from tier1 to tier2 when confidence < threshold
- All tier1/tier2 failure combinations handled gracefully (no panic, no error propagation)
- CatalogStatus returned from Analyze is always StatusIndexed or StatusNeedsResearch
- WAQ handler routes create_device and patch_custom_fields ops to correct NetBox methods
- Unknown WAQ op types return error (re-queued, not silently dropped)
- ResearchClient interface stub present for Phase 7
</success_criteria>
<output>
After completion, create `.planning/phases/02-ai-pipeline/02-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,504 @@
---
phase: 02-ai-pipeline
plan: "03"
type: execute
wave: 3
depends_on: [02-01, 02-02]
files_modified:
- internal/api/handlers/intake.go
- internal/api/handlers/intake_test.go
- internal/api/router.go
- cmd/hwlab/main.go
autonomous: true
requirements: [AI-02, AI-03, AI-07]
must_haves:
truths:
- "POST /api/intake with 1-3 JPEG/PNG files returns 200 with serial, model, manufacturer, specs, category, tags, hw_id, catalog_status"
- "POST /api/intake with 0 or 4+ files returns 400"
- "Quick add mode (confidence >= quick_add_threshold AND quick_add_enabled=true) creates NetBox record in one step; returns hw_id in response"
- "When NetBox is unreachable, intake enqueues netbox.create_device op to WAQ and returns 202"
- "WAQ real handler (NewNetBoxOpHandler) replaces NoOpHandler in main.go"
artifacts:
- path: "internal/api/handlers/intake.go"
provides: "POST /api/intake multipart handler"
exports: [IntakeHandler, NewIntakeHandler]
- path: "internal/api/handlers/intake_test.go"
provides: "Unit tests using MockAIClient and mock NetBox client"
- path: "internal/api/router.go"
provides: "POST /api/intake route registered"
contains: "POST.*intake"
- path: "cmd/hwlab/main.go"
provides: "NewNetBoxOpHandler wired as WAQ handler"
contains: "NewNetBoxOpHandler"
key_links:
- from: "internal/api/handlers/intake.go"
to: "internal/ai/orchestrator.go"
via: "IntakeHandler.ServeHTTP calls orchestrator.Analyze with base64-encoded photos"
pattern: "orchestrator\\.Analyze"
- from: "internal/api/handlers/intake.go"
to: "internal/netbox/hwid.go"
via: "AllocateNextHWID called after successful AI analysis"
pattern: "AllocateNextHWID"
- from: "internal/api/handlers/intake.go"
to: "internal/queue/handler.go"
via: "WAQ.Enqueue called with OpNetBoxCreateDevice payload when NetBox unreachable"
pattern: "OpNetBoxCreateDevice"
---
<objective>
Implement POST /api/intake: multipart photo upload → orchestrator → HW-ID allocation → NetBox create (or WAQ enqueue on failure) → tag sync → catalog status. Wire real WAQ handler in main.go. Add quick add mode.
Purpose: This is the core end-to-end intake flow — the primary value proposition of HWLab Phase 2.
Output: Fully wired intake endpoint, updated router, updated main.go with real WAQ handler.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/02-ai-pipeline/02-CONTEXT.md
@.planning/phases/02-ai-pipeline/02-01-SUMMARY.md
@.planning/phases/02-ai-pipeline/02-02-SUMMARY.md
<interfaces>
<!-- Contracts from Plans 01 and 02 -->
From internal/ai/orchestrator.go:
```go
type Orchestrator struct{ /* ... */ }
func NewOrchestrator(tier1, tier2 AIClient, threshold float64) *Orchestrator
func (o *Orchestrator) Analyze(ctx context.Context, req IntakeRequest) (*IntakeResult, inventory.CatalogStatus, error)
```
From internal/ai/types.go:
```go
type IntakeRequest struct { PhotosBase64 []string; JobID string }
type IntakeResult struct {
SerialNumber, Model, Manufacturer, Category string
Specs map[string]string; SuggestedTags []string
AINotes string; Confidence float64; ConfidenceNote string
}
type AIConfig struct {
Tier1, Tier2 TierConfig
ConfidenceThreshold float64
QuickAddEnabled bool
QuickAddThreshold float64
}
```
From internal/netbox/client.go:
```go
func (c *Client) CreateDevice(ctx, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error)
func (c *Client) PatchCustomFields(ctx, deviceID int64, patch map[string]interface{}) error
func (c *Client) AllocateNextHWID(ctx) (string, error)
func (c *Client) SyncTags(ctx, tags []string) ([]netbox.TagRef, error)
```
From internal/netbox/custom_fields.go:
```go
func BuildFullCustomFieldsPatch(cf CustomFields) map[string]interface{}
```
From internal/inventory/catalog_updater.go:
```go
type CatalogUpdater struct{ /* wraps *netbox.Client */ }
func (u *CatalogUpdater) UpdateCatalogStatus(ctx, deviceID int64, current, next inventory.CatalogStatus) error
```
From internal/queue/handler.go:
```go
const OpNetBoxCreateDevice = "netbox.create_device"
const OpNetBoxPatchCustomFields = "netbox.patch_custom_fields"
type CreateDevicePayload struct {
Name string; AssetTag string; DeviceTypeID, RoleID, SiteID int32
}
func NewNetBoxOpHandler(client NetBoxOpsClient) OpHandler
```
From internal/queue/waq.go:
```go
func (q *WAQ) Enqueue(ctx, op PendingOp) error
func NewPendingOp(opType string, payload json.RawMessage) PendingOp
```
From internal/config/config.go:
```go
type Config struct {
// ... existing fields ...
AI ai.AIConfig
}
```
NetBox device defaults for new items (use these IDs for Phase 2 — they must exist in the provisioned NetBox):
- DeviceTypeID: 1 (placeholder — "Generic Device" type must be provisioned in NetBox)
- RoleID: 1 (placeholder — "Inventory Item" role must be provisioned in NetBox)
- SiteID: 1 (placeholder — "Homelab" site provisioned in Phase 1)
Add to config.go defaults:
```go
v.SetDefault("netbox_default_device_type_id", 1)
v.SetDefault("netbox_default_role_id", 1)
v.SetDefault("netbox_default_site_id", 1)
```
These become `Config.NetBoxDefaultDeviceTypeID int32`, etc.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: POST /api/intake handler with orchestrator and NetBox wiring</name>
<files>
internal/api/handlers/intake.go,
internal/api/handlers/intake_test.go,
internal/config/config.go
</files>
<read_first>
- internal/api/handlers/health.go (full — understand handler pattern used in this project)
- internal/api/handlers/health_test.go (full — understand test patterns)
- internal/netbox/client.go (full — AllocateNextHWID, CreateDevice, PatchCustomFields, SyncTags)
- internal/netbox/custom_fields.go (full — BuildFullCustomFieldsPatch)
- internal/inventory/catalog_updater.go (full — UpdateCatalogStatus)
- internal/config/config.go (full — to add new NetBox default ID fields)
- internal/queue/waq.go (full — Enqueue, NewPendingOp)
- internal/queue/handler.go (full — op type constants and payload structs)
</read_first>
<behavior>
- Test: TestIntakeHandlerRejectsZeroPhotos — POST /api/intake with no files returns 400
- Test: TestIntakeHandlerRejectsFourPhotos — POST /api/intake with 4 files returns 400
- Test: TestIntakeHandlerHighConfidence — mock orchestrator returns HighConfidenceResult (0.95); mock NetBox CreateDevice succeeds; response is 201 JSON with fields: hw_id, model, manufacturer, category, catalog_status="indexed"
- Test: TestIntakeHandlerLowConfidence — mock returns LowConfidenceResult (0.40); response is 201 with catalog_status="needs_research"
- Test: TestIntakeHandlerQuickAdd — quick_add_enabled=true, quick_add_threshold=0.90, mock returns confidence 0.95; response is 201; CreateDevice called once (verify no review step)
- Test: TestIntakeHandlerNetBoxDown — mock NetBox CreateDevice returns error; handler enqueues to WAQ; returns 202 with queued=true in JSON body
</behavior>
<action>
**Extend internal/config/config.go** — add three fields to Config struct and defaults:
```go
NetBoxDefaultDeviceTypeID int32 `mapstructure:"netbox_default_device_type_id"`
NetBoxDefaultRoleID int32 `mapstructure:"netbox_default_role_id"`
NetBoxDefaultSiteID int32 `mapstructure:"netbox_default_site_id"`
```
Defaults in Load():
```go
v.SetDefault("netbox_default_device_type_id", 1)
v.SetDefault("netbox_default_role_id", 1)
v.SetDefault("netbox_default_site_id", 1)
```
Bindings:
```go
_ = v.BindEnv("netbox_default_device_type_id", "HWLAB_NETBOX_DEFAULT_DEVICE_TYPE_ID")
_ = v.BindEnv("netbox_default_role_id", "HWLAB_NETBOX_DEFAULT_ROLE_ID")
_ = v.BindEnv("netbox_default_site_id", "HWLAB_NETBOX_DEFAULT_SITE_ID")
```
**internal/api/handlers/intake.go** — the intake handler:
Define interfaces for testability (handler does not import netbox.Client directly):
```go
// intakeNetBoxClient is the subset of netbox.Client the intake handler needs.
type intakeNetBoxClient interface {
AllocateNextHWID(ctx context.Context) (string, error)
CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error)
PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error
SyncTags(ctx context.Context, tags []string) ([]netbox.TagRef, error)
}
// intakeCatalogUpdater is the subset needed for catalog status.
type intakeCatalogUpdater interface {
UpdateCatalogStatus(ctx context.Context, deviceID int64, current, next inventory.CatalogStatus) error
}
// intakeWAQ is the subset of WAQ the handler needs.
type intakeWAQ interface {
Enqueue(ctx context.Context, op queue.PendingOp) error
}
```
IntakeHandler struct:
```go
type IntakeHandler struct {
orchestrator *ai.Orchestrator
netbox intakeNetBoxClient
catalogUpdater intakeCatalogUpdater
waq intakeWAQ // may be nil if DragonFlyDB unavailable
deviceTypeID int32
roleID int32
siteID int32
quickAddEnabled bool
quickAddThresh float64
}
func NewIntakeHandler(
orch *ai.Orchestrator,
nb intakeNetBoxClient,
cu intakeCatalogUpdater,
waq intakeWAQ,
deviceTypeID, roleID, siteID int32,
quickAddEnabled bool,
quickAddThresh float64,
) *IntakeHandler {
return &IntakeHandler{
orchestrator: orch,
netbox: nb,
catalogUpdater: cu,
waq: waq,
deviceTypeID: deviceTypeID,
roleID: roleID,
siteID: siteID,
quickAddEnabled: quickAddEnabled,
quickAddThresh: quickAddThresh,
}
}
```
IntakeResponse JSON struct:
```go
type IntakeResponse struct {
HWID string `json:"hw_id"`
Model string `json:"model"`
Manufacturer string `json:"manufacturer"`
Category string `json:"category"`
Specs map[string]string `json:"specs"`
SuggestedTags []string `json:"suggested_tags"`
AINotes string `json:"ai_notes"`
Confidence float64 `json:"confidence"`
CatalogStatus string `json:"catalog_status"`
NetBoxID int64 `json:"netbox_id,omitempty"`
Queued bool `json:"queued,omitempty"` // true if NetBox was unreachable
}
```
ServeHTTP flow:
1. `r.ParseMultipartForm(32 << 20)` — 32MB max
2. Validate files count: len(files) == 0 → 400; len(files) > 3 → 400
3. Read each file, detect MIME (http.DetectContentType on first 512 bytes), base64-encode → photosBase64 slice
4. Generate jobID: `uuid.New().String()`
5. `result, status, err := h.orchestrator.Analyze(r.Context(), ai.IntakeRequest{PhotosBase64: photosBase64, JobID: jobID})`
6. If err != nil → 500
7. `hwid, err := h.netbox.AllocateNextHWID(r.Context())`; if err → 500
8. Decide name: `result.Manufacturer + " " + result.Model` (trim spaces; if empty use hwid)
9. Quick add check: if h.quickAddEnabled && result.Confidence >= h.quickAddThresh → attempt NetBox create
10. Non-quick-add path AND quick-add path both try CreateDevice; on error → enqueue to WAQ if available → 202
11. On successful CreateDevice: PatchCustomFields with BuildFullCustomFieldsPatch, SyncTags, UpdateCatalogStatus
12. Return 201 (or 202 if queued) JSON IntakeResponse
For BuildFullCustomFieldsPatch, construct a netbox.CustomFields from IntakeResult:
```go
cf := netboxTypes.CustomFields{
HWID: hwid,
CatalogStatus: string(status),
AINotes: result.AINotes,
}
patch := netbox.BuildFullCustomFieldsPatch(cf)
```
Note: Use encoding/json to marshal CreateDevicePayload for WAQ enqueue:
```go
payload, _ := json.Marshal(queue.CreateDevicePayload{
Name: deviceName,
AssetTag: hwid,
DeviceTypeID: h.deviceTypeID,
RoleID: h.roleID,
SiteID: h.siteID,
})
op := queue.NewPendingOp(queue.OpNetBoxCreateDevice, payload)
h.waq.Enqueue(r.Context(), op)
```
Return 202 with `{"queued": true, "hw_id": hwid, ...}` when NetBox was unreachable.
**internal/api/handlers/intake_test.go** — six tests using mock structs:
Define mock types in intake_test.go (unexported):
- `mockOrchestrator` with `FixedResult *ai.IntakeResult`, `FixedStatus inventory.CatalogStatus` — wraps with an `Analyze` method matching the expected signature
- `mockNetBox` with configurable return values for AllocateNextHWID, CreateDevice, PatchCustomFields, SyncTags
- `mockCatalogUpdater`
- `mockWAQ` that records enqueued ops
Use `net/http/httptest` to create a recorder, call `handler.ServeHTTP(rec, req)`.
For multipart body construction in tests:
```go
var body bytes.Buffer
w := multipart.NewWriter(&body)
fw, _ := w.CreateFormFile("photos", "test.jpg")
fw.Write([]byte{0xff, 0xd8, 0xff}) // minimal JPEG header
w.Close()
req := httptest.NewRequest(http.MethodPost, "/api/intake", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
```
NOTE: The handler receives `*ai.Orchestrator` but for tests you need to pass a mock orchestrator. Refactor IntakeHandler to accept an orchestratorFunc or define an `IntakeOrchestrator` interface with `Analyze(ctx, req) (*IntakeResult, CatalogStatus, error)` — use the interface instead of the concrete type. This decouples tests cleanly.
Update IntakeHandler.orchestrator field to use an interface:
```go
type intakeOrchestrator interface {
Analyze(ctx context.Context, req ai.IntakeRequest) (*ai.IntakeResult, inventory.CatalogStatus, error)
}
```
The concrete `*ai.Orchestrator` satisfies this interface automatically.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/api/... ./internal/config/... -v 2>&1 | tail -40</automated>
</verify>
<done>
- All 6 TestIntakeHandler* tests pass
- `go build ./...` clean
- Config struct has NetBoxDefaultDeviceTypeID, NetBoxDefaultRoleID, NetBoxDefaultSiteID
- internal/api/handlers/intake.go exists with NewIntakeHandler and ServeHTTP
</done>
</task>
<task type="auto">
<name>Task 2: Wire intake route and real WAQ handler in router and main.go</name>
<files>internal/api/router.go, cmd/hwlab/main.go</files>
<read_first>
- internal/api/router.go (full — add POST /api/intake route)
- cmd/hwlab/main.go (full — wire intake handler and swap NoOpHandler for NetBoxOpHandler)
- internal/api/handlers/intake.go (skim — NewIntakeHandler signature)
- internal/queue/handler.go (skim — NewNetBoxOpHandler signature)
- internal/netbox/client.go (skim — *Client satisfies intakeNetBoxClient and NetBoxOpsClient interfaces)
- internal/inventory/catalog_updater.go (skim — NewCatalogUpdater if it exists, or construct CatalogUpdater directly)
</read_first>
<action>
**internal/api/router.go** — add POST /api/intake:
Update NewRouter signature to accept an http.Handler for the intake endpoint:
```go
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler {
r := chi.NewRouter()
// ... existing middleware ...
r.Route("/api", func(r chi.Router) {
r.Get("/health", handlers.Health)
r.Post("/intake", intakeHandler.ServeHTTP)
})
// ... existing SPA handler ...
}
```
**cmd/hwlab/main.go** — wire everything:
1. Load config (existing)
2. Create NetBox client: `nbClient, err := netbox.NewClient(cfg.NetBoxURL, cfg.NetBoxToken)`; if err → log.Fatalf
3. Create AI tier clients:
```go
tier1 := ai.NewTierClient(cfg.AI.Tier1)
tier2 := ai.NewTierClient(cfg.AI.Tier2)
orch := ai.NewOrchestrator(tier1, tier2, cfg.AI.ConfidenceThreshold)
```
4. Create catalog updater:
```go
catalogUpdater := &inventory.CatalogUpdater{} // or however it's constructed from Phase 1
```
Read internal/inventory/catalog_updater.go to check its exact constructor/struct literal.
5. Create intake handler:
```go
intakeHandler := handlers.NewIntakeHandler(
orch,
nbClient,
catalogUpdater,
waq, // may be nil — handler must handle nil waq gracefully
cfg.NetBoxDefaultDeviceTypeID,
cfg.NetBoxDefaultRoleID,
cfg.NetBoxDefaultSiteID,
cfg.AI.QuickAddEnabled,
cfg.AI.QuickAddThreshold,
)
```
6. Swap NoOpHandler for real WAQ handler:
```go
// Replace: go waq.RunWorker(ctx, queue.NoOpHandler, ...)
// With:
nbHandler := queue.NewNetBoxOpHandler(nbClient)
go waq.RunWorker(ctx, nbHandler, cfg.WAQMaxAttempts, retryInterval)
```
7. Pass intakeHandler to router:
```go
router := api.NewRouter(staticFiles, intakeHandler)
```
Handle the case where waq is nil: IntakeHandler.waq is an interface — if waq init failed (non-fatal), pass nil. In ServeHTTP, check `if h.waq != nil` before calling Enqueue. If waq is nil and NetBox is down → return 503 (service unavailable, cannot queue).
Also handle nil waq in main.go WAQ worker section:
```go
var nbHandler queue.OpHandler
if waq != nil {
nbHandler = queue.NewNetBoxOpHandler(nbClient)
go waq.RunWorker(ctx, nbHandler, cfg.WAQMaxAttempts, retryInterval)
}
```
NOTE: `catalog_updater.go` from Phase 1 — read it to find the correct constructor. The struct is:
```go
type CatalogUpdater struct {
client *netbox.Client
}
```
Construct with struct literal: `&inventory.CatalogUpdater{...}` or if it has a constructor `inventory.NewCatalogUpdater(nbClient)`. Check the actual file.
After editing, run `go build ./...` to confirm compilation. Fix any import cycle or interface mismatch errors before finishing.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... 2>&1 && echo "BUILD OK" && curl -s http://localhost:8080/api/health 2>/dev/null || echo "(server not running — build check only)"</automated>
</verify>
<done>
- `go build ./...` passes with zero errors
- router.go has `r.Post("/intake", ...)` route
- main.go uses NewNetBoxOpHandler (grep confirms; NoOpHandler no longer referenced in main.go)
- main.go creates netbox.Client, ai.Orchestrator, and handlers.IntakeHandler
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP client → /api/intake | Untrusted multipart file upload from browser or curl |
| intake handler → oMLX | Base64 image data sent to local AI — image content is untrusted |
| intake handler → NetBox | Structured data written to source of truth |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-09 | Denial of Service | multipart upload size | mitigate | r.ParseMultipartForm(32 << 20) 32MB hard cap on request body |
| T-02-10 | Tampering | AI-extracted device name | mitigate | device name passed directly to CreateDevice — net/http handler sanitizes via Go string (no SQL injection possible; go-netbox marshals to JSON) |
| T-02-11 | Tampering | AI-extracted tags | accept | Tags pass through normalizeTags in SyncTags (Phase 1 T-04-02 mitigation) — slug normalization strips injection characters |
| T-02-12 | Denial of Service | photo count bypass | mitigate | Explicit len check 1-3 before any processing; 0 or 4+ returns 400 immediately |
| T-02-13 | Spoofing | intake response hw_id | accept | HW-ID assigned by AllocateNextHWID, not caller-controlled; sequential allocation cannot be spoofed via this endpoint |
</threat_model>
<verification>
After plan completion:
1. `go build ./...` — zero errors
2. `go test ./internal/api/... -v` — all intake handler tests pass, health tests still pass
3. `grep "Post.*intake" internal/api/router.go` — route present
4. `grep "NewNetBoxOpHandler" cmd/hwlab/main.go` — real handler wired
5. `grep "NoOpHandler" cmd/hwlab/main.go` — NOT present (replaced)
6. `go test ./...` — zero failures (all packages)
</verification>
<success_criteria>
- POST /api/intake registered and reachable
- Handler validates 1-3 photos, returns 400 on violations
- Mock-based unit tests cover high confidence, low confidence, quick add, and NetBox-down scenarios
- WAQ real handler (NewNetBoxOpHandler) used in main.go — NoOpHandler no longer in main.go
- `go build ./...` clean with zero errors
</success_criteria>
<output>
After completion, create `.planning/phases/02-ai-pipeline/02-03-SUMMARY.md`
</output>

View file

@ -0,0 +1,303 @@
---
phase: 02-ai-pipeline
plan: "04"
type: execute
wave: 4
depends_on: [02-03]
files_modified:
- internal/ai/omlx_integration_test.go
- docs/omlx-setup.md
autonomous: false
requirements: [AI-01]
must_haves:
truths:
- "oMLX serves Gemma 4 on Mac Mini M4 and responds to OpenAI-compatible /v1/chat/completions"
- "Gemma 4 E4B memory usage measured and documented (must fit within 16GB)"
- "Integration test proves end-to-end: 1 photo → IntakeResult with non-empty model field"
- "Memory budget and model tier decision documented in docs/omlx-setup.md"
artifacts:
- path: "internal/ai/omlx_integration_test.go"
provides: "Integration test that skips unless HWLAB_OMLX_URL is set; proves real AI call works"
- path: "docs/omlx-setup.md"
provides: "oMLX installation steps, model tier selection, measured memory budget"
key_links:
- from: "internal/ai/omlx_integration_test.go"
to: "http://localhost:8000/v1"
via: "TierClient with real oMLX endpoint; skips when HWLAB_OMLX_URL unset"
pattern: "HWLAB_OMLX_URL"
---
<objective>
Verify oMLX runs on Mac Mini M4 with Gemma 4, measure memory usage, and document the model tier decision. Write an integration test that proves the real AI pipeline works end-to-end.
Purpose: AI-01 requires empirical validation that Gemma 4 fits in 16GB on the Mac Mini. This checkpoint collects that measurement.
Output: Passing integration test (when oMLX reachable), memory measurement recorded in docs/, model tier confirmed.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/02-ai-pipeline/02-CONTEXT.md
@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md
<interfaces>
From internal/ai/client.go:
```go
type TierClient struct{ /* ... */ }
func NewTierClient(cfg TierConfig) *TierClient
func (c *TierClient) AnalyzePhotos(ctx context.Context, req IntakeRequest) (*IntakeResult, error)
```
From internal/ai/types.go:
```go
type TierConfig struct {
BaseURL string
APIKey string
Model string
TimeoutSeconds int
}
type IntakeRequest struct { PhotosBase64 []string; JobID string }
type IntakeResult struct {
Model string; Manufacturer string; Confidence float64
// ... other fields
}
```
oMLX installation (macOS Apple Silicon):
```bash
# Install oMLX (requires macOS 15+, Apple Silicon)
# From https://omlx.ai or brew if available
# Default port: 8000
# Start with: omlx serve --model gemma-4-e4b --port 8000
# Measure memory: activity monitor or `memory_pressure` / `vm_stat`
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: oMLX integration test with skip guard</name>
<files>internal/ai/omlx_integration_test.go</files>
<read_first>
- internal/ai/client.go (full — TierClient and AnalyzePhotos)
- internal/ai/types.go (full)
- internal/netbox/client_test.go (skim — skip guard pattern used in this codebase)
</read_first>
<action>
Create internal/ai/omlx_integration_test.go:
```go
//go:build integration
package ai_test
import (
"context"
"os"
"testing"
"encoding/base64"
"git.georgsen.dk/hwlab/internal/ai"
)
// TestOMLXIntegration tests a real call to oMLX.
// Run with: HWLAB_OMLX_URL=http://localhost:8000/v1 go test ./internal/ai/... -tags integration -v -run TestOMLX
//
// Skip conditions:
// - HWLAB_OMLX_URL not set
// - oMLX unreachable (test fails with connection error — not skipped, so the failure is visible)
func TestOMLXIntegration(t *testing.T) {
omlxURL := os.Getenv("HWLAB_OMLX_URL")
if omlxURL == "" {
t.Skip("HWLAB_OMLX_URL not set — skipping oMLX integration test")
}
model := os.Getenv("HWLAB_OMLX_MODEL")
if model == "" {
model = "gemma-4-e4b"
}
client := ai.NewTierClient(ai.TierConfig{
BaseURL: omlxURL,
APIKey: "local",
Model: model,
TimeoutSeconds: 60,
})
// Minimal 1x1 red JPEG for testing — real photos not needed for integration smoke test
// This is a valid tiny JPEG in base64
minimalJPEG := "data:image/jpeg;base64," + minimalJPEGBase64()
result, err := client.AnalyzePhotos(context.Background(), ai.IntakeRequest{
PhotosBase64: []string{minimalJPEG},
JobID: "integration-test-001",
})
if err != nil {
t.Fatalf("AnalyzePhotos error: %v", err)
}
if result == nil {
t.Fatal("result is nil")
}
// Confidence may be low for a minimal test image — just verify the call completed
t.Logf("IntakeResult: model=%q manufacturer=%q category=%q confidence=%.2f",
result.Model, result.Manufacturer, result.Category, result.Confidence)
t.Logf("AINotes: %s", result.AINotes)
// The model must return something in the JSON fields — at minimum a non-panic parse
// (empty model string is acceptable for a 1x1 pixel image)
if result.Confidence < 0 || result.Confidence > 1.0 {
t.Errorf("confidence %.2f out of [0,1] range", result.Confidence)
}
}
// minimalJPEGBase64 returns a base64-encoded minimal valid JPEG (1x1 white pixel).
// Source: https://github.com/nicowillis/pngheaders (1x1 JPEG, 631 bytes)
func minimalJPEGBase64() string {
// 1x1 white JPEG — static bytes for reproducible test
data := []byte{
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12,
0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20,
0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29,
0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32,
0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00,
0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03,
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08,
0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72,
0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45,
0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,
0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8a, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4,
0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7,
0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca,
0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3,
0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5,
0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00,
0x00, 0x3f, 0x00, 0xfb, 0xd2, 0x8a, 0x28, 0x03, 0xff, 0xd9,
}
return base64.StdEncoding.EncodeToString(data)
}
```
NOTE: Use build tag `//go:build integration` so the test is excluded from normal `go test ./...` runs. Integration tests only run when explicitly tagged: `go test -tags integration ./internal/ai/...`
This follows the skip-guard pattern established in Phase 1 but uses build tags instead of env-only guards, since oMLX is only available on the Mac Mini production machine.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/ai/... -v 2>&1 | tail -20</automated>
</verify>
<done>
- `go build ./...` passes
- `go test ./internal/ai/... -v` (without -tags integration) shows integration test NOT included — only unit tests run
- internal/ai/omlx_integration_test.go exists with build tag `integration`
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
All Phase 2 AI pipeline code is complete and tested:
- go-openai installed, AIClient interface, MockAIClient, TierClient
- Three-tier orchestrator with confidence-based tier escalation
- WAQ real NetBox op handler (create_device + patch_custom_fields)
- POST /api/intake endpoint wired end-to-end
- Quick add mode (config-driven)
- SearXNG ResearchClient stub
- oMLX integration test (build-tag guarded)
</what-built>
<how-to-verify>
**Step 1: Run all unit tests (no external services needed)**
```bash
cd /home/mikkel/homelabby
go test ./... -v 2>&1 | grep -E "^(ok|FAIL|---)" | head -40
```
Expected: all packages show "ok" or "SKIP" — zero FAIL.
**Step 2: Test the intake endpoint with mock photos (binary running locally)**
```bash
# Terminal 1: start the server
cd /home/mikkel/homelabby
go run cmd/hwlab/main.go &
# Terminal 2: send a test intake request (any JPEG file will work)
curl -s -X POST http://localhost:8080/api/intake \
-F "photos=@/path/to/any-photo.jpg" | python3 -m json.tool
```
Expected (without real oMLX running):
- If oMLX is reachable: JSON response with hw_id, model, confidence, catalog_status
- If oMLX unreachable (expected on dev machine): 500 or 202 depending on tier client timeout
**Step 3: On Mac Mini M4 — run the oMLX integration test**
```bash
# On Mac Mini: start oMLX
omlx serve --model gemma-4-e4b --port 8000
# Check memory: Activity Monitor → omlx process, note "Real Memory"
# Expected for E4B: ~8-10GB RAM
# Run integration test
cd /home/mikkel/homelabby
HWLAB_OMLX_URL=http://localhost:8000/v1 go test -tags integration ./internal/ai/... -run TestOMLX -v
```
Expected: PASS with logged IntakeResult fields (model may be empty for test pixel — that's OK).
**Step 4: Document memory measurement**
Record in the summary: "Gemma 4 E4B: X GB real memory on Mac Mini M4 16GB"
If > 12GB: note that 26B A4B is not feasible without TurboQuant KV offload.
</how-to-verify>
<resume-signal>
Type "approved" after verifying unit tests pass.
If oMLX test was run on Mac Mini, include memory measurement (e.g. "approved — E4B uses 9.2GB").
If Mac Mini not available yet, type "approved — oMLX test deferred, unit tests pass".
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| integration test → oMLX | Test sends real data to local AI; only runs when explicitly triggered |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-14 | Information Disclosure | omlx-setup.md | accept | Document contains model names and port numbers — no secrets; oMLX API key is "local" (not a real credential) |
| T-02-15 | Denial of Service | integration test resource usage | mitigate | Build tag `integration` ensures test never runs in standard CI pipeline; only runs manually with explicit env var |
</threat_model>
<verification>
After plan completion:
1. `go test ./... 2>&1 | grep FAIL` — no failures
2. `go test -tags integration ./internal/ai/... -v -run TestOMLX` when HWLAB_OMLX_URL unset → SKIP
3. `ls docs/omlx-setup.md` — file exists with memory measurement (filled in during checkpoint)
4. Human verified: unit test suite clean, oMLX smoke test outcome documented
</verification>
<success_criteria>
- All Phase 2 unit tests pass with zero failures
- oMLX integration test exists, skips gracefully when HWLAB_OMLX_URL not set
- Memory budget for Gemma 4 E4B documented (or deferred with note if Mac Mini not available)
- Phase 2 complete: POST /api/intake is end-to-end functional
</success_criteria>
<output>
After completion, create `.planning/phases/02-ai-pipeline/02-04-SUMMARY.md`
</output>