all repos — llm_aggregator @ e6f508220e5dbe8a9333b4ad603b5add4b13af7b

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	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}