all repos — llm_aggregator @ 97214a9c4d505867b2fb898ce520469a6cec86c5

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

cmd/llm_aggregator.go (view raw)

  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"strconv"
  8
  9	tea "github.com/charmbracelet/bubbletea"
 10	"llm_aggregator/internal/aggregator"
 11	"llm_aggregator/internal/cli"
 12	"llm_aggregator/internal/config"
 13	"llm_aggregator/internal/processor"
 14	"llm_aggregator/internal/progress"
 15	"llm_aggregator/internal/runtime"
 16	"llm_aggregator/internal/signals"
 17	"llm_aggregator/internal/style"
 18	"llm_aggregator/internal/tui"
 19)
 20
 21var (
 22	version   string
 23	buildDate string
 24)
 25
 26func main() {
 27	cli.BuildDate = buildDate
 28	cli.Version = version
 29
 30	// Parse command line arguments
 31	args, err := cli.ParseArgs()
 32	if err != nil {
 33		fmt.Fprintln(os.Stderr, style.Errorf("parsing arguments: %v", err))
 34		os.Exit(1)
 35	}
 36
 37	// Set up signal handling before any blocking call.
 38	// SIGINT, SIGTERM, and SIGHUP are all treated the same — clean shutdown.
 39	sh := signals.New()
 40	sh.Watch()
 41
 42
 43	// Get viper instance with config file + environment variables + defaults
 44	v := config.GetViper()
 45
 46	// Bind CLI args to viper (highest precedence)
 47	config.BindCLIArgs(v, args.ToViperMap())
 48
 49	// Create runtime directly from viper configuration
 50	rt := config.ViperToRuntime(v, args.FeedsFile, args.Prompt)
 51
 52
 53	// Run dry-run mode if requested (validates config, shows statistics, no LLM calls)
 54	if args.DryRun {
 55		sh.Stop()
 56		runDryRun(rt, args.Verbose)
 57		os.Exit(0)
 58	}
 59
 60	// Validate API key (required for actual execution)
 61	if v.GetString("api_key") == "" {
 62		sh.Stop()
 63		fmt.Fprintln(os.Stderr, style.Errorf("OpenAI-compatible API key is required. Set via --api-key, %s environment variable, or config file.", "LLM_AGGREGATOR_API_KEY"))
 64		os.Exit(1)
 65	}
 66
 67	// Run with TUI if requested
 68	if args.TUI {
 69		sh.Stop()
 70		runWithTUI(rt)
 71	} else {
 72		runWithoutTUI(rt, args.Verbose, sh)
 73	}
 74}
 75
 76func runWithTUI(rt *runtime.Runtime) {
 77	// 1. Create the model and pass the runtime to it.
 78	model := tui.New(rt)
 79
 80	// 2. Create the tea.Program.
 81	p := tea.NewProgram(model, tea.WithAltScreen())
 82
 83	// 3. Create the TUIProgress handler and give it the program instance.
 84	//    This is the bridge that allows the runtime to send messages to the TUI.
 85	tp := tui.NewTUIProgress(p)
 86
 87	// 4. Inject the progress handler into the runtime.
 88	rt.Progress = tp
 89
 90	// 5. Run the program. This is a blocking call.
 91	if _, err := p.Run(); err != nil {
 92		fmt.Fprintln(os.Stderr, style.Errorf("TUI error: %v", err))
 93		os.Exit(1)
 94	}
 95}
 96
 97func runWithoutTUI(rt *runtime.Runtime, verbose bool, sh *signals.SignalHandler) {
 98	// Setup logger based on verbose flag and inject it into the runtime
 99	if verbose {
100		rt.Progress = progress.NewSimpleLogger(os.Stdout, true)
101	} else {
102		// Default is already NoopLogger, but we can be explicit
103		rt.Progress = &progress.NoopLogger{}
104	}
105
106	// Create a context that is cancelled when a signal arrives.
107	// signal.Notify disables default exit behaviour for SIGINT/SIGTERM/SIGHUP,
108	// so the program stays alive long enough to handle them.
109	ctx, cancel := context.WithCancel(context.Background())
110
111	// Monitor for signals and propagate into context cancellation.
112	// This goroutine lives until the Watch() goroutine exits (signalled via sh).
113	go func() {
114		// Poll IsExiting() — returns true as soon as the signal is received.
115		// No busy-waiting: the select in Watch() unblocks immediately on signal.
116		for {
117			if sh.IsExiting() {
118				cancel()
119				return
120			}
121		}
122	}()
123
124	// Execute the runtime
125	err := rt.Execute(ctx)
126	if sh.IsExiting() {
127		// Signal arrived during execution — output partial result if available
128		sh.Stop()
129		if rt.Summary != "" {
130			_ = rt.WriteOutput(os.Stdout) //nolint:errcheck // stdout write failure is unrecoverable
131		}
132		fmt.Fprintln(os.Stderr, style.Errorf("interrupted by signal"))
133		os.Exit(130)
134	}
135	if err != nil {
136		sh.Stop()
137		fmt.Fprintln(os.Stderr, style.Errorf("execution failed: %v", err))
138		os.Exit(1)
139	}
140
141	// Write output
142	if rt.OutputFile != "" {
143		if err := rt.WriteOutputToFile(); err != nil {
144			fmt.Fprintln(os.Stderr, style.Errorf("writing output to file: %v", err))
145			os.Exit(1)
146		}
147	} else {
148		if err := rt.WriteOutput(os.Stdout); err != nil {
149			fmt.Fprintln(os.Stderr, style.Errorf("writing output: %v", err))
150			os.Exit(1)
151		}
152	}
153
154	sh.Stop()
155}
156
157func runDryRun(rt *runtime.Runtime, verbose bool) {
158	// Setup logger based on verbose flag
159	if verbose {
160		rt.Progress = progress.NewSimpleLogger(os.Stdout, true)
161	} else {
162		rt.Progress = &progress.NoopLogger{}
163	}
164
165	// Print dry-run header
166	fmt.Println(style.Heading("========================================"))
167	fmt.Println(style.Heading("       llm_aggregator --dry-run"))
168	fmt.Println(style.Heading("========================================"))
169	fmt.Println()
170
171	// Validate feed source exists
172	if rt.FeedsFile != "" {
173		fmt.Printf("%s Feeds file: %s\n", style.Success(""), style.Filepath(rt.FeedsFile))
174	} else {
175		fmt.Printf("%s Feed source: stdin\n", style.Success(""))
176	}
177
178	// Print configuration summary
179	fmt.Println()
180	fmt.Println(style.Label("Configuration:"))
181	fmt.Printf("  Max articles per feed: %s\n", style.Value(strconv.Itoa(rt.MaxArticlesPerFeed)))
182	fmt.Printf("  Max days old: %s\n", style.Value(strconv.Itoa(rt.MaxDaysOld)))
183	fmt.Printf("  Max total articles: %s\n", style.Value(strconv.Itoa(rt.MaxTotalArticles)))
184	fmt.Printf("  Include keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.IncludeKeywords)))
185	fmt.Printf("  Exclude keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.ExcludeKeywords)))
186	fmt.Printf("  Output format: %s\n", style.Value(rt.Output))
187	fmt.Printf("  Model: %s\n", style.Value(rt.Model))
188	fmt.Printf("  LLM timeout: %s\n", style.Value(fmt.Sprintf("%d seconds", rt.LLMTimeout)))
189	fmt.Println()
190
191	// Fetch and process feeds (but don't call LLM)
192	fmt.Println(style.Info("Fetching feeds..."))
193
194	feedAgg := aggregator.NewFeedAggregatorWithProgress(
195		rt.MaxArticlesPerFeed,
196		rt.MaxDaysOld,
197		5000, // max content length
198		nil,  // No progress context for cleaner dry-run output
199	)
200
201	var articles []*aggregator.Article
202	var err error
203
204	switch {
205	case rt.Stdin && rt.FeedsFile != "":
206		var stdinArticles []*aggregator.Article
207		var fileArticles []*aggregator.Article
208
209		if stdinArticles, err = feedAgg.ParseFeedFromStdin(); err != nil {
210			fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse stdin feed: %v", err))
211			os.Exit(1)
212		}
213		if fileArticles, err = feedAgg.ParseFeedsFromFile(rt.FeedsFile); err != nil {
214			fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds file: %v", err))
215			os.Exit(1)
216		}
217		articles = append(stdinArticles, fileArticles...) //nolint:gocritic
218	case rt.Stdin:
219		if articles, err = feedAgg.ParseFeedFromStdin(); err != nil {
220			fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse stdin feed: %v", err))
221			os.Exit(1)
222		}
223	default:
224		if articles, err = feedAgg.ParseFeedsFromFile(rt.FeedsFile); err != nil {
225			fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds: %v", err))
226			os.Exit(1)
227		}
228	}
229
230	totalArticles := len(articles)
231	fmt.Printf("%s Fetched %d articles from feeds\n", style.Success(""), totalArticles)
232
233	if totalArticles == 0 {
234		fmt.Fprintln(os.Stderr, style.Warning("No articles found. Check your feeds file or network connectivity."))
235		fmt.Println()
236		fmt.Printf("%s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
237		os.Exit(0)
238	}
239
240	// Process articles (filter and sort)
241	contentProc := processor.NewContentProcessor(
242		rt.MaxTotalArticles,
243		3000, // max content per article
244		rt.IncludeKeywords,
245		rt.ExcludeKeywords,
246	)
247
248	processedArticles := contentProc.ProcessArticles(articles, "date", true)
249	filteredCount := totalArticles - len(processedArticles)
250
251	fmt.Println()
252	fmt.Println(style.Label("Article statistics:"))
253	fmt.Printf("  Total fetched: %s\n", style.Value(strconv.Itoa(totalArticles)))
254	fmt.Printf("  After filtering: %s\n", style.Value(strconv.Itoa(len(processedArticles))))
255	if filteredCount > 0 {
256		fmt.Printf("  Filtered out: %s\n", style.Value(strconv.Itoa(filteredCount)))
257	}
258
259	// Group articles by source for summary
260	sourceCounts := make(map[string]int)
261	for _, article := range processedArticles {
262		if title, ok := article["source_feed"].(string); ok {
263			sourceCounts[title]++
264		}
265	}
266
267	if len(sourceCounts) > 0 {
268		fmt.Println()
269		fmt.Println(style.Label("Articles by source:"))
270		for source, count := range sourceCounts {
271			fmt.Printf("  %s: %s\n", style.Filepath(source), style.Value(strconv.Itoa(count)))
272		}
273	}
274
275	// Estimate token count
276	estimatedTokens := contentProc.EstimateTokenCount(processedArticles, rt.Model)
277	fmt.Println()
278	fmt.Printf("Estimated token count: %s (for model: %s)\n", style.Value(fmt.Sprintf("~%d", estimatedTokens)), style.Value(rt.Model))
279	fmt.Printf("Max tokens for response: %s\n", style.Value(strconv.Itoa(rt.MaxTokens)))
280
281	// Prompt preview
282	fmt.Println()
283	fmt.Println(style.Label("Prompt:"))
284	fmt.Printf("  %s\n", style.Italic(rt.Prompt))
285	if rt.SystemPrompt != "" {
286		fmt.Println(style.Label("System prompt:"))
287		fmt.Printf("  %s\n", style.Italic(rt.SystemPrompt))
288	}
289
290	fmt.Println()
291	fmt.Println(style.Heading("========================================"))
292	fmt.Printf(" %s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
293	fmt.Println(style.Heading("========================================"))
294}