all repos — llm_aggregator @ 262b91134ac16b77d435b706d54680bf6cff5de6

A CLI tool to aggregate RSS feeds and summarise them with LLMs

internal/config/config.go (view raw)

  1package config
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7
  8	"github.com/spf13/viper"
  9
 10	"llm_aggregator/internal/cli"
 11	"llm_aggregator/internal/progress"
 12	"llm_aggregator/internal/runtime"
 13)
 14
 15// Config holds the application configuration loaded from TOML file, environment variables, and CLI arguments.
 16type Config struct {
 17	// Feed aggregation options
 18	MaxArticlesPerFeed int    `mapstructure:"max_articles_per_feed"`
 19	MaxDaysOld         int    `mapstructure:"max_days_old"`
 20	MaxTotalArticles   int    `mapstructure:"max_total_articles"`
 21	IncludeKeywords    string `mapstructure:"include_keywords"`
 22	ExcludeKeywords    string `mapstructure:"exclude_keywords"`
 23
 24	// LLM API options
 25	APIKey      string  `mapstructure:"api_key"`
 26	Model       string  `mapstructure:"model"`
 27	MaxTokens   int     `mapstructure:"max_tokens"`
 28	Temperature float64 `mapstructure:"temperature"`
 29
 30	// System prompt
 31	SystemPrompt string `mapstructure:"system_prompt"`
 32
 33	// Output options
 34	Output          string `mapstructure:"output"`
 35	OutputFile      string `mapstructure:"output_file"`
 36	IncludeArticles bool   `mapstructure:"include_articles"`
 37}
 38
 39// DefaultConfig returns a Config with sensible defaults.
 40func DefaultConfig() *Config {
 41	return &Config{
 42		MaxArticlesPerFeed: 10,
 43		MaxDaysOld:         7,
 44		MaxTotalArticles:   20,
 45		Model:              "deepseek-chat",
 46		MaxTokens:          4000,
 47		Temperature:        0.7,
 48		SystemPrompt: `You are an expert analyst and summariser.
 49You analyse content from multiple sources and provide
 50concise, insightful summaries based on user requests.
 51Focus on key points, trends, and important information.`,
 52		Output:          "text",
 53		IncludeArticles: false,
 54	}
 55}
 56
 57// Load loads configuration from TOML file, environment variables, and sets defaults.
 58// The precedence order is: CLI args > Environment variables > Config file > Defaults.
 59func Load() (*Config, error) {
 60	// Get the global viper instance which handles precedence automatically
 61	v := GetViper()
 62
 63	// Return the current configuration as a Config struct
 64	return ViperToConfig(v), nil
 65}
 66
 67// GetViper returns the global viper instance with all configuration sources set up.
 68// The precedence order is: CLI flags > Environment variables > Config file > Defaults.
 69func GetViper() *viper.Viper {
 70	// Set defaults first (lowest priority)
 71	setDefaults()
 72
 73	// Initialise or get existing Viper instance
 74	v := viper.GetViper()
 75
 76	// Ensure defaults are set (for fresh instances)
 77	setDefaultsOn(v)
 78
 79	// Set environment variable prefix
 80	v.SetEnvPrefix("LLM_AGGREGATOR")
 81	v.AutomaticEnv()
 82
 83	// Bind environment variables to config fields
 84	bindEnvVars(v)
 85
 86	// Get config path from XDG
 87	configPath, err := GetConfigPath()
 88	if err == nil {
 89		// Set config file path
 90		v.SetConfigFile(configPath)
 91
 92		// Try to read config file
 93		if err := v.ReadInConfig(); err != nil {
 94			// If config file doesn't exist, that's OK - we'll use defaults + env vars
 95			if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
 96				fmt.Fprintf(os.Stderr, "Warning: error reading config file: %v\n", err)
 97			}
 98		}
 99	}
