homelabby/internal/config/config.go
Mikkel Georgsen 8c03780230 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
2026-04-10 05:45:13 +00:00

117 lines
4 KiB
Go

package config
import (
"fmt"
"github.com/joho/godotenv"
"github.com/spf13/viper"
"git.georgsen.dk/hwlab/internal/ai"
)
type Config struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
LogLevel string `mapstructure:"log_level"`
NetBoxURL string `mapstructure:"netbox_url"`
NetBoxToken string `mapstructure:"netbox_token"`
NetBoxTimeoutSeconds int `mapstructure:"netbox_timeout_seconds"`
DragonflyURL string `mapstructure:"dragonfly_url"`
WAQRetryIntervalSeconds int `mapstructure:"waq_retry_interval_seconds"`
WAQMaxAttempts int `mapstructure:"waq_max_attempts"`
QualityGateConfidenceThreshold float64 `mapstructure:"quality_gate_confidence_threshold"`
AI ai.AIConfig `mapstructure:"ai"`
}
func Load() (*Config, error) {
// Load .env file if present (ignore error — .env is optional in production)
_ = godotenv.Load()
v := viper.New()
// Set defaults
v.SetDefault("host", "0.0.0.0")
v.SetDefault("port", 8080)
v.SetDefault("log_level", "info")
v.SetDefault("netbox_timeout_seconds", 10)
v.SetDefault("waq_retry_interval_seconds", 30)
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")
v.AddConfigPath(".")
v.AddConfigPath("/etc/hwlab")
// Environment variables: HWLAB_PORT -> port, HWLAB_NETBOX_URL -> netbox_url, etc.
v.SetEnvPrefix("HWLAB")
v.AutomaticEnv()
// Explicit bindings ensure AutomaticEnv works correctly with mapstructure v2 unmarshalling.
_ = v.BindEnv("host", "HWLAB_HOST")
_ = v.BindEnv("port", "HWLAB_PORT")
_ = v.BindEnv("log_level", "HWLAB_LOG_LEVEL")
_ = v.BindEnv("netbox_url", "HWLAB_NETBOX_URL")
_ = v.BindEnv("netbox_token", "HWLAB_NETBOX_TOKEN")
_ = v.BindEnv("netbox_timeout_seconds", "HWLAB_NETBOX_TIMEOUT_SECONDS")
_ = v.BindEnv("dragonfly_url", "HWLAB_DRAGONFLY_URL")
_ = v.BindEnv("waq_retry_interval_seconds", "HWLAB_WAQ_RETRY_INTERVAL_SECONDS")
_ = v.BindEnv("waq_max_attempts", "HWLAB_WAQ_MAX_ATTEMPTS")
_ = v.BindEnv("quality_gate_confidence_threshold", "HWLAB_QUALITY_GATE_CONFIDENCE_THRESHOLD")
// 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)
}
return &cfg, nil
}