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 // Step 3: Initialise LLM client
96 r.Progress.SetStage("Connecting to LLM")
97 r.Progress.SetSubStage(fmt.Sprintf("Using model: %s", r.Model))
98
99 llm, err := llm.NewLLMClient(
100 r.APIKey,
101 r.BaseURL,
102 r.Model,
103 r.MaxTokens,
104 r.Temperature,
105 )
106 if err != nil {
107 return fmt.Errorf("error initialising LLM client: %w", err)
108 }
109 llm.SetLogger(progCtx) // Pass the new progress context
110
111 // Step 4: Get summary from LLM
112 r.Progress.SetStage("Getting summary")
113 r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles)))
114
115 summary, err := llm.SummariseArticles(
116 processedArticles,
117 r.Prompt,
118 r.SystemPrompt,
119 )
120 if err != nil {
121 return fmt.Errorf("error getting summary from LLM: %w", err)
122 }
123
124 r.Summary = summary
125 r.Progress.SetArticleCount(len(processedArticles), len(processedArticles))
126 return nil
127}
128
129// WriteOutput writes the formatted output to the specified writer
130func (r *Runtime) WriteOutput(writer io.Writer) error {
131 formatter, err := output.NewFormatter(r.Output)
132 if err != nil {
133 return fmt.Errorf("error creating formatter: %w", err)
134 }
135
136 outputData := map[string]any{
137 "title": fmt.Sprintf("LLM Aggregator Summary - %s", time.Now().Format("2006-01-02 15:04")),
138 "prompt": r.Prompt,
139 "model": r.Model,
140 "articles_count": len(r.Articles),
141 "summary": r.Summary,
142 "timestamp": time.Now().Format(time.RFC3339),
143 }
144
145 if r.IncludeArticles {
146 outputData["articles"] = r.Articles
147 }
148
149 formattedOutput, err := formatter.FormatData(outputData)
150 if err != nil {
151 return fmt.Errorf("error formatting output: %w", err)
152 }
153
154 if _, err := fmt.Fprint(writer, formattedOutput); err != nil {
155 return fmt.Errorf("error writing output: %w", err)
156 }
157
158 return nil
159}
160
161// WriteOutputToFile writes the output to the specified file
162func (r *Runtime) WriteOutputToFile() error {
163 if r.OutputFile == "" {
164 return r.WriteOutput(os.Stdout)
165 }
166
167 file, err := os.Create(r.OutputFile)
168 if err != nil {
169 return fmt.Errorf("error creating output file: %w", err)
170 }
171 defer file.Close()
172
173 return r.WriteOutput(file)
174}