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.Printf(" LLM timeout: %s\n", style.Value(fmt.Sprintf("%d seconds", rt.LLMTimeout)))
188 fmt.Println()
189
190 // Fetch and process feeds (but don't call LLM)
191 fmt.Println(style.Info("Fetching feeds..."))
192
193 feedAgg := aggregator.NewFeedAggregatorWithProgress(
194 rt.MaxArticlesPerFeed,
195 rt.MaxDaysOld,
196 5000, // max content length
197 nil, // No progress context for cleaner dry-run output
198 )
199
200 var articles []*aggregator.Article
201 var err error
202
203 if rt.Stdin && rt.FeedsFile != "" {
204 var stdinArticles []*aggregator.Article
205 var fileArticles []*aggregator.Article
206
207 if stdinArticles, err = feedAgg.ParseFeedFromStdin(); err != nil {
208 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse stdin feed: %v", err))
209 os.Exit(1)
210 }
211 if fileArticles, err = feedAgg.ParseFeedsFromFile(rt.FeedsFile); err != nil {
212 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds file: %v", err))
213 os.Exit(1)
214 }
215 articles = append(stdinArticles, fileArticles...)
216 } else if rt.Stdin {
217 if articles, err = feedAgg.ParseFeedFromStdin(); err != nil {
218 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse stdin feed: %v", err))
219 os.Exit(1)
220 }
221 } else {
222 if articles, err = feedAgg.ParseFeedsFromFile(rt.FeedsFile); err != nil {
223 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds: %v", err))
224 os.Exit(1)
225 }
226 }
227
228 totalArticles := len(articles)
229 fmt.Printf("%s Fetched %d articles from feeds\n", style.Success(""), totalArticles)
230
231 if totalArticles == 0 {
232 fmt.Fprintln(os.Stderr, style.Warning("No articles found. Check your feeds file or network connectivity."))
233 fmt.Println()
234 fmt.Printf("%s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
235 os.Exit(0)
236 }
237
238 // Process articles (filter and sort)
239 contentProc := processor.NewContentProcessor(
240 rt.MaxTotalArticles,
241 3000, // max content per article
242 rt.IncludeKeywords,
243 rt.ExcludeKeywords,
244 )
245
246 processedArticles := contentProc.ProcessArticles(articles, "date", true)
247 filteredCount := totalArticles - len(processedArticles)
248
249 fmt.Println()
250 fmt.Println(style.Label("Article statistics:"))
251 fmt.Printf(" Total fetched: %s\n", style.Value(fmt.Sprintf("%d", totalArticles)))
252 fmt.Printf(" After filtering: %s\n", style.Value(fmt.Sprintf("%d", len(processedArticles))))
253 if filteredCount > 0 {
254 fmt.Printf(" Filtered out: %s\n", style.Value(fmt.Sprintf("%d", filteredCount)))
255 }
256
257 // Group articles by source for summary
258 sourceCounts := make(map[string]int)
259 for _, article := range processedArticles {
260 if title, ok := article["source_feed"].(string); ok {
261 sourceCounts[title]++
262 }
263 }
264
265 if len(sourceCounts) > 0 {
266 fmt.Println()
267 fmt.Println(style.Label("Articles by source:"))
268 for source, count := range sourceCounts {
269 fmt.Printf(" %s: %s\n", style.Filepath(source), style.Value(fmt.Sprintf("%d", count)))
270 }
271 }
272
273 // Estimate token count
274 estimatedTokens := contentProc.EstimateTokenCount(processedArticles, rt.Model)
275 fmt.Println()
276 fmt.Printf("Estimated token count: %s (for model: %s)\n", style.Value(fmt.Sprintf("~%d", estimatedTokens)), style.Value(rt.Model))
277 fmt.Printf("Max tokens for response: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTokens)))
278
279 // Prompt preview
280 fmt.Println()
281 fmt.Println(style.Label("Prompt:"))
282 fmt.Printf(" %s\n", style.Italic(rt.Prompt))
283 if rt.SystemPrompt != "" {
284 fmt.Println(style.Label("System prompt:"))
285 fmt.Printf(" %s\n", style.Italic(rt.SystemPrompt))
286 }
287
288 fmt.Println()
289 fmt.Println(style.Heading("========================================"))
290 fmt.Printf(" %s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
291 fmt.Println(style.Heading("========================================"))
292}