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:
parent
6040ecc3cc
commit
8c03780230
11 changed files with 361 additions and 10 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
19
ai_config.json
Normal 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
9
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
|
||||
)
|
||||
|
|
|
|||
11
go.sum
11
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=
|
||||
|
|
|
|||
98
internal/ai/client.go
Normal file
98
internal/ai/client.go
Normal 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)
|
||||
}
|
||||
65
internal/ai/client_test.go
Normal file
65
internal/ai/client_test.go
Normal 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
38
internal/ai/mock.go
Normal 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",
|
||||
}
|
||||
}
|
||||
21
internal/ai/prompts/intake.go
Normal file
21
internal/ai/prompts/intake.go
Normal 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
38
internal/ai/types.go
Normal 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.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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue