From 8c03780230b48ff8a750063e660365baa4760dcf Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 05:45:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(02-01):=20AI=20package=20foundation=20?= =?UTF-8?q?=E2=80=94=20types,=20interface,=20mock,=20prompts,=20config=20e?= =?UTF-8?q?xtension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 3 ++ ai_config.json | 19 +++++++ go.mod | 9 ++-- go.sum | 11 ++-- internal/ai/client.go | 98 ++++++++++++++++++++++++++++++++++ internal/ai/client_test.go | 65 ++++++++++++++++++++++ internal/ai/mock.go | 38 +++++++++++++ internal/ai/prompts/intake.go | 21 ++++++++ internal/ai/types.go | 38 +++++++++++++ internal/config/config.go | 44 ++++++++++++++- internal/config/config_test.go | 25 +++++++++ 11 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 ai_config.json create mode 100644 internal/ai/client.go create mode 100644 internal/ai/client_test.go create mode 100644 internal/ai/mock.go create mode 100644 internal/ai/prompts/intake.go create mode 100644 internal/ai/types.go diff --git a/.gitignore b/.gitignore index 4c49bd7..5a5559c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ai_config.json b/ai_config.json new file mode 100644 index 0000000..a17415a --- /dev/null +++ b/ai_config.json @@ -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 + } +} diff --git a/go.mod b/go.mod index f9200b2..610f5b0 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 4de2033..ec22579 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/ai/client.go b/internal/ai/client.go new file mode 100644 index 0000000..66a0190 --- /dev/null +++ b/internal/ai/client.go @@ -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) +} diff --git a/internal/ai/client_test.go b/internal/ai/client_test.go new file mode 100644 index 0000000..4309d38 --- /dev/null +++ b/internal/ai/client_test.go @@ -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") + } +} diff --git a/internal/ai/mock.go b/internal/ai/mock.go new file mode 100644 index 0000000..4a10551 --- /dev/null +++ b/internal/ai/mock.go @@ -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", + } +} diff --git a/internal/ai/prompts/intake.go b/internal/ai/prompts/intake.go new file mode 100644 index 0000000..cd7c41a --- /dev/null +++ b/internal/ai/prompts/intake.go @@ -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": "", + "model": "", + "manufacturer": "", + "category": "", + "specs": {"": ""}, + "suggested_tags": ["", ""], + "ai_notes": "", + "confidence": , + "confidence_note": "= 0.75>" +}`, photoCount) +} diff --git a/internal/ai/types.go b/internal/ai/types.go new file mode 100644 index 0000000..a4ad41c --- /dev/null +++ b/internal/ai/types.go @@ -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.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"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 4432719..3df7265 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1da0f4b..8d0c235 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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")