all repos — llm_aggregator @ af6da634ea141bc028760fd78253d03cee78a82f

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