// ©AngelaMos | 2026 // config.go package config import ( "fmt" "os" "gopkg.in/yaml.v3" ) const ( defaultDBPath = "nadezhda.db" defaultUserAgent = "nadezhda/0.1 (+https://github.com/CarterPerez-dev/nadezhda)" defaultPerHostRate = 0.5 defaultPerHostBurst = 1 defaultTimeoutSeconds = 25 defaultSourceTimeoutSeconds = 90 defaultWorkers = 8 defaultMaxRetries = 3 defaultCacheTTLHours = 24 defaultNegativeTTLHours = 3 defaultTitleJaccard = 0.6 defaultWindowHours = 72 trackingUTMPrefix = "utm_*" defaultHalfLifeHours = 48 defaultVelocityNorm = 0.5 defaultWeightRecency = 0.20 defaultWeightCVSS = 0.15 defaultWeightKEV = 0.25 defaultWeightEPSS = 0.15 defaultWeightVelocity = 0.15 defaultWeightSource = 0.05 defaultWeightKeyword = 0.05 defaultAIProvider = "qwen" defaultQwenBaseURL = "http://localhost:11434/v1" defaultQwenModel = "qwen2.5:7b" defaultOpenAIURL = "https://api.openai.com/v1" defaultOpenAIModel = "gpt-4o-mini" defaultGeminiURL = "https://generativelanguage.googleapis.com/v1beta/openai/" defaultGeminiModel = "gemini-2.5-flash" defaultAnthropicURL = "https://api.anthropic.com/v1" defaultClaudeModel = "claude-opus-4-8" ) var defaultTrackingParams = []string{ trackingUTMPrefix, "gclid", "fbclid", "ref", "mc_cid", "mc_eid", } type Fetch struct { UserAgent string `yaml:"user_agent"` PerHostRate float64 `yaml:"per_host_rate"` PerHostBurst int `yaml:"per_host_burst"` TimeoutSeconds int `yaml:"timeout_seconds"` SourceTimeoutSeconds int `yaml:"source_timeout_seconds"` Workers int `yaml:"workers"` MaxRetries int `yaml:"max_retries"` } type Enrich struct { CacheTTLHours int `yaml:"cache_ttl_hours"` NegativeTTLHours int `yaml:"negative_ttl_hours"` } type Cluster struct { TitleJaccard float64 `yaml:"title_jaccard_threshold"` WindowHours int `yaml:"window_hours"` TrackingParams []string `yaml:"tracking_params"` } type Weights struct { Recency float64 `yaml:"recency"` CVSS float64 `yaml:"cvss"` KEV float64 `yaml:"kev"` EPSS float64 `yaml:"epss"` Velocity float64 `yaml:"velocity"` Source float64 `yaml:"source"` Keyword float64 `yaml:"keyword"` } type Rank struct { HalfLifeHours int `yaml:"half_life_hours"` VelocityNorm float64 `yaml:"velocity_norm"` Weights Weights `yaml:"weights"` } type Provider struct { BaseURL string `yaml:"base_url"` Model string `yaml:"model"` } type AI struct { Enabled bool `yaml:"enabled"` Provider string `yaml:"provider"` Qwen Provider `yaml:"qwen"` OpenAI Provider `yaml:"openai"` Gemini Provider `yaml:"gemini"` Anthropic Provider `yaml:"anthropic"` } type Config struct { DBPath string `yaml:"db_path"` SourcesPath string `yaml:"sources_path"` Watchlist []string `yaml:"watchlist"` Fetch Fetch `yaml:"fetch"` Enrich Enrich `yaml:"enrich"` Cluster Cluster `yaml:"cluster"` Rank Rank `yaml:"rank"` AI AI `yaml:"ai"` } func Default() Config { return Config{ DBPath: defaultDBPath, Fetch: Fetch{ UserAgent: defaultUserAgent, PerHostRate: defaultPerHostRate, PerHostBurst: defaultPerHostBurst, TimeoutSeconds: defaultTimeoutSeconds, SourceTimeoutSeconds: defaultSourceTimeoutSeconds, Workers: defaultWorkers, MaxRetries: defaultMaxRetries, }, Enrich: Enrich{ CacheTTLHours: defaultCacheTTLHours, NegativeTTLHours: defaultNegativeTTLHours, }, Cluster: Cluster{ TitleJaccard: defaultTitleJaccard, WindowHours: defaultWindowHours, TrackingParams: defaultTrackingParams, }, Rank: Rank{ HalfLifeHours: defaultHalfLifeHours, VelocityNorm: defaultVelocityNorm, Weights: Weights{ Recency: defaultWeightRecency, CVSS: defaultWeightCVSS, KEV: defaultWeightKEV, EPSS: defaultWeightEPSS, Velocity: defaultWeightVelocity, Source: defaultWeightSource, Keyword: defaultWeightKeyword, }, }, AI: AI{ Enabled: false, Provider: defaultAIProvider, Qwen: Provider{BaseURL: defaultQwenBaseURL, Model: defaultQwenModel}, OpenAI: Provider{BaseURL: defaultOpenAIURL, Model: defaultOpenAIModel}, Gemini: Provider{BaseURL: defaultGeminiURL, Model: defaultGeminiModel}, Anthropic: Provider{BaseURL: defaultAnthropicURL, Model: defaultClaudeModel}, }, } } func Load(path string) (Config, error) { cfg := Default() if path == "" { return cfg, nil } raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return cfg, nil } return Config{}, fmt.Errorf("read config %s: %w", path, err) } if err := yaml.Unmarshal(raw, &cfg); err != nil { return Config{}, fmt.Errorf("parse config %s: %w", path, err) } if err := cfg.validate(); err != nil { return Config{}, err } return cfg, nil } func (c Config) validate() error { if c.DBPath == "" { return fmt.Errorf("config: db_path must not be empty") } if c.Fetch.Workers < 1 { return fmt.Errorf("config: fetch.workers must be >= 1, got %d", c.Fetch.Workers) } if c.Fetch.PerHostRate <= 0 { return fmt.Errorf("config: fetch.per_host_rate must be > 0, got %v", c.Fetch.PerHostRate) } if c.Fetch.PerHostBurst < 1 { return fmt.Errorf("config: fetch.per_host_burst must be >= 1, got %d", c.Fetch.PerHostBurst) } if c.Fetch.TimeoutSeconds < 1 { return fmt.Errorf("config: fetch.timeout_seconds must be >= 1, got %d", c.Fetch.TimeoutSeconds) } if c.Fetch.SourceTimeoutSeconds < 1 { return fmt.Errorf("config: fetch.source_timeout_seconds must be >= 1, got %d", c.Fetch.SourceTimeoutSeconds) } if c.Enrich.CacheTTLHours < 0 || c.Enrich.NegativeTTLHours < 0 { return fmt.Errorf("config: enrich TTLs must be >= 0, got cache=%d negative=%d", c.Enrich.CacheTTLHours, c.Enrich.NegativeTTLHours) } if c.Cluster.TitleJaccard < 0 || c.Cluster.TitleJaccard > 1 { return fmt.Errorf("config: cluster.title_jaccard_threshold must be in [0,1], got %v", c.Cluster.TitleJaccard) } if c.Rank.HalfLifeHours < 1 { return fmt.Errorf("config: rank.half_life_hours must be >= 1, got %d", c.Rank.HalfLifeHours) } if c.Rank.VelocityNorm <= 0 { return fmt.Errorf("config: rank.velocity_norm must be > 0, got %v", c.Rank.VelocityNorm) } switch c.AI.Provider { case "qwen", "openai", "gemini", "anthropic": default: return fmt.Errorf("config: ai.provider must be one of qwen|openai|gemini|anthropic, got %q", c.AI.Provider) } return nil }