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