docs(02-01): complete AI package foundation plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:46:05 +00:00
parent 8c03780230
commit 3eed2e9c63

View file

@ -0,0 +1,152 @@
---
phase: 02-ai-pipeline
plan: "01"
subsystem: ai
tags: [go, ai, openai, netbox, tdd, config]
dependency_graph:
requires: [internal/netbox.Client, internal/config.Config]
provides: [internal/ai.AIClient, internal/ai.TierClient, internal/ai.MockAIClient, internal/netbox.CreateDevice]
affects: [internal/config.Config, cmd/hwlab]
tech_stack:
added: [github.com/sashabaranov/go-openai v1.41.2]
patterns: [interface-with-mock, tier-routing-via-baseurl, viper-merge-config, oneOf-FK-helpers]
key_files:
created:
- internal/ai/types.go
- internal/ai/client.go
- internal/ai/mock.go
- internal/ai/client_test.go
- internal/ai/prompts/intake.go
- ai_config.json
modified:
- go.mod
- go.sum
- internal/netbox/client.go
- internal/netbox/client_test.go
- internal/config/config.go
- internal/config/config_test.go
- .gitignore
decisions:
- id: AI-CLIENT-01
summary: "AIClient is an interface so TierClient and MockAIClient are interchangeable — all downstream plans depend on this contract, not the concrete type"
- id: AI-CLIENT-02
summary: "TierClient tier-routing via openai.DefaultConfig + BaseURL override — single go-openai client factory, swap endpoint per tier from config"
- id: AI-CONFIG-01
summary: "ai_config.json merged via separate viper instance to avoid SetConfigName collision with primary config.json"
- id: NB-CREATE-01
summary: "go-netbox v4 CreateDevice uses Int32AsDeviceBayTemplateRequestDeviceType / Int32AsDeviceWithConfigContextRequest{Role,Site} oneOf helpers — plain int32 not accepted by constructor"
- id: SEC-01
summary: "ai_config.json committed as template with REPLACE_WITH_OPENROUTER_KEY placeholder; ai_config.local.json added to .gitignore for real keys (T-02-01)"
metrics:
duration: "~4 minutes"
completed: "2026-04-10T05:45:16Z"
tasks_completed: 2
files_created: 6
files_modified: 7
---
# Phase 2 Plan 01: AI Package Foundation Summary
**One-liner:** go-openai v1.41.2 installed; AIClient interface with TierClient (BaseURL tier-routing) and MockAIClient test double; intake prompt template; Config extended with AIConfig; NetBox CreateDevice added using go-netbox v4 oneOf FK helpers.
## What Was Built
### `internal/ai/types.go`
Domain types decoupled from go-openai:
- `IntakeRequest` — 1-3 base64 photos + job UUID for tracing
- `IntakeResult` — structured AI output (model, manufacturer, category, specs, confidence, etc.)
- `TierConfig` — per-tier provider config (BaseURL, APIKey, Model, TimeoutSeconds)
- `AIConfig` — orchestration config (Tier1, Tier2, ConfidenceThreshold, QuickAddEnabled, QuickAddThreshold)
### `internal/ai/client.go`
- `AIClient` interface with `AnalyzePhotos(ctx, IntakeRequest) (*IntakeResult, error)`
- `TierClient` — production implementation wrapping `go-openai`; tier-routing via `openai.DefaultConfig(key) + config.BaseURL`; `context.WithTimeout` on every call (T-02-03 DoS mitigation)
- JSON parse failure returns zero-confidence `IntakeResult` (not error) — orchestrator escalates
### `internal/ai/mock.go`
- `MockAIClient` — records all `IntakeRequest` calls in `Calls` slice; returns configurable `FixedResult`/`FixedError`
- `HighConfidenceResult()` — Raspberry Pi 4 fixture at 0.95 confidence
- `LowConfidenceResult()` — unknown device fixture at 0.40 confidence
### `internal/ai/prompts/intake.go`
- `BuildIntakePrompt(photoCount int)` — JSON-extraction prompt with exact schema, no markdown/fences instruction
### `internal/config/config.go`
- `Config.AI ai.AIConfig` field added with `mapstructure:"ai"` tag
- Viper defaults for both tiers, confidence threshold, quick-add settings
- Env bindings: `HWLAB_AI_TIER1_*`, `HWLAB_AI_TIER2_*`, `HWLAB_AI_CONFIDENCE_THRESHOLD`, `HWLAB_AI_QUICK_ADD_ENABLED`
- Secondary viper instance merges `ai_config.json` as override without conflicting with primary `config.json` loading
### `ai_config.json`
Template config file committed to repo with placeholder `REPLACE_WITH_OPENROUTER_KEY` for Tier2.
### `internal/netbox/client.go` — CreateDevice + DeleteDevice
- `CreateDevice(ctx, name, assetTag, deviceTypeID, roleID, siteID int32) (int64, error)` — uses `Int32As*` oneOf helpers required by go-netbox v4 constructor
- `DeleteDevice(ctx, id int64) error` — for integration test cleanup
## Test Results
| Test | Package | Result |
|------|---------|--------|
| TestCreateDeviceValidation | internal/netbox | PASS |
| TestCreateDeviceLive | internal/netbox | SKIP (placeholder token) |
| TestMockAIClient | internal/ai | PASS |
| TestMockAIClientError | internal/ai | PASS |
| TestTierClientConstruction | internal/ai | PASS |
| TestAIConfigDefaults | internal/config | PASS |
| TestLoadDefaults | internal/config | PASS |
| TestLoadEnvOverride | internal/config | PASS |
| TestLoadNetBoxURL | internal/config | PASS |
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] go-netbox v4 WritableDeviceWithConfigContextRequest constructor signature mismatch**
- **Found during:** Task 1 GREEN phase
- **Issue:** Plan used `nb.NewWritableDeviceWithConfigContextRequest(name, roleID, siteID, deviceTypeID)` (4 args with name first). Actual go-netbox v4 signature is `(deviceType DeviceBayTemplateRequestDeviceType, role DeviceWithConfigContextRequestRole, site DeviceWithConfigContextRequestSite)` — 3 args with oneOf wrapper types, no name parameter.
- **Fix:** Used `Int32AsDeviceBayTemplateRequestDeviceType`, `Int32AsDeviceWithConfigContextRequestRole`, `Int32AsDeviceWithConfigContextRequestSite` helpers; called `req.SetName(name)` separately; `SetAssetTag` takes plain `string` not `NullableString`.
- **Files modified:** internal/netbox/client.go
- **Commit:** 6040ecc
**2. [Rule 2 - Security] ai_config.local.json added to .gitignore (T-02-01)**
- **Found during:** Task 2 — threat model review
- **Issue:** T-02-01 requires gitignoring ai_config.json for real API keys, but the plan also requires committing the template. These are contradictory for the same filename.
- **Fix:** Committed `ai_config.json` as template (placeholder key), added `ai_config.local.json` to `.gitignore` as the pattern for operator's real keys. Documented in file comment.
- **Files modified:** .gitignore
- **Commit:** 8c03780
## Known Stubs
None — no UI rendering paths or data sources in this plan. All types are concrete and wired to real go-openai client or mock.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: information_disclosure | internal/ai/client.go | TierClient logs no API keys (T-02-04 accepted) — verified: no log.Printf of TierConfig.APIKey |
## Self-Check
Files created:
- internal/ai/types.go: FOUND
- internal/ai/client.go: FOUND
- internal/ai/mock.go: FOUND
- internal/ai/client_test.go: FOUND
- internal/ai/prompts/intake.go: FOUND
- ai_config.json: FOUND
Commits:
- 6040ecc: Task 1 (go-openai install + CreateDevice)
- 8c03780: Task 2 (AI package + config extension)
`go build ./...`: PASS
`go test ./internal/ai/...`: PASS (3/3)
`go test ./internal/config/...`: PASS (4/4)
`go test ./internal/netbox/... -run TestCreateDevice`: PASS (1 pass, 1 skip)
`grep sashabaranov/go-openai go.mod`: FOUND v1.41.2
`ls internal/ai/`: types.go client.go mock.go client_test.go prompts/
`ls internal/ai/prompts/`: intake.go
`ls ai_config.json`: FOUND
## Self-Check: PASSED