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