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