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/style"
16 "llm_aggregator/internal/tui"
17)
18
19var (
20 version string
21 buildDate string
22)
23
24func main() {
25 cli.BuildDate = buildDate
26 cli.Version = version
27
28 // Parse command line arguments
29 args, err := cli.ParseArgs()
30 if err != nil {
31 fmt.Fprintln(os.Stderr, style.Errorf("parsing arguments: %v", err))
32 os.Exit(1)
33 }
34
35 // Get viper instance with config file + environment variables + defaults
36 v := config.GetViper()
37
38 // Bind CLI args to viper (highest precedence)
39 config.BindCLIArgs(v, args.ToViperMap())
40
41 // Create runtime directly from viper configuration
42 rt := config.ViperToRuntime(v, args.FeedsFile, args.Prompt)
43
44 // Run dry-run mode if requested (validates config, shows statistics, no LLM calls)
45 if args.DryRun {
46 runDryRun(rt, args.Verbose)
47 os.Exit(0)
48 }
49
50 // Validate API key (required for actual execution)
51 if v.GetString("api_key") == "" {
52 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"))
53 os.Exit(1)
54 }
55
56 // Run with TUI if requested
57 if args.TUI {
58 runWithTUI(rt)
59 } else {
60 runWithoutTUI(rt, args.Verbose)
61 }
62}
63
64func runWithTUI(rt *runtime.Runtime) {
65 // 1. Create the model and pass the runtime to it.
66 model := tui.New(rt)
67
68 // 2. Create the tea.Program.
69 p := tea.NewProgram(model, tea.WithAltScreen())
70
71 // 3. Create the TUIProgress handler and give it the program instance.
72 // This is the bridge that allows the runtime to send messages to the TUI.
73 tp := tui.NewTUIProgress(p)
74
75 // 4. Inject the progress handler into the runtime.
76 rt.Progress = tp
77
78 // 5. Run the program. This is a blocking call.
79 if _, err := p.Run(); err != nil {
80 fmt.Fprintln(os.Stderr, style.Errorf("TUI error: %v", err))
81 os.Exit(1)
82 }
83}
84
85func runWithoutTUI(rt *runtime.Runtime, verbose bool) {
86 // Setup logger based on verbose flag and inject it into the runtime
87 if verbose {
88 rt.Progress = progress.NewSimpleLogger(os.Stdout, true)
89 } else {
90 // Default is already NoopLogger, but we can be explicit
91 rt.Progress = &progress.NoopLogger{}
92 }
93
94 // Execute the runtime
95 err := rt.Execute(context.Background())
96 if err != nil {
97 fmt.Fprintln(os.Stderr, style.Errorf("execution failed: %v", err))
98 os.Exit(1)
99 }
100
101 // Write output
102 if rt.OutputFile != "" {
103 if err := rt.WriteOutputToFile(); err != nil {
104 fmt.Fprintln(os.Stderr, style.Errorf("writing output to file: %v", err))
105 os.Exit(1)
106 }
107 } else {
108 if err := rt.WriteOutput(os.Stdout); err != nil {
109 fmt.Fprintln(os.Stderr, style.Errorf("writing output: %v", err))
110 os.Exit(1)
111 }
112 }
113}
114
115func runDryRun(rt *runtime.Runtime, verbose bool) {
116 // Setup logger based on verbose flag
117 if verbose {
118 rt.Progress = progress.NewSimpleLogger(os.Stdout, true)
119 } else {
120 rt.Progress = &progress.NoopLogger{}
121 }
122
123 // Print dry-run header
124 fmt.Println(style.Heading("========================================"))
125 fmt.Println(style.Heading(" llm_aggregator --dry-run"))
126 fmt.Println(style.Heading("========================================"))
127 fmt.Println()
128
129 // Validate feeds file exists
130 if _, err := os.Stat(rt.FeedsFile); os.IsNotExist(err) {
131 fmt.Fprintln(os.Stderr, style.Errorf("Feeds file not found: %s", rt.FeedsFile))
132 os.Exit(1)
133 }
134 fmt.Printf("%s Feeds file: %s\n", style.Success(""), style.Filepath(rt.FeedsFile))
135
136 // Print configuration summary
137 fmt.Println()
138 fmt.Println(style.Label("Configuration:"))
139 fmt.Printf(" Max articles per feed: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxArticlesPerFeed)))
140 fmt.Printf(" Max days old: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxDaysOld)))
141 fmt.Printf(" Max total articles: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTotalArticles)))
142 fmt.Printf(" Include keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.IncludeKeywords)))
143 fmt.Printf(" Exclude keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.ExcludeKeywords)))
144 fmt.Printf(" Output format: %s\n", style.Value(rt.Output))
145 fmt.Printf(" Model: %s\n", style.Value(rt.Model))
146 fmt.Println()
147
148 // Fetch and process feeds (but don't call LLM)
149 fmt.Println(style.Info("Fetching feeds..."))
150
151 feedAgg := aggregator.NewFeedAggregatorWithProgress(
152 rt.MaxArticlesPerFeed,
153 rt.MaxDaysOld,
154 5000, // max content length
155 nil, // No progress context for cleaner dry-run output
156 )
157
158 articles, err := feedAgg.ParseFeedsFromFile(rt.FeedsFile)
159 if err != nil {
160 fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds: %v", err))
161 os.Exit(1)
162 }
163
164 totalArticles := len(articles)
165 fmt.Printf("%s Fetched %d articles from feeds\n", style.Success(""), totalArticles)
166
167 if totalArticles == 0 {
168 fmt.Fprintln(os.Stderr, style.Warning("No articles found. Check your feeds file or network connectivity."))
169 fmt.Println()
170 fmt.Printf("%s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
171 os.Exit(0)
172 }
173
174 // Process articles (filter and sort)
175 contentProc := processor.NewContentProcessor(
176 rt.MaxTotalArticles,
177 3000, // max content per article
178 rt.IncludeKeywords,
179 rt.ExcludeKeywords,
180 )
181
182 processedArticles := contentProc.ProcessArticles(articles, "date", true)
183 filteredCount := totalArticles - len(processedArticles)
184
185 fmt.Println()
186 fmt.Println(style.Label("Article statistics:"))
187 fmt.Printf(" Total fetched: %s\n", style.Value(fmt.Sprintf("%d", totalArticles)))
188 fmt.Printf(" After filtering: %s\n", style.Value(fmt.Sprintf("%d", len(processedArticles))))
189 if filteredCount > 0 {
190 fmt.Printf(" Filtered out: %s\n", style.Value(fmt.Sprintf("%d", filteredCount)))
191 }
192
193 // Group articles by source for summary
194 sourceCounts := make(map[string]int)
195 for _, article := range processedArticles {
196 if title, ok := article["source_feed"].(string); ok {
197 sourceCounts[title]++
198 }
199 }
200
201 if len(sourceCounts) > 0 {
202 fmt.Println()
203 fmt.Println(style.Label("Articles by source:"))
204 for source, count := range sourceCounts {
205 fmt.Printf(" %s: %s\n", style.Filepath(source), style.Value(fmt.Sprintf("%d", count)))
206 }
207 }
208
209 // Estimate token count
210 estimatedTokens := contentProc.EstimateTokenCount(processedArticles, rt.Model)
211 fmt.Println()
212 fmt.Printf("Estimated token count: %s (for model: %s)\n", style.Value(fmt.Sprintf("~%d", estimatedTokens)), style.Value(rt.Model))
213 fmt.Printf("Max tokens for response: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTokens)))
214
215 // Prompt preview
216 fmt.Println()
217 fmt.Println(style.Label("Prompt:"))
218 fmt.Printf(" %s\n", style.Italic(rt.Prompt))
219 if rt.SystemPrompt != "" {
220 fmt.Println(style.Label("System prompt:"))
221 fmt.Printf(" %s\n", style.Italic(rt.SystemPrompt))
222 }
223
224 fmt.Println()
225 fmt.Println(style.Heading("========================================"))
226 fmt.Printf(" %s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).")
227 fmt.Println(style.Heading("========================================"))
228}