all repos — llm_aggregator @ 97ccb8da8015d57585d37a5d27e94ada7f766922

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	"llm_aggregator/internal/defaults"
 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// NewRuntime creates a new runtime with default values
 49func NewRuntime() *Runtime {
 50	return &Runtime{
 51		MaxArticlesPerFeed: defaults.DefaultMaxArticlesPerFeed,
 52		MaxDaysOld:         defaults.DefaultMaxDaysOld,
 53		MaxTotalArticles:   defaults.DefaultMaxTotalArticles,
 54		Model:              defaults.DefaultModel,
 55		BaseURL:            defaults.DefaultBaseURL,
 56		MaxTokens:          defaults.DefaultMaxTokens,
 57		Temperature:        defaults.DefaultTemperature,
 58		Output:             defaults.DefaultOutput,
 59		Progress:           &progress.NoopLogger{},
 60	}
 61}
 62
 63// Execute runs the full aggregation pipeline
 64func (r *Runtime) Execute(ctx context.Context) error {
 65	// The logger/progress handler is injected, so we don't create it here.
 66	// We wrap it in a context to pass to sub-modules that expect a *progress.Context.
 67	progCtx := progress.NewContext(r.Progress)
 68
 69	// Step 1: Aggregate feeds
 70	r.Progress.SetStage("Aggregating feeds")
 71	r.Progress.SetSubStage(fmt.Sprintf("Parsing feeds from %s", r.FeedsFile))
 72
 73	feedAgg := aggregator.NewFeedAggregatorWithProgress(
 74		r.MaxArticlesPerFeed,
 75		r.MaxDaysOld,
 76		5000,    // max content length
 77		progCtx, // Pass the new progress context
 78	)
 79
 80	articles, err := feedAgg.ParseFeedsFromFile(r.FeedsFile)
 81	if err != nil {
 82		return fmt.Errorf("error aggregating feeds: %w", err)
 83	}
 84	if len(articles) == 0 {
 85		return fmt.Errorf("no articles found after parsing feeds")
 86	}
 87	r.Progress.SetArticleCount(len(articles), 0)
 88
 89	// Step 2: Process content
 90	r.Progress.SetStage("Processing articles")
 91	r.Progress.SetSubStage(fmt.Sprintf("Filtering and sorting %d articles", len(articles)))
 92
 93	contentProc := processor.NewContentProcessor(
 94		r.MaxTotalArticles,
 95		3000, // max content per article
 96		r.IncludeKeywords,
 97		r.ExcludeKeywords,
 98	)
 99	contentProc.SetLogger(progCtx) // Pass the new progress context
100
101	processedArticles := contentProc.ProcessArticles(articles, "date", true)
102
103	if len(processedArticles) == 0 {
104		return fmt.Errorf("no articles passed filtering")
105	}
106	r.Progress.SetArticleCount(len(articles), len(articles)-len(processedArticles))
107
108	r.Articles = processedArticles
109
110	// Step 3: Initialise LLM client
111	r.Progress.SetStage("Connecting to LLM")
112	r.Progress.SetSubStage(fmt.Sprintf("Using model: %s", r.Model))
113
114	llm, err := llm.NewLLMClient(
115		r.APIKey,
116		r.BaseURL,
117		r.Model,
118		r.MaxTokens,
119		r.Temperature,
120	)
121	if err != nil {
122		return fmt.Errorf("error initialising LLM client: %w", err)
123	}
124	llm.SetLogger(progCtx) // Pass the new progress context
125
126	// Step 4: Get summary from LLM
127	r.Progress.SetStage("Getting summary")
128	r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles)))
129
130	summary, err := llm.SummariseArticles(
131		processedArticles,
132		r.Prompt,
133		r.SystemPrompt,
134	)
135	if err != nil {
136		return fmt.Errorf("error getting summary from LLM: %w", err)
137	}
138
139	r.Summary = summary
140	r.Progress.SetArticleCount(len(processedArticles), len(processedArticles))
141	return nil
142}
143
144// WriteOutput writes the formatted output to the specified writer
145func (r *Runtime) WriteOutput(writer io.Writer) error {
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}