all repos — llm_aggregator @ 6e3abd0665fe86906baf2db7652595d393f2ffc3

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
 11// Config holds the application configuration loaded from TOML file, environment variables, and CLI arguments.
 12type Config struct {
 13	// Feed aggregation options
 14	MaxArticlesPerFeed int    `mapstructure:"max_articles_per_feed"`
 15	MaxDaysOld         int    `mapstructure:"max_days_old"`
 16	MaxTotalArticles   int    `mapstructure:"max_total_articles"`
 17	IncludeKeywords    string `mapstructure:"include_keywords"`
 18	ExcludeKeywords    string `mapstructure:"exclude_keywords"`
 19
 20	// Deepseek API options
 21	APIKey      string  `mapstructure:"api_key"`
 22	Model       string  `mapstructure:"model"`
 23	MaxTokens   int     `mapstructure:"max_tokens"`
 24	Temperature float64 `mapstructure:"temperature"`
 25
 26	// System prompt
 27	SystemPrompt string `mapstructure:"system_prompt"`
 28
 29	// Output options
 30	Output          string `mapstructure:"output"`
 31	OutputFile      string `mapstructure:"output_file"`
 32	IncludeArticles bool   `mapstructure:"include_articles"`
 33
 34	// Internal state
 35	loaded bool
 36}
 37
 38// DefaultConfig returns a Config with sensible defaults.
 39func DefaultConfig() *Config {
 40	return &Config{
 41		MaxArticlesPerFeed: 10,
 42		MaxDaysOld:         7,
 43		MaxTotalArticles:   20,
 44		Model:              "deepseek-chat",
 45		MaxTokens:          4000,
 46		Temperature:        0.7,
 47		SystemPrompt: `You are an expert analyst and summariser.
 48You analyse content from multiple sources and provide
 49concise, insightful summaries based on user requests.
 50Focus on key points, trends, and important information.`,
 51		Output:          "text",
 52		IncludeArticles: false,
 53	}
 54}
 55
 56// Load loads configuration from TOML file, environment variables, and sets defaults.
 57// The precedence order is: CLI args > Environment variables > Config file > Defaults.
 58func Load() (*Config, error) {
 59	config := DefaultConfig()
 60
 61	// Initialise Viper
 62	v := viper.New()
 63	v.SetConfigName("config")
 64	v.SetConfigType("toml")
 65
 66	// Set environment variable prefix first
 67	v.SetEnvPrefix("LLM_AGGREGATOR")
 68	v.AutomaticEnv() // Read from environment variables
 69
 70	// Bind environment variables to config fields
 71	bindEnvVars(v)
 72
 73	// Get config path from XDG
 74	configPath, err := GetConfigPath()
 75	if err != nil {
 76		return nil, fmt.Errorf("failed to get config path: %w", err)
 77	}
 78
 79	// Set config file path
 80	v.AddConfigPath(filepath.Dir(configPath))
 81
 82	// Try to read config file
 83	if err := v.ReadInConfig(); err != nil {
 84		// If config file doesn't exist, that's OK - we'll use defaults + env vars
 85		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
 86			return nil, fmt.Errorf("error reading config file: %w", err)
 87		}
 88	} else {
 89		// Config file was found
 90		config.loaded = true
 91	}
 92
 93	// Unmarshal config into struct
 94	if err := v.Unmarshal(config); err != nil {
 95		return nil, fmt.Errorf("error unmarshalling config: %w", err)
 96	}
 97
 98	return config, nil
 99}
100
101// bindEnvVars binds environment variables to viper keys.
102func bindEnvVars(v *viper.Viper) {
103	// Feed aggregation options
104	v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED")
105	v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD")
106	v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES")
107	v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS")
108	v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS")
109
110	// Deepseek API options
111	v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY")
112	v.BindEnv("model", "LLM_AGGREGATOR_MODEL")
113	v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
114	v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
115
116	// System prompt
117	v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
118
119	// Output options
120	v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
121	v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
122	v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
123}
124
125// GetConfigPath returns the path to the config file.
126func GetConfigPath() (string, error) {
127	configDir, err := os.UserConfigDir()
128	if err != nil {
129		return "", fmt.Errorf("failed to get user config directory: %w", err)
130	}
131	return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
132}
133
134// ConfigExists returns true if a config file exists.
135func ConfigExists() (bool, error) {
136	configPath, err := GetConfigPath()
137	if err != nil {
138		return false, err
139	}
140	_, err = os.Stat(configPath)
141	if err != nil {
142		if os.IsNotExist(err) {
143			return false, nil
144		}
145		return false, err
146	}
147	return true, nil
148}
149
150// Save saves the configuration to the TOML file.
151func (c *Config) Save() error {
152	v := viper.New()
153	v.SetConfigType("toml")
154
155	// Set values from config struct
156	v.Set("max_articles_per_feed", c.MaxArticlesPerFeed)
157	v.Set("max_days_old", c.MaxDaysOld)
158	v.Set("max_total_articles", c.MaxTotalArticles)
159	v.Set("include_keywords", c.IncludeKeywords)
160	v.Set("exclude_keywords", c.ExcludeKeywords)
161	v.Set("api_key", c.APIKey)
162	v.Set("model", c.Model)
163	v.Set("max_tokens", c.MaxTokens)
164	v.Set("temperature", c.Temperature)
165	v.Set("system_prompt", c.SystemPrompt)
166	v.Set("output", c.Output)
167	v.Set("output_file", c.OutputFile)
168	v.Set("include_articles", c.IncludeArticles)
169
170	// Get config path
171	configPath, err := GetConfigPath()
172	if err != nil {
173		return fmt.Errorf("failed to get config path: %w", err)
174	}
175
176	// Create directory if it doesn't exist
177	configDir := filepath.Dir(configPath)
178	if err := os.MkdirAll(configDir, 0755); err != nil {
179		return fmt.Errorf("failed to create config directory: %w", err)
180	}
181
182	// Write config file
183	if err := v.WriteConfigAs(configPath); err != nil {
184		return fmt.Errorf("failed to write config file: %w", err)
185	}
186
187	return nil
188}
189
190// IsLoaded returns true if the configuration was loaded from a file.
191func (c *Config) IsLoaded() bool {
192	return c.loaded
193}