all repos — llm_aggregator @ e3e7be8461bb50a7fe674b4055286d34dddb0c5c

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