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