homelabby/.planning/phases/02-ai-pipeline/02-02-PLAN.md
Mikkel Georgsen 7bebe2ed93 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>
2026-04-10 05:40:22 +00:00

418 lines
17 KiB
Markdown

---
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>