all repos — llm_aggregator @ 2d9899ec7612acc35f5ae4e343d6364a9f2dc8b1

A CLI tool to aggregate RSS feeds and summarise them with LLMs

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