// 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 ( "bytes" "encoding/json" "fmt" "os" "regexp" "strings" "git.totmin.ru/en2zmax/addrkit/internal/domain/address" "git.totmin.ru/en2zmax/addrkit/internal/domain/match" ) // 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"` // Cities is the dictionary of cities this deployment understands. A // single-city deployment lists one city and may set DefaultCity so that // queries like "ленина 5" work without naming it. Cities []address.City `json:"cities"` DefaultCity string `json:"default_city"` LLM LLMConfig `json:"llm"` } // ResolveConfig tunes the matching engine. Defaults come from // match.DefaultOptions so there is exactly one source of truth. type ResolveConfig struct { FuzzyThreshold float64 `json:"fuzzy_threshold"` AmbiguityMargin float64 `json:"ambiguity_margin"` MaxCandidates int `json:"max_candidates"` } // 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 { resolve := match.DefaultOptions() return Config{ Addr: ":6171", LogLevel: "info", Resolve: ResolveConfig{ FuzzyThreshold: resolve.FuzzyThreshold, AmbiguityMargin: resolve.AmbiguityMargin, MaxCandidates: resolve.MaxCandidates, }, 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)))) // A typo in a key (fuzzy_treshold, data_pth, portal, ...) must not silently // fall back to a default. Fail closed and let the operator fix the config. dec.DisallowUnknownFields() 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 } // LoadCitiesFile reads a standalone JSON array of cities. It is used by the // seed CLI so that seeding and serving share the same city vocabulary. func LoadCitiesFile(path string) ([]address.City, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read cities: %w", err) } var cities []address.City dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&cities); err != nil { return nil, fmt.Errorf("parse cities: %w", err) } if err := validateCities(cities, ""); err != nil { return nil, err } return cities, 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.Resolve.FuzzyThreshold <= 0 || c.Resolve.FuzzyThreshold > 1 { return fmt.Errorf("config: resolve.fuzzy_threshold must be in (0, 1]") } if c.Resolve.AmbiguityMargin < 0 || c.Resolve.AmbiguityMargin > 1 { return fmt.Errorf("config: resolve.ambiguity_margin must be in [0, 1]") } if c.Resolve.MaxCandidates < 1 { return fmt.Errorf("config: resolve.max_candidates must be at least 1") } if c.LLM.Enabled && c.LLM.URL == "" { return fmt.Errorf("config: llm.url is required when llm.enabled is true") } return validateCities(c.Cities, c.DefaultCity) } // validateCities checks the city dictionary for the mistakes that would make // matching silently ambiguous: empty names, duplicate cities and an alias // claimed by two different cities. func validateCities(cities []address.City, defaultCity string) error { seenNames := make(map[string]string) seenAliases := make(map[string]string) for _, city := range cities { name := strings.TrimSpace(city.Name) key := address.NormalizeText(name) if key == "" { return fmt.Errorf("config: cities[].name must not be empty") } if prev, ok := seenNames[key]; ok { return fmt.Errorf("config: duplicate city %q and %q", prev, name) } seenNames[key] = name aliases := append([]string{name}, city.Aliases...) for _, alias := range aliases { norm := address.NormalizeText(alias) if norm == "" { continue } if owner, ok := seenAliases[norm]; ok && owner != key { return fmt.Errorf("config: alias %q is claimed by both %q and %q", alias, owner, key) } seenAliases[norm] = key } } if defaultCity = strings.TrimSpace(defaultCity); defaultCity != "" && len(cities) > 0 { if _, ok := seenNames[address.NormalizeText(defaultCity)]; !ok { return fmt.Errorf("config: default_city %q is not listed in cities", defaultCity) } } 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) }) }