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