100
101	return v
102}
103
104// setDefaults sets default values on the global viper instance.
105func setDefaults() {
106	v := viper.GetViper()
107	setDefaultsOn(v)
108}
109
110// setDefaultsOn sets default values on the given viper instance.
111func setDefaultsOn(v *viper.Viper) {
112	// Feed aggregation defaults
113	v.SetDefault("max_articles_per_feed", 10)
114	v.SetDefault("max_days_old", 7)
115	v.SetDefault("max_total_articles", 20)
116
117	// LLM API defaults
118	v.SetDefault("model", "deepseek-chat")
119	v.SetDefault("max_tokens", 4000)
120	v.SetDefault("temperature", 0.7)
121
122	// System prompt default
123	v.SetDefault("system_prompt", `You are an expert analyst and summariser.
124You analyse content from multiple sources and provide
125concise, insightful summaries based on user requests.
126Focus on key points, trends, and important information.`)
127
128	// Output defaults
129	v.SetDefault("output", "text")
130	v.SetDefault("include_articles", false)
131}
132
133// ViperToConfig converts viper configuration to a Config struct.
134func ViperToConfig(v *viper.Viper) *Config {
135	return &Config{
136		MaxArticlesPerFeed: v.GetInt("max_articles_per_feed"),
137		MaxDaysOld:         v.GetInt("max_days_old"),
138		MaxTotalArticles:   v.GetInt("max_total_articles"),
139		IncludeKeywords:    v.GetString("include_keywords"),
140		ExcludeKeywords:    v.GetString("exclude_keywords"),
141		APIKey:             v.GetString("api_key"),
142		Model:              v.GetString("model"),
143		MaxTokens:          v.GetInt("max_tokens"),
144		Temperature:        v.GetFloat64("temperature"),
145		SystemPrompt:       v.GetString("system_prompt"),
146		Output:             v.GetString("output"),
147		OutputFile:         v.GetString("output_file"),
148		IncludeArticles:    v.GetBool("include_articles"),
149	}
150}
151
152// BindCLIArgs binds CLI argument values to viper with highest precedence.
153// This should be called after parsing CLI arguments to override config file/env defaults.
154func BindCLIArgs(v *viper.Viper, args map[string]any) {
155	for key, value := range args {
156		if value != nil && !isZero(value) {
157			v.Set(key, value)
158		}
159	}
160}
161
162// isZero checks if a value is the zero value for its type.
163func isZero(v any) bool {
164	switch val := v.(type) {
165	case string:
166		return val == ""
167	case int, int8, int16, int32, int64:
168		return val == 0
169	case float32, float64:
170		return val == 0
171	case bool:
172		return !val
173	default:
174		return false
175	}
176}
177
178// ViperToRuntime converts viper configuration directly to runtime.Runtime.
179func ViperToRuntime(v *viper.Viper, feedsFile, prompt string) *runtime.Runtime {
180	rt := &runtime.Runtime{
181		FeedsFile: feedsFile,
182		Prompt:    prompt,
183		Progress:  &progress.NoopLogger{},
184	}
185
186	// Feed aggregation options
187	rt.MaxArticlesPerFeed = v.GetInt("max_articles_per_feed")
188	rt.MaxDaysOld = v.GetInt("max_days_old")
189	rt.MaxTotalArticles = v.GetInt("max_total_articles")
190
191	// Keywords
192	includeKeywords := v.GetString("include_keywords")
193	excludeKeywords := v.GetString("exclude_keywords")
194	if includeKeywords != "" {
195		rt.IncludeKeywords = cli.ParseKeywords(includeKeywords)
196	}
197	if excludeKeywords != "" {
198		rt.ExcludeKeywords = cli.ParseKeywords(excludeKeywords)
199	}
200
201	// LLM API options
202	rt.APIKey = v.GetString("api_key")
203	rt.Model = v.GetString("model")
204	rt.MaxTokens = v.GetInt("max_tokens")
205	rt.Temperature = v.GetFloat64("temperature")
206
207	// System prompt
208	rt.SystemPrompt = v.GetString("system_prompt")
209
210	// Output options
211	rt.Output = v.GetString("output")
212	rt.OutputFile = v.GetString("output_file")
213	rt.IncludeArticles = v.GetBool("include_articles")
214
215	return rt
216}
217
218// bindEnvVars binds environment variables to viper keys.
219func bindEnvVars(v *viper.Viper) {
220	// Feed aggregation options
221	v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED")
222	v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD")
223	v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES")
224	v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS")
225	v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS")
226
227	// LLM API options
228	v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY")
229	v.BindEnv("model", "LLM_AGGREGATOR_MODEL")
230	v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
231	v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
232
233	// System prompt
234	v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
235
236	// Output options
237	v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
238	v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
239	v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
240}
241
242// GetConfigPath returns the path to the config file.
243func GetConfigPath() (string, error) {
244	configDir, err := os.UserConfigDir()
245	if err != nil {
246		return "", fmt.Errorf("failed to get user config directory: %w", err)
247	}
248	return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
249}
250
251// ConfigExists returns true if a config file exists.
252func ConfigExists() (bool, error) {
253	configPath, err := GetConfigPath()
254	if err != nil {
255		return false, err
256	}
257	_, err = os.Stat(configPath)
258	if err != nil {
259		if os.IsNotExist(err) {
260			return false, nil
261		}
262		return false, err
263	}
264	return true, nil
265}
266
267// Save saves the configuration to the TOML file.
268func (c *Config) Save() error {
269	v := viper.New()
270	v.SetConfigType("toml")
271
272	// Set values from config struct
273	v.Set("max_articles_per_feed", c.MaxArticlesPerFeed)
274	v.Set("max_days_old", c.MaxDaysOld)
275	v.Set("max_total_articles", c.MaxTotalArticles)
276	v.Set("include_keywords", c.IncludeKeywords)
277	v.Set("exclude_keywords", c.ExcludeKeywords)
278	v.Set("api_key", c.APIKey)
279	v.Set("model", c.Model)
280	v.Set("max_tokens", c.MaxTokens)
281	v.Set("temperature", c.Temperature)
282	v.Set("system_prompt", c.SystemPrompt)
283	v.Set("output", c.Output)
284	v.Set("output_file", c.OutputFile)
285	v.Set("include_articles", c.IncludeArticles)
286
287	// Get config path
288	configPath, err := GetConfigPath()
289	if err != nil {
290		return fmt.Errorf("failed to get config path: %w", err)
291	}
292
293	// Create directory if it doesn't exist
294	configDir := filepath.Dir(configPath)
295	if err := os.MkdirAll(configDir, 0755); err != nil {
296		return fmt.Errorf("failed to create config directory: %w", err)
297	}
298
299	// Write config file
300	if err := v.WriteConfigAs(configPath); err != nil {
301		return fmt.Errorf("failed to write config file: %w", err)
302	}
303
304	return nil
305}
306
307// IsLoaded returns true if the configuration was loaded from a file.
308func (c *Config) IsLoaded() bool {
309	// Check if config file exists - if it does, values would have been loaded from it
310	configPath, err := GetConfigPath()
311	if err != nil {
312		return false
313	}
314	_, err = os.Stat(configPath)
315	return err == nil
316}