// Package config loads the addrkit configuration from a JSON file, expanding // ${VAR} placeholders from the environment. It fails closed: missing required // values are an error, never a silent default. package config import ( "encoding/json" "fmt" "os" "regexp" "strings" ) // Config is the whole service configuration. type Config struct { // Addr is the listen address of the HTTP server, e.g. ":6171". Addr string `json:"addr"` // DataPath is the SQLite file produced by the seed pipeline. DataPath string `json:"data_path"` // LogLevel is one of debug|info|warn|error. LogLevel string `json:"log_level"` Resolve ResolveConfig `json:"resolve"` Portal PortalConfig `json:"portal"` LLM LLMConfig `json:"llm"` } // ResolveConfig tunes the matching engine. type ResolveConfig struct { FuzzyThreshold float64 `json:"fuzzy_threshold"` AmbiguityMargin float64 `json:"ambiguity_margin"` MaxCandidates int `json:"max_candidates"` } // PortalConfig configures the external outages portal (ArcGIS). When URL is // empty the "events by house" module is disabled and only /v1/resolve works. type PortalConfig struct { URL string `json:"url"` TimeoutSeconds int `json:"timeout_seconds"` Retries int `json:"retries"` } // LLMConfig configures the optional, disabled-by-default query rewriter. type LLMConfig struct { Enabled bool `json:"enabled"` URL string `json:"url"` Token string `json:"token"` Model string `json:"model"` TimeoutSeconds int `json:"timeout_seconds"` // MaxTokens bounds the rewriter's reply. Thinking models need enough room // for the final JSON even when thinking is disabled. MaxTokens int `json:"max_tokens"` // DisableThinking turns off chain-of-thought for models that support it // (Qwen3 via chat_template_kwargs). A rewriter only needs the JSON answer. DisableThinking bool `json:"disable_thinking"` // SystemPrompt overrides the built-in rewriter prompt inline. When empty, // the prompt embedded in the binary is used. SystemPrompt string `json:"system_prompt"` // SystemPromptFile loads the rewriter prompt from a file. When set it takes // precedence over SystemPrompt, which keeps long prompts out of the config. SystemPromptFile string `json:"system_prompt_file"` } // Default returns a config with sane defaults for the non-required fields. func Default() Config { return Config{ Addr: ":6171", LogLevel: "info", Resolve: ResolveConfig{ FuzzyThreshold: 0.82, AmbiguityMargin: 0.03, MaxCandidates: 5, }, Portal: PortalConfig{ TimeoutSeconds: 2, Retries: 1, }, LLM: LLMConfig{ TimeoutSeconds: 3, MaxTokens: 512, DisableThinking: true, }, } } // Load reads, expands and validates a config file. func Load(path string) (Config, error) { raw, err := os.ReadFile(path) if err != nil { return Config{}, fmt.Errorf("read config: %w", err) } cfg := Default() dec := json.NewDecoder(strings.NewReader(expandEnv(string(raw)))) if err := dec.Decode(&cfg); err != nil { return Config{}, fmt.Errorf("parse config: %w", err) } if cfg.LLM.SystemPromptFile != "" { data, err := os.ReadFile(cfg.LLM.SystemPromptFile) if err != nil { return Config{}, fmt.Errorf("read llm.system_prompt_file: %w", err) } cfg.LLM.SystemPrompt = string(data) } if err := cfg.validate(); err != nil { return Config{}, err } return cfg, nil } func (c Config) validate() error { if c.Addr == "" { return fmt.Errorf("config: addr is required") } if c.DataPath == "" { return fmt.Errorf("config: data_path is required") } if c.LLM.Enabled && c.LLM.URL == "" { return fmt.Errorf("config: llm.url is required when llm.enabled is true") } // portal.url is optional: without it the "events by house" module is // disabled and only /v1/resolve is served. return nil } // envRef matches ${VAR} and $VAR placeholders. var envRef = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`) // expandEnv replaces ${VAR} / $VAR with the environment value. An unset // variable expands to the empty string; validation then rejects configs that // relied on it (fail closed). func expandEnv(s string) string { return envRef.ReplaceAllStringFunc(s, func(m string) string { sub := envRef.FindStringSubmatch(m) name := sub[1] if name == "" { name = sub[2] } return os.Getenv(name) }) }