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 "llm_aggregator/internal/defaults"
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 Verbose bool
38
39 // State
40 Articles []map[string]any
41 Summary string
42 Error error
43
44 // Logger for verbose output
45 Progress progress.Progress
46}
47
48// NewRuntime creates a new runtime with default values
49func NewRuntime() *Runtime {
50 return &Runtime{
51 MaxArticlesPerFeed: defaults.DefaultMaxArticlesPerFeed,
52 MaxDaysOld: defaults.DefaultMaxDaysOld,
53 MaxTotalArticles: defaults.DefaultMaxTotalArticles,
54 Model: defaults.DefaultModel,
55 BaseURL: defaults.DefaultBaseURL,
56 MaxTokens: defaults.DefaultMaxTokens,
57 Temperature: defaults.DefaultTemperature,
58 Output: defaults.DefaultOutput,
59 Progress: &progress.NoopLogger{},
60 }
61}
62
63// Execute runs the full aggregation pipeline
64func (r *Runtime) Execute(ctx context.Context) error {
65 // The logger/progress handler is injected, so we don't create it here.
66 // We wrap it in a context to pass to sub-modules that expect a *progress.Context.
67 progCtx := progress.NewContext(r.Progress)
68
69 // Step 1: Aggregate feeds
70 r.Progress.SetStage("Aggregating feeds")
71 r.Progress.SetSubStage(fmt.Sprintf("Parsing feeds from %s", r.FeedsFile))
72
73 feedAgg := aggregator.NewFeedAggregatorWithProgress(
74 r.MaxArticlesPerFeed,
75 r.MaxDaysOld,
76 5000, // max content length
77 progCtx, // Pass the new progress context
78 )
79
80 articles, err := feedAgg.ParseFeedsFromFile(r.FeedsFile)
81 if err != nil {
82 return fmt.Errorf("error aggregating feeds: %w", err)
83 }
84 if len(articles) == 0 {
85 return fmt.Errorf("no articles found after parsing feeds")
86 }
87 r.Progress.SetArticleCount(len(articles), 0)
88
89 // Step 2: Process content
90 r.Progress.SetStage("Processing articles")
91 r.Progress.SetSubStage(fmt.Sprintf("Filtering and sorting %d articles", len(articles)))
92
93 contentProc := processor.NewContentProcessor(
94 r.MaxTotalArticles,
95 3000, // max content per article
96 r.IncludeKeywords,
97 r.ExcludeKeywords,
98 )
99 contentProc.SetLogger(progCtx) // Pass the new progress context
100
101 processedArticles := contentProc.ProcessArticles(articles, "date", true)
102
103 if len(processedArticles) == 0 {
104 return fmt.Errorf("no articles passed filtering")
105 }
106 r.Progress.SetArticleCount(len(articles), len(articles)-len(processedArticles))
107
108 r.Articles = processedArticles
109
110 // Step 3: Initialise LLM client
111 r.Progress.SetStage("Connecting to LLM")
112 r.Progress.SetSubStage(fmt.Sprintf("Using model: %s", r.Model))
113
114 llm, err := llm.NewLLMClient(
115 r.APIKey,
116 r.BaseURL,
117 r.Model,
118 r.MaxTokens,
119 r.Temperature,
120 )
121 if err != nil {
122 return fmt.Errorf("error initialising LLM client: %w", err)
123 }
124 llm.SetLogger(progCtx) // Pass the new progress context
125
126 // Step 4: Get summary from LLM
127 r.Progress.SetStage("Getting summary")
128 r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles)))
129
130 summary, err := llm.SummariseArticles(
131 processedArticles,
132 r.Prompt,
133 r.SystemPrompt,
134 )
135 if err != nil {
136 return fmt.Errorf("error getting summary from LLM: %w", err)
137 }
138
139 r.Summary = summary
140 r.Progress.SetArticleCount(len(processedArticles), len(processedArticles))
141 return nil
142}
143
144// WriteOutput writes the formatted output to the specified writer
145func (r *Runtime) WriteOutput(writer io.Writer) error {
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}