all repos — llm_aggregator @ a0e7d1a442a251ae94645cdddccf30ded92e97a1

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