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>
17 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-ai-pipeline | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom internal/ai/client.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:
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:
type CatalogStatus string
const StatusIndexed CatalogStatus = "indexed"
const StatusNeedsResearch CatalogStatus = "needs_research"
From internal/queue/waq.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:
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:**
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):
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
MockAIClientwithFixedResult/FixedErrorfor 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.
Op type string constants:
const (
OpNetBoxCreateDevice = "netbox.create_device"
OpNetBoxPatchCustomFields = "netbox.patch_custom_fields"
)
Payload types:
// 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):
// 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):
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.
<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> |
<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>