internal/runtime/runtime.go (view raw)
1package runtime
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "time"
9
10 "llm_aggregator/internal/aggregator"
11
12 "llm_aggregator/internal/llm"
13 "llm_aggregator/internal/output"
14 "llm_aggregator/internal/processor"
15 "llm_aggregator/internal/progress"
16)
17
18// Runtime holds the execution context for the aggregator
19type Runtime struct {
20 // Configuration
21 FeedsFile string
22 Stdin bool
23 MaxArticlesPerFeed int
24 MaxDaysOld int
25 MaxTotalArticles int
26 IncludeKeywords []string
27 ExcludeKeywords []string
28 APIKey string
29 BaseURL string
30 Model string
31 MaxTokens int
32 Temperature float64
33 Prompt string
34 SystemPrompt string
35 Output string
36 OutputFile string
37 IncludeArticles bool
38 Plain bool
39 Verbose bool
40
41 // State
42 Articles []map[string]any
43 Summary string
44 Error error
45 Interrupted bool // Set to true when a termination signal is received
46
47 // Logger for verbose output
48 Progress progress.Progress
49}
50
51// Execute runs the full aggregation pipeline
52func (r *Runtime) Execute(ctx context.Context) error {
53 // The logger/progress handler is injected, so we don't create it here.
54 // We wrap it in a context to pass to sub-modules that expect a *progress.Context.
55 progCtx := progress.NewContext(r.Progress)
56
57 feedAgg := aggregator.NewFeedAggregatorWithProgress(
58 r.MaxArticlesPerFeed,
59 r.MaxDaysOld,
60 5000, // max content length
61 progCtx, // Pass the new progress context
62 )
63
64 // Step 1: Aggregate feeds
65 r.Progress.SetStage("Aggregating feeds")
66
67 var articles []*aggregator.Article
68 var err error
69
70 if r.Stdin && r.FeedsFile != "" {
71 // Both stdin and feeds file: collate results
72 r.Progress.SetSubStage("Parsing stdin feed and feeds file")
73
74 var stdinArticles []*aggregator.Article
75 var fileArticles []*aggregator.Article
76
77 if stdinArticles, err = feedAgg.ParseFeedFromStdin(); err != nil {
78 return fmt.Errorf("error parsing stdin feed: %w", err)
79 }
80
81 if fileArticles, err = feedAgg.ParseFeedsFromFile(r.FeedsFile); err != nil {
82 return fmt.Errorf("error aggregating feeds: %w", err)
83 }
84
85 articles = append(stdinArticles, fileArticles...)
86 } else if r.Stdin {
87 // Stdin only
88 r.Progress.SetSubStage("Parsing stdin feed")
89 if articles, err = feedAgg.ParseFeedFromStdin(); err != nil {
90 return fmt.Errorf("error parsing stdin feed: %w", err)
91 }
92 } else if r.FeedsFile != "" {
93 // Feeds file only
94 r.Progress.SetSubStage(fmt.Sprintf("Parsing feeds from %s", r.FeedsFile))
95 if articles, err = feedAgg.ParseFeedsFromFile(r.FeedsFile); err != nil {
96 return fmt.Errorf("error aggregating feeds: %w", err)
97 }
98 } else {
99 return fmt.Errorf("no feed source specified: use --feeds-file or --stdin")
100 }
101
102 if len(articles) == 0 {
103 return fmt.Errorf("no articles found after parsing feeds")
104 }
105 r.Progress.SetArticleCount(len(articles), 0)
106
107 // Step 2: Process content
108 r.Progress.SetStage("Processing articles")
109 r.Progress.SetSubStage(fmt.Sprintf("Filtering and sorting %d articles", len(articles)))
110
111 contentProc := processor.NewContentProcessor(
112 r.MaxTotalArticles,
113 3000, // max content per article
114 r.IncludeKeywords,
115 r.ExcludeKeywords,
116 )
117 contentProc.SetLogger(progCtx) // Pass the new progress context
118
119 processedArticles := contentProc.ProcessArticles(articles, "date", true)
120
121 if len(processedArticles) == 0 {
122 return fmt.Errorf("no articles passed filtering")
123 }
124 r.Progress.SetArticleCount(len(articles), len(articles)-len(processedArticles))
125
126 r.Articles = processedArticles
127
128 // Estimate tokens for progress tracking
129 tokenEst := contentProc.EstimateTokenCount(processedArticles, r.Model)
130 r.Progress.SetTokenEstimate(tokenEst, tokenEst)
131
132 // Step 3: Initialise LLM client
133 r.Progress.SetStage("Connecting to LLM")
134 r.Progress.SetSubStage(fmt.Sprintf("Using model: %s", r.Model))
135
136 llm, err := llm.NewLLMClient(
137 r.APIKey,
138 r.BaseURL,
139 r.Model,
140 r.MaxTokens,
141 r.Temperature,
142 )
143 if err != nil {
144 return fmt.Errorf("error initialising LLM client: %w", err)
145 }
146 llm.SetLogger(progCtx) // Pass the new progress context
147
148 // Step 4: Get summary from LLM
149 r.Progress.SetStage("Getting summary")
150 r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles)))
151 r.Progress.StartWaiting()
152
153 summary, usage, err := llm.SummariseArticles(
154 processedArticles,
155 r.Prompt,
156 r.SystemPrompt,
157 ctx,
158 )
159 if err != nil {
160 return fmt.Errorf("error getting summary from LLM: %w", err)
161 }
162
163 r.Summary = summary
164 if usage != nil {
165 r.Progress.SetTokenEstimate(tokenEst, tokenEst+usage.CompletionTokens)
166 }
167 return nil
168}
169
170// WasInterrupted returns true if a termination signal was received during execution.
171func (r *Runtime) WasInterrupted() bool {
172 return r.Interrupted
173}
174func (r *Runtime) WriteOutput(writer io.Writer) error {
175 if r.Plain {
176 if _, err := fmt.Fprint(writer, r.Summary); err != nil {
177 return fmt.Errorf("error writing plain output: %w", err)
178 }
179 return nil
180 }
181
182 formatter, err := output.NewFormatter(r.Output)
183 if err != nil {
184 return fmt.Errorf("error creating formatter: %w", err)
185 }
186
187 outputData := map[string]any{
188 "title": fmt.Sprintf("LLM Aggregator Summary - %s", time.Now().Format("2006-01-02 15:04")),
189 "prompt": r.Prompt,
190 "model": r.Model,
191 "articles_count": len(r.Articles),
192 "summary": r.Summary,
193 "timestamp": time.Now().Format(time.RFC3339),
194 }
195
196 if r.IncludeArticles {
197 outputData["articles"] = r.Articles
198 }
199
200 formattedOutput, err := formatter.FormatData(outputData)
201 if err != nil {
202 return fmt.Errorf("error formatting output: %w", err)
203 }
204
205 if _, err := fmt.Fprint(writer, formattedOutput); err != nil {
206 return fmt.Errorf("error writing output: %w", err)
207 }
208
209 return nil
210}
211
212// WriteOutputToFile writes the output to the specified file
213func (r *Runtime) WriteOutputToFile() error {
214 if r.OutputFile == "" {
215 return r.WriteOutput(os.Stdout)
216 }
217
218 file, err := os.Create(r.OutputFile)
219 if err != nil {
220 return fmt.Errorf("error creating output file: %w", err)
221 }
222 defer file.Close()
223
224 return r.WriteOutput(file)
225}