feat(02-01): AI package foundation — types, interface, mock, prompts, config extension

- internal/ai/types.go: IntakeRequest, IntakeResult, TierConfig, AIConfig domain types
- internal/ai/client.go: AIClient interface + TierClient (go-openai, BaseURL tier-routing)
- internal/ai/mock.go: MockAIClient test double with HighConfidenceResult/LowConfidenceResult fixtures
- internal/ai/prompts/intake.go: BuildIntakePrompt() JSON-extraction prompt template
- internal/config/config.go: Config.AI AIConfig field, tier defaults, env bindings, ai_config.json merge
- ai_config.json: template config with placeholder Tier2 API key
- .gitignore: add ai_config.local.json pattern for real keys (T-02-01 mitigation)
- All tests pass: TestMockAIClient, TestMockAIClientError, TestTierClientConstruction, TestAIConfigDefaults
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:45:13 +00:00
parent 6040ecc3cc
commit 8c03780230
11 changed files with 361 additions and 10 deletions

3
.gitignore vendored
View file

@ -1 +1,4 @@
.env
# ai_config.json is committed as a template (placeholder keys only).
# Create ai_config.local.json for real API keys — never commit it.
ai_config.local.json

19
ai_config.json Normal file
View file

@ -0,0 +1,19 @@
{
"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
}
}

9
go.mod
View file

@ -4,7 +4,11 @@ go 1.23.0
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/netbox-community/go-netbox/v4 v4.3.0
github.com/redis/go-redis/v9 v9.18.0
github.com/sashabaranov/go-openai v1.41.2
github.com/spf13/viper v1.21.0
)
@ -13,12 +17,8 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/netbox-community/go-netbox/v4 v4.3.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sashabaranov/go-openai v1.41.2 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@ -28,6 +28,5 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
)

11
go.sum
View file

@ -1,3 +1,7 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -18,11 +22,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/netbox-community/go-netbox/v4 v4.3.0 h1:1kYHscOJG8+GJobC9OdgXX39zBKrBzUE5bxwMgxdlaQ=
@ -53,6 +56,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=

98
internal/ai/client.go Normal file
View file

@ -0,0 +1,98 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"time"
openai "github.com/sashabaranov/go-openai"
"git.georgsen.dk/hwlab/internal/ai/prompts"
)
// 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 {
oCfg := openai.DefaultConfig(cfg.APIKey)
oCfg.BaseURL = cfg.BaseURL
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
return &TierClient{
client: openai.NewClientWithConfig(oCfg),
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
}
// buildIntakePromptWithCount is a package-internal shim to the prompts package.
func buildIntakePromptWithCount(n int) string {
return prompts.BuildIntakePrompt(n)
}

View file

@ -0,0 +1,65 @@
package ai_test
import (
"context"
"errors"
"testing"
"git.georgsen.dk/hwlab/internal/ai"
)
func TestMockAIClient(t *testing.T) {
mock := &ai.MockAIClient{
FixedResult: ai.HighConfidenceResult(),
}
result, err := mock.AnalyzePhotos(context.Background(), ai.IntakeRequest{
PhotosBase64: []string{"data:image/jpeg;base64,AAAA"},
JobID: "test-job-1",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Confidence != 0.95 {
t.Errorf("expected confidence 0.95, got %f", result.Confidence)
}
if len(mock.Calls) != 1 {
t.Errorf("expected 1 call recorded, got %d", len(mock.Calls))
}
}
func TestMockAIClientError(t *testing.T) {
mock := &ai.MockAIClient{
FixedError: errors.New("timeout"),
}
result, err := mock.AnalyzePhotos(context.Background(), ai.IntakeRequest{})
if err == nil {
t.Error("expected error, got nil")
}
if result != nil {
t.Errorf("expected nil result on error, got %+v", result)
}
}
func TestTierClientConstruction(t *testing.T) {
cfg := ai.TierConfig{
BaseURL: "http://localhost:9999/v1",
APIKey: "x",
Model: "m",
TimeoutSeconds: 1,
}
client := ai.NewTierClient(cfg)
if client == nil {
t.Fatal("expected non-nil TierClient")
}
// AnalyzePhotos should return a non-nil error (connection refused) — not panic
_, err := client.AnalyzePhotos(context.Background(), ai.IntakeRequest{
PhotosBase64: []string{"data:image/jpeg;base64,AAAA"},
JobID: "test-job-2",
})
if err == nil {
t.Error("expected connection refused error, got nil")
}
}

38
internal/ai/mock.go Normal file
View file

@ -0,0 +1,38 @@
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",
}
}

View file

@ -0,0 +1,21 @@
package prompts
import "fmt"
// BuildIntakePrompt returns the vision prompt instructing the model to return
// structured JSON for hardware analysis. photoCount is 1-3.
func BuildIntakePrompt(photoCount int) string {
return fmt.Sprintf(`You are a hardware inventory assistant. Analyze the %d hardware photo(s) provided and return ONLY valid JSON matching this exact schema. Do not include markdown, code fences, or explanations return only the raw JSON object.
{
"serial_number": "<string — exact serial number visible on label, or empty string if not visible>",
"model": "<string — product model name>",
"manufacturer": "<string — manufacturer/brand name>",
"category": "<one of: compute, networking, storage, cable, peripheral, component, unknown>",
"specs": {"<spec_key>": "<spec_value>"},
"suggested_tags": ["<tag1>", "<tag2>"],
"ai_notes": "<free-form observations about condition, notable features, or ambiguities>",
"confidence": <float between 0.0 and 1.0 your confidence in the identification>,
"confidence_note": "<reason why confidence is below 0.75, or empty string if confidence >= 0.75>"
}`, photoCount)
}

