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