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 feed source exists
130 if rt.FeedsFile != "" {
131 fmt.Printf("%s Feeds file: %s\n", style.Success(""), style.Filepath(rt.FeedsFile))
132 } else {
133 fmt.Printf("%s Feed source: stdin\n", style.Success(""))
134 }
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 var articles []*aggregator.Article
159 var err error
160
161 if rt.Stdin && rt.FeedsFile != "" {
162 var stdinArticles []*aggregator.Article
163 var fileArticles []*aggregator.Article
164
165 if stdinArticles, err = feedAgg.ParseFeedFromStdin(); err != nil {
166 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse stdin feed: %v", err))
167 os.Exit(1)
168 }
169 if fileArticles, err = feedAgg.ParseFeedsFromFile(rt.FeedsFile); err != nil {
170 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds file: %v", err))
171 os.Exit(1)
172 }
173 articles = append(stdinArticles, fileArticles...)
174 } else if rt.Stdin {
175 if articles, err = feedAgg.ParseFeedFromStdin(); err != nil {
176 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse stdin feed: %v", err))
177 os.Exit(1)
178 }
179 } else {
180 if articles, err = feedAgg.ParseFeedsFromFile(rt.FeedsFile); err != nil {
181 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds: %v", err))
182 os.Exit(1)
183 }
184 }
185
186 totalArticles := len(articles)
187 fmt.Printf("%s Fetched %d articles from feeds\n", style.Success(""), totalArticles)
188
189 if totalArticles == 0 {
190 fmt.Fprintln(os.Stderr, style.Warning("No articles found. Check your feeds file or network connectivity."))
191 fmt.Println()
192 fmt.Printf("%s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
193 os.Exit(0)
194 }
195
196 // Process articles (filter and sort)
197 contentProc := processor.NewContentProcessor(
198 rt.MaxTotalArticles,
199 3000, // max content per article
200 rt.IncludeKeywords,
201 rt.ExcludeKeywords,
202 )
203
204 processedArticles := contentProc.ProcessArticles(articles, "date", true)
205 filteredCount := totalArticles - len(processedArticles)
206
207 fmt.Println()
208 fmt.Println(style.Label("Article statistics:"))
209 fmt.Printf(" Total fetched: %s\n", style.Value(fmt.Sprintf("%d", totalArticles)))
210 fmt.Printf(" After filtering: %s\n", style.Value(fmt.Sprintf("%d", len(processedArticles))))
211 if filteredCount > 0 {
212 fmt.Printf(" Filtered out: %s\n", style.Value(fmt.Sprintf("%d", filteredCount)))
213 }
214
215 // Group articles by source for summary
216 sourceCounts := make(map[string]int)
217 for _, article := range processedArticles {
218 if title, ok := article["source_feed"].(string); ok {
219 sourceCounts[title]++
220 }
221 }
222
223 if len(sourceCounts) > 0 {
224 fmt.Println()
225 fmt.Println(style.Label("Articles by source:"))
226 for source, count := range sourceCounts {
227 fmt.Printf(" %s: %s\n", style.Filepath(source), style.Value(fmt.Sprintf("%d", count)))
228 }
229 }
230
231 // Estimate token count
232 estimatedTokens := contentProc.EstimateTokenCount(processedArticles, rt.Model)
233 fmt.Println()
234 fmt.Printf("Estimated token count: %s (for model: %s)\n", style.Value(fmt.Sprintf("~%d", estimatedTokens)), style.Value(rt.Model))
235 fmt.Printf("Max tokens for response: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTokens)))
236
237 // Prompt preview
238 fmt.Println()
239 fmt.Println(style.Label("Prompt:"))
240 fmt.Printf(" %s\n", style.Italic(rt.Prompt))
241 if rt.SystemPrompt != "" {
242 fmt.Println(style.Label("System prompt:"))
243 fmt.Printf(" %s\n", style.Italic(rt.SystemPrompt))
244 }
245
246 fmt.Println()
247 fmt.Println(style.Heading("========================================"))
248 fmt.Printf(" %s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
249 fmt.Println(style.Heading("========================================"))
250}