all repos — llm_aggregator @ 2d9899ec7612acc35f5ae4e343d6364a9f2dc8b1

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