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