all repos — llm_aggregator @ b4f6beb3ba126b91482bcd5df0745445088d51d7

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	// Step 3: Initialise LLM client
 96	r.Progress.SetStage("Connecting to LLM")
 97	r.Progress.SetSubStage(fmt.Sprintf("Using model: %s", r.Model))
 98
 99	llm, err := llm.NewLLMClient(
100		r.APIKey,
101		r.BaseURL,
102		r.Model,
103		r.MaxTokens,
104		r.Temperature,
105	)
106	if err != nil {
107		return fmt.Errorf("error initialising LLM client: %w", err)
108	}
109	llm.SetLogger(progCtx) // Pass the new progress context
110
111	// Step 4: Get summary from LLM
112	r.Progress.SetStage("Getting summary")
113	r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles)))
114
115	summary, err := llm.SummariseArticles(
116		processedArticles,
117		r.Prompt,
118		r.SystemPrompt,
119	)
120	if err != nil {
121		return fmt.Errorf("error getting summary from LLM: %w", err)
122	}
123
124	r.Summary = summary
125	r.Progress.SetArticleCount(len(processedArticles), len(processedArticles))
126	return nil
127}
128
129// WriteOutput writes the formatted output to the specified writer
130func (r *Runtime) WriteOutput(writer io.Writer) error {
131	formatter, err := output.NewFormatter(r.Output)
132	if err != nil {
133		return fmt.Errorf("error creating formatter: %w", err)
134	}
135
136	outputData := map[string]any{
137		"title":          fmt.Sprintf("LLM Aggregator Summary - %s", time.Now().Format("2006-01-02 15:04")),
138		"prompt":         r.Prompt,
139		"model":          r.Model,
140		"articles_count": len(r.Articles),
141		"summary":        r.Summary,
142		"timestamp":      time.Now().Format(time.RFC3339),
143	}
144
145	if r.IncludeArticles {
146		outputData["articles"] = r.Articles
147	}
148
149	formattedOutput, err := formatter.FormatData(outputData)
150	if err != nil {
151		return fmt.Errorf("error formatting output: %w", err)
152	}
153
154	if _, err := fmt.Fprint(writer, formattedOutput); err != nil {
155		return fmt.Errorf("error writing output: %w", err)
156	}
157
158	return nil
159}
160
161// WriteOutputToFile writes the output to the specified file
162func (r *Runtime) WriteOutputToFile() error {
163	if r.OutputFile == "" {
164		return r.WriteOutput(os.Stdout)
165	}
166
167	file, err := os.Create(r.OutputFile)
168	if err != nil {
169		return fmt.Errorf("error creating output file: %w", err)
170	}
171	defer file.Close()
172
173	return r.WriteOutput(file)
174}