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