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