all repos — llm_aggregator @ 4b4b37a236f75a04bd530d0a4ffaa1fb26b89a27

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