diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e28f95b..3ebf2fc 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/02-ai-pipeline/02-01-PLAN.md b/.planning/phases/02-ai-pipeline/02-01-PLAN.md new file mode 100644 index 0000000..b1c7e30 --- /dev/null +++ b/.planning/phases/02-ai-pipeline/02-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/02-ai-pipeline/02-CONTEXT.md +@.planning/phases/02-ai-pipeline/02-RESEARCH.md + + + + +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 +``` + + + + + + + Task 1: Install go-openai and add CreateDevice to NetBox client + go.mod, go.sum, internal/netbox/client.go, internal/netbox/client_test.go + + - 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) + + + - 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) + + +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. + + + cd /home/mikkel/homelabby && go build ./... && go test ./internal/netbox/... -run TestCreateDevice -v 2>&1 | tail -20 + + go.mod contains github.com/sashabaranov/go-openai; `go build ./...` passes; TestCreateDeviceValidation PASS; TestCreateDeviceLive SKIP (no live token). + + + + Task 2: AI package — types, interface, mock, prompts, config extension + + 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 + + + - 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) + + + - 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) + + +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": "", + "model": "", + "manufacturer": "", + "category": "", + "specs": {"": ""}, + "suggested_tags": ["", ""], + "ai_notes": "", + "confidence": , + "confidence_note": "= 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) + + + cd /home/mikkel/homelabby && go build ./... && go test ./internal/ai/... ./internal/config/... -v 2>&1 | tail -30 + + + - `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 + + + + + + +## 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) | + + + +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 + + + +- 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 + + + +After completion, create `.planning/phases/02-ai-pipeline/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-ai-pipeline/02-02-PLAN.md b/.planning/phases/02-ai-pipeline/02-02-PLAN.md new file mode 100644 index 0000000..c47c500 --- /dev/null +++ b/.planning/phases/02-ai-pipeline/02-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 +``` + + + + + + + Task 1: Three-tier orchestrator with confidence routing + internal/ai/orchestrator.go, internal/ai/orchestrator_test.go, internal/ai/research.go + + - 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) + + + - 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 + + +**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 + + + cd /home/mikkel/homelabby && go build ./... && go test ./internal/ai/... -run TestOrchestrator -v 2>&1 + + + All 5 TestOrchestrator* tests pass. + `go build ./...` clean. + internal/ai/research.go exists with ResearchClient interface and NoOpResearchClient. + + + + + Task 2: WAQ real NetBox op handler (replaces NoOpHandler) + internal/queue/handler.go, internal/queue/handler_test.go + + - 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) + + + - 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 + + +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. + + + cd /home/mikkel/homelabby && go build ./... && go test ./internal/queue/... -v 2>&1 + + + All 5 TestNetBoxOpHandler* tests pass. + `go build ./...` clean. + internal/queue/handler.go exports NewNetBoxOpHandler, OpNetBoxCreateDevice, OpNetBoxPatchCustomFields constants. + NoOpHandler remains untouched in worker.go. + + + + + + +## 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 | + + + +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 + + + +- 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 + + + +After completion, create `.planning/phases/02-ai-pipeline/02-02-SUMMARY.md` + diff --git a/.planning/phases/02-ai-pipeline/02-03-PLAN.md b/.planning/phases/02-ai-pipeline/02-03-PLAN.md new file mode 100644 index 0000000..ec4a5ad --- /dev/null +++ b/.planning/phases/02-ai-pipeline/02-03-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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. + + + + + + + Task 1: POST /api/intake handler with orchestrator and NetBox wiring + + internal/api/handlers/intake.go, + internal/api/handlers/intake_test.go, + internal/config/config.go + + + - 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) + + + - 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 + + +**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. + + + cd /home/mikkel/homelabby && go build ./... && go test ./internal/api/... ./internal/config/... -v 2>&1 | tail -40 + + + - All 6 TestIntakeHandler* tests pass + - `go build ./...` clean + - Config struct has NetBoxDefaultDeviceTypeID, NetBoxDefaultRoleID, NetBoxDefaultSiteID + - internal/api/handlers/intake.go exists with NewIntakeHandler and ServeHTTP + + + + + Task 2: Wire intake route and real WAQ handler in router and main.go + internal/api/router.go, cmd/hwlab/main.go + + - 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) + + +**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. + + + 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)" + + + - `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 + + + + + + +## 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 | + + + +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) + + + +- 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 + + + +After completion, create `.planning/phases/02-ai-pipeline/02-03-SUMMARY.md` + diff --git a/.planning/phases/02-ai-pipeline/02-04-PLAN.md b/.planning/phases/02-ai-pipeline/02-04-PLAN.md new file mode 100644 index 0000000..15232b8 --- /dev/null +++ b/.planning/phases/02-ai-pipeline/02-04-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/02-ai-pipeline/02-CONTEXT.md +@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md + + +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` +``` + + + + + + + Task 1: oMLX integration test with skip guard + internal/ai/omlx_integration_test.go + + - 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) + + +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. + + + cd /home/mikkel/homelabby && go build ./... && go test ./internal/ai/... -v 2>&1 | tail -20 + + + - `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` + + + + + + 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) + + + **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. + + + 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". + + + + + + +## 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 | + + + +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 + + + +- 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 + + + +After completion, create `.planning/phases/02-ai-pipeline/02-04-SUMMARY.md` +