38
internal/ai/types.go Normal file
View file

@ -0,0 +1,38 @@
package ai
// IntakeRequest carries 1-3 photos (base64-encoded data URLs) for AI analysis.
type IntakeRequest struct {
PhotosBase64 []string // "data:image/jpeg;base64,..."
JobID string // UUID for tracing
}
// IntakeResult is the structured output from any AI tier's photo analysis.
// The model is instructed to return this JSON shape verbatim.
type IntakeResult struct {
SerialNumber string `json:"serial_number"`
Model string `json:"model"`
Manufacturer string `json:"manufacturer"`
Category string `json:"category"` // compute | networking | storage | cable | peripheral | component | unknown
Specs map[string]string `json:"specs"`
SuggestedTags []string `json:"suggested_tags"`
AINotes string `json:"ai_notes"`
Confidence float64 `json:"confidence"` // 0.01.0 self-reported
ConfidenceNote string `json:"confidence_note"` // reason if < threshold
}
// TierConfig holds provider configuration for one AI tier.
type TierConfig struct {
BaseURL string `json:"base_url" mapstructure:"base_url"`
APIKey string `json:"api_key" mapstructure:"api_key"`
Model string `json:"model" mapstructure:"model"`
TimeoutSeconds int `json:"timeout_seconds" mapstructure:"timeout_seconds"`
}
// AIConfig holds all AI tier configurations and orchestration settings.
type AIConfig struct {
Tier1 TierConfig `json:"tier1" mapstructure:"tier1"`
Tier2 TierConfig `json:"tier2" mapstructure:"tier2"`
ConfidenceThreshold float64 `json:"confidence_threshold" mapstructure:"confidence_threshold"`
QuickAddEnabled bool `json:"quick_add_enabled" mapstructure:"quick_add_enabled"`
QuickAddThreshold float64 `json:"quick_add_threshold" mapstructure:"quick_add_threshold"`
}

View file

@ -5,6 +5,8 @@ import (
"github.com/joho/godotenv"
"github.com/spf13/viper"
"git.georgsen.dk/hwlab/internal/ai"
)
type Config struct {
@ -21,6 +23,8 @@ type Config struct {
WAQMaxAttempts int `mapstructure:"waq_max_attempts"`
QualityGateConfidenceThreshold float64 `mapstructure:"quality_gate_confidence_threshold"`
AI ai.AIConfig `mapstructure:"ai"`
}
func Load() (*Config, error) {
@ -38,6 +42,19 @@ func Load() (*Config, error) {
v.SetDefault("waq_max_attempts", 5)
v.SetDefault("quality_gate_confidence_threshold", 0.75)
// AI tier defaults
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)
// Config file
v.SetConfigName("config")
v.SetConfigType("json")
@ -49,7 +66,6 @@ func Load() (*Config, error) {
v.AutomaticEnv()
// Explicit bindings ensure AutomaticEnv works correctly with mapstructure v2 unmarshalling.
// Format: key name -> env var (without prefix, viper adds HWLAB_ automatically).
_ = v.BindEnv("host", "HWLAB_HOST")
_ = v.BindEnv("port", "HWLAB_PORT")
_ = v.BindEnv("log_level", "HWLAB_LOG_LEVEL")
@ -61,13 +77,37 @@ func Load() (*Config, error) {
_ = v.BindEnv("waq_max_attempts", "HWLAB_WAQ_MAX_ATTEMPTS")
_ = v.BindEnv("quality_gate_confidence_threshold", "HWLAB_QUALITY_GATE_CONFIDENCE_THRESHOLD")
// Read config file (non-fatal if missing)
// AI env bindings
_ = 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")
// Read primary config file (non-fatal if missing)
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("config file: %w", err)
}
}
// Optionally merge ai_config.json as an override (separate viper instance to avoid
// conflicts with the primary config file name).
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 present — merge AI section into main viper
if err := v.MergeConfigMap(v2.AllSettings()); err != nil {
return nil, fmt.Errorf("merge ai_config: %w", err)
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)

View file

@ -7,6 +7,31 @@ import (
"git.georgsen.dk/hwlab/internal/config"
)
func TestAIConfigDefaults(t *testing.T) {
// Unset any AI env overrides that might interfere
os.Unsetenv("HWLAB_AI_TIER1_BASE_URL")
os.Unsetenv("HWLAB_AI_TIER1_MODEL")
os.Unsetenv("HWLAB_AI_CONFIDENCE_THRESHOLD")
os.Unsetenv("HWLAB_AI_QUICK_ADD_ENABLED")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.AI.Tier1.BaseURL != "http://localhost:8000/v1" {
t.Errorf("AI.Tier1.BaseURL: want http://localhost:8000/v1, got %q", cfg.AI.Tier1.BaseURL)
}
if cfg.AI.Tier1.Model != "gemma-4-e4b" {
t.Errorf("AI.Tier1.Model: want gemma-4-e4b, got %q", cfg.AI.Tier1.Model)
}
if cfg.AI.ConfidenceThreshold != 0.75 {
t.Errorf("AI.ConfidenceThreshold: want 0.75, got %f", cfg.AI.ConfidenceThreshold)
}
if cfg.AI.QuickAddEnabled != false {
t.Errorf("AI.QuickAddEnabled: want false, got %v", cfg.AI.QuickAddEnabled)
}
}
func TestLoadDefaults(t *testing.T) {
// Unset env vars that might interfere
os.Unsetenv("HWLAB_PORT")