all repos — llm_aggregator @ 4316d19f105d3ded269a393c048b38d2e83d3141

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/style"
 16	"llm_aggregator/internal/tui"
 17)
 18
 19var (
 20	version   string
 21	buildDate string
 22)
 23
 24func main() {
 25	cli.BuildDate = buildDate
 26	cli.Version = version
 27
 28	// Parse command line arguments
 29	args, err := cli.ParseArgs()
 30	if err != nil {
 31		fmt.Fprintln(os.Stderr, style.Errorf("parsing arguments: %v", err))
 32		os.Exit(1)
 33	}
 34
 35	// Get viper instance with config file + environment variables + defaults
 36	v := config.GetViper()
 37
 38	// Bind CLI args to viper (highest precedence)
 39	config.BindCLIArgs(v, args.ToViperMap())
 40
 41	// Create runtime directly from viper configuration
 42	rt := config.ViperToRuntime(v, args.FeedsFile, args.Prompt)
 43
 44	// Run dry-run mode if requested (validates config, shows statistics, no LLM calls)
 45	if args.DryRun {
 46		runDryRun(rt, args.Verbose)
 47		os.Exit(0)
 48	}
 49
 50	// Validate API key (required for actual execution)
 51	if v.GetString("api_key") == "" {
 52		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"))
 53		os.Exit(1)
 54	}
 55
 56	// Run with TUI if requested
 57	if args.TUI {
 58		runWithTUI(rt)
 59	} else {
 60		runWithoutTUI(rt, args.Verbose)
 61	}
 62}
 63
 64func runWithTUI(rt *runtime.Runtime) {
 65	// 1. Create the model and pass the runtime to it.
 66	model := tui.New(rt)
 67
 68	// 2. Create the tea.Program.
 69	p := tea.NewProgram(model, tea.WithAltScreen())
 70
 71	// 3. Create the TUIProgress handler and give it the program instance.
 72	//    This is the bridge that allows the runtime to send messages to the TUI.
 73	tp := tui.NewTUIProgress(p)
 74
 75	// 4. Inject the progress handler into the runtime.
 76	rt.Progress = tp
 77
 78	// 5. Run the program. This is a blocking call.
 79	if _, err := p.Run(); err != nil {
 80		fmt.Fprintln(os.Stderr, style.Errorf("TUI error: %v", err))
 81		os.Exit(1)
 82	}
 83}
 84
 85func runWithoutTUI(rt *runtime.Runtime, verbose bool) {
 86	// Setup logger based on verbose flag and inject it into the runtime
 87	if verbose {
 88		rt.Progress = progress.NewSimpleLogger(os.Stdout, true)
 89	} else {
 90		// Default is already NoopLogger, but we can be explicit
 91		rt.Progress = &progress.NoopLogger{}
 92	}
 93
 94	// Execute the runtime
 95	err := rt.Execute(context.Background())
 96	if err != nil {
 97		fmt.Fprintln(os.Stderr, style.Errorf("execution failed: %v", err))
 98		os.Exit(1)
 99	}
100
101	// Write output
102	if rt.OutputFile != "" {
103		if err := rt.WriteOutputToFile(); err != nil {
104			fmt.Fprintln(os.Stderr, style.Errorf("writing output to file: %v", err))
105			os.Exit(1)
106		}
107	} else {
108		if err := rt.WriteOutput(os.Stdout); err != nil {
109			fmt.Fprintln(os.Stderr, style.Errorf("writing output: %v", err))
110			os.Exit(1)
111		}
112	}
113}
114
115func runDryRun(rt *runtime.Runtime, verbose bool) {
116	// Setup logger based on verbose flag
117	if verbose {
118		rt.Progress = progress.NewSimpleLogger(os.Stdout, true)
119	} else {
120		rt.Progress = &progress.NoopLogger{}
121	}
122
123	// Print dry-run header
124	fmt.Println(style.Heading("========================================"))
125	fmt.Println(style.Heading("       llm_aggregator --dry-run"))
126	fmt.Println(style.Heading("========================================"))
127	fmt.Println()
128
129	// Validate feeds file exists
130	if _, err := os.Stat(rt.FeedsFile); os.IsNotExist(err) {
131		fmt.Fprintln(os.Stderr, style.Errorf("Feeds file not found: %s", rt.FeedsFile))
132		os.Exit(1)
133	}
134	fmt.Printf("%s Feeds file: %s\n", style.Success(""), style.Filepath(rt.FeedsFile))
135
136	// Print configuration summary
137	fmt.Println()
138	fmt.Println(style.Label("Configuration:"))
139	fmt.Printf("  Max articles per feed: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxArticlesPerFeed)))
140	fmt.Printf("  Max days old: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxDaysOld)))
141	fmt.Printf("  Max total articles: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTotalArticles)))
142	fmt.Printf("  Include keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.IncludeKeywords)))
143	fmt.Printf("  Exclude keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.ExcludeKeywords)))
144	fmt.Printf("  Output format: %s\n", style.Value(rt.Output))
145	fmt.Printf("  Model: %s\n", style.Value(rt.Model))
146	fmt.Println()
147
148	// Fetch and process feeds (but don't call LLM)
149	fmt.Println(style.Info("Fetching feeds..."))
150
151	feedAgg := aggregator.NewFeedAggregatorWithProgress(
152		rt.MaxArticlesPerFeed,
153		rt.MaxDaysOld,
154		5000, // max content length
155		nil,  // No progress context for cleaner dry-run output
156	)
157
158	articles, err := feedAgg.ParseFeedsFromFile(rt.FeedsFile)
159	if err != nil {
160		fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds: %v", err))
161		os.Exit(1)
162	}
163
164	totalArticles := len(articles)
165	fmt.Printf("%s Fetched %d articles from feeds\n", style.Success(""), totalArticles)
166
167	if totalArticles == 0 {
168		fmt.Fprintln(os.Stderr, style.Warning("No articles found. Check your feeds file or network connectivity."))
169		fmt.Println()
170		fmt.Printf("%s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
171		os.Exit(0)
172	}
173
174	// Process articles (filter and sort)
175	contentProc := processor.NewContentProcessor(
176		rt.MaxTotalArticles,
177		3000, // max content per article
178		rt.IncludeKeywords,
179		rt.ExcludeKeywords,
180	)
181
182	processedArticles := contentProc.ProcessArticles(articles, "date", true)
183	filteredCount := totalArticles - len(processedArticles)
184
185	fmt.Println()
186	fmt.Println(style.Label("Article statistics:"))
187	fmt.Printf("  Total fetched: %s\n", style.Value(fmt.Sprintf("%d", totalArticles)))
188	fmt.Printf("  After filtering: %s\n", style.Value(fmt.Sprintf("%d", len(processedArticles))))
189	if filteredCount > 0 {
190		fmt.Printf("  Filtered out: %s\n", style.Value(fmt.Sprintf("%d", filteredCount)))
191	}
192
193	// Group articles by source for summary
194	sourceCounts := make(map[string]int)
195	for _, article := range processedArticles {
196		if title, ok := article["source_feed"].(string); ok {
197			sourceCounts[title]++
198		}
199	}
200
201	if len(sourceCounts) > 0 {
202		fmt.Println()
203		fmt.Println(style.Label("Articles by source:"))
204		for source, count := range sourceCounts {
205			fmt.Printf("  %s: %s\n", style.Filepath(source), style.Value(fmt.Sprintf("%d", count)))
206		}
207	}
208
209	// Estimate token count
210	estimatedTokens := contentProc.EstimateTokenCount(processedArticles, rt.Model)
211	fmt.Println()
212	fmt.Printf("Estimated token count: %s (for model: %s)\n", style.Value(fmt.Sprintf("~%d", estimatedTokens)), style.Value(rt.Model))
213	fmt.Printf("Max tokens for response: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTokens)))
214
215	// Prompt preview
216	fmt.Println()
217	fmt.Println(style.Label("Prompt:"))
218	fmt.Printf("  %s\n", style.Italic(rt.Prompt))
219	if rt.SystemPrompt != "" {
220		fmt.Println(style.Label("System prompt:"))
221		fmt.Printf("  %s\n", style.Italic(rt.SystemPrompt))
222	}
223
224	fmt.Println()
225	fmt.Println(style.Heading("========================================"))
226	fmt.Printf(" %s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
227	fmt.Println(style.Heading("========================================"))
228}