// ©AngelaMos | 2026 // config.go package config import ( "fmt" "os" "time" "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 defaultLookbackHours = 168 trackingUTMPrefix = "utm_*" defaultHalfLifeHours = 48 defaultVelocityNorm = 0.5 defaultWeightRecency = 0.30 defaultWeightVelocity = 0.20 defaultWeightKEV = 0.12 defaultWeightCVSS = 0.10 defaultWeightSource = 0.10 defaultWeightKeyword = 0.10 defaultWeightEPSS = 0.08 defaultAIProvider = "qwen" defaultQwenBaseURL = "http://localhost:39847/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-sonnet-4-6" defaultWatchInterval = "1h" defaultWatchNotifyMinScore = 0.5 defaultWatchNotifyMax = 10 MinWatchInterval = time.Minute ) 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"` NVDAPIKey string `yaml:"nvd_api_key"` } type Cluster struct { TitleJaccard float64 `yaml:"title_jaccard_threshold"` WindowHours int `yaml:"window_hours"` LookbackHours int `yaml:"lookback_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 Watch struct { Interval string `yaml:"interval"` WebhookURL string `yaml:"webhook_url"` NotifyMinScore float64 `yaml:"notify_min_score"` NotifyMaxItems int `yaml:"notify_max_items"` NotifyOnKEV bool `yaml:"notify_on_kev"` } 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"` Watch Watch `yaml:"watch"` } 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, LookbackHours: defaultLookbackHours, 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}, }, Watch: Watch{ Interval: defaultWatchInterval, NotifyMinScore: defaultWatchNotifyMinScore, NotifyMaxItems: defaultWatchNotifyMax, NotifyOnKEV: true, }, } } 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.Cluster.WindowHours < 1 { return fmt.Errorf("config: cluster.window_hours must be >= 1, got %d", c.Cluster.WindowHours) } if c.Cluster.LookbackHours < 1 { return fmt.Errorf("config: cluster.lookback_hours must be >= 1, got %d", c.Cluster.LookbackHours) } if c.Cluster.LookbackHours < c.Cluster.WindowHours { return fmt.Errorf("config: cluster.lookback_hours (%d) must be >= cluster.window_hours (%d) or window edges near the corpus boundary are silently dropped", c.Cluster.LookbackHours, c.Cluster.WindowHours) } 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) } d, err := time.ParseDuration(c.Watch.Interval) if err != nil { return fmt.Errorf("config: watch.interval %q is not a valid duration: %w", c.Watch.Interval, err) } if d < MinWatchInterval { return fmt.Errorf("config: watch.interval must be >= %s, got %s", MinWatchInterval, c.Watch.Interval) } if c.Watch.NotifyMinScore < 0 || c.Watch.NotifyMinScore > 1 { return fmt.Errorf("config: watch.notify_min_score must be in [0,1], got %v", c.Watch.NotifyMinScore) } if c.Watch.NotifyMaxItems < 1 { return fmt.Errorf("config: watch.notify_max_items must be >= 1, got %d", c.Watch.NotifyMaxItems) } return nil }