all repos — llm_aggregator @ f8680f68d04b67101a791bdcffbbb96f0bc740a8

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	"errors"
  6	"fmt"
  7	"io"
  8	"os"
  9	"time"
 10
 11	"codeberg.org/maxwelljensen/llm_aggregator/internal/aggregator"
 12
 13	"codeberg.org/maxwelljensen/llm_aggregator/internal/llm"
 14	"codeberg.org/maxwelljensen/llm_aggregator/internal/output"
 15	"codeberg.org/maxwelljensen/llm_aggregator/internal/processor"
 16	"codeberg.org/maxwelljensen/llm_aggregator/internal/progress"
 17)
 18
 19// Runtime holds the full execution context for the pipeline.
 20// It is constructed once in main() and passed through each stage.
 21type Runtime struct {
 22	// Configuration
 23	FeedsFile          string
 24	Stdin              bool
 25	MaxArticlesPerFeed int
 26	MaxDaysOld         int
 27	MaxTotalArticles   int
 28	IncludeKeywords    []string
 29	ExcludeKeywords    []string
 30	APIKey             string
 31	BaseURL            string
 32	Model              string
 33	MaxTokens          int
 34	Temperature        *float64
 35	LLMTimeout         int // seconds; 0 means no timeout
 36	Prompt             string
 37	SystemPrompt       string
 38	Output             string
 39	OutputFile         string
 40	IncludeArticles    bool
 41	Plain              bool
 42	Verbose            bool
 43
 44	// State
 45	Articles    []map[string]any
 46	Summary     string
 47	Error       error
 48	Interrupted bool // Set to true when a termination signal is received
 49
 50	// Progress is the logger/progress handler injected by the caller.
 51	// nil means no output (NoopLogger). SimpleLogger outputs to stdout.
 52	// TUIProgress bridges into the Bubbletea program for live updates.
 53	Progress progress.Progress
 54}
 55
 56// Execute runs the four-stage pipeline: aggregate → process → LLM → (output is separate).
 57func (r *Runtime) Execute(ctx context.Context) error {
 58	// The logger/progress handler is injected, so we don't create it here.
 59	// We wrap it in a context to pass to sub-modules that expect a *progress.Context.
 60	progCtx := progress.NewContext(r.Progress)
 61
 62	feedAgg := aggregator.NewFeedAggregatorWithProgress(
 63		r.MaxArticlesPerFeed,
 64		r.MaxDaysOld,
 65		5000,    // max content length
 66		progCtx, // Pass the new progress context
 67	)
 68
 69	// Step 1: Aggregate feeds
 70	r.Progress.SetStage("Aggregating feeds")
 71
 72	var articles []*aggregator.Article
 73	var err error
 74
 75	switch {
 76	case r.Stdin && r.FeedsFile != "":
 77		// Both stdin and feeds file: collate results
 78		r.Progress.SetSubStage("Parsing stdin feed and feeds file")
 79
 80		var stdinArticles []*aggregator.Article
 81		var fileArticles []*aggregator.Article
 82
 83		if stdinArticles, err = feedAgg.ParseFeedFromStdin(); err != nil {
 84			return fmt.Errorf("error parsing stdin feed: %w", err)
 85		}
 86
 87		if fileArticles, err = feedAgg.ParseFeedsFromFile(r.FeedsFile); err != nil {
 88			return fmt.Errorf("error aggregating feeds: %w", err)
 89		}
 90
 91		articles = append(stdinArticles, fileArticles...) //nolint:gocritic
 92	case r.Stdin:
 93		// Stdin only
 94		r.Progress.SetSubStage("Parsing stdin feed")
 95		if articles, err = feedAgg.ParseFeedFromStdin(); err != nil {
 96			return fmt.Errorf("error parsing stdin feed: %w", err)
 97		}
 98	case r.FeedsFile != "":
 99		// Feeds file only
100		r.Progress.SetSubStage("Parsing feeds from " + r.FeedsFile)
101		if articles, err = feedAgg.ParseFeedsFromFile(r.FeedsFile); err != nil {
102			return fmt.Errorf("error aggregating feeds: %w", err)
103		}
104	default:
105		return errors.New("no feed source specified: use --feeds-file or --stdin")
106	}
107
108	if len(articles) == 0 {
109		return errors.New("no articles found after parsing feeds")
110	}
111	r.Progress.SetArticleCount(len(articles), 0)
112
113	// Step 2: Process content
114	r.Progress.SetStage("Processing articles")
115	r.Progress.SetSubStage(fmt.Sprintf("Filtering and sorting %d articles", len(articles)))
116
117	contentProc := processor.NewContentProcessor(
118		r.MaxTotalArticles,
119		3000, // max content per article
120		r.IncludeKeywords,
121		r.ExcludeKeywords,
122	)
123	contentProc.SetLogger(progCtx) // Pass the new progress context
124
125	processedArticles := contentProc.ProcessArticles(articles, "date", true)
126
127	if len(processedArticles) == 0 {
128		return errors.New("no articles passed filtering")
129	}
130	r.Progress.SetArticleCount(len(articles), len(articles)-len(processedArticles))
131
132	r.Articles = processedArticles
133
134	// Estimate tokens for progress tracking
135	tokenEst := contentProc.EstimateTokenCount(processedArticles, r.Model)
136	r.Progress.SetTokenEstimate(tokenEst, tokenEst)
137
138	// Step 3: Initialise LLM client
139	r.Progress.SetStage("Connecting to LLM")
140	r.Progress.SetSubStage("Using model: " + r.Model)
141
142	llm, err := llm.NewLLMClient(
143		r.APIKey,
144		r.BaseURL,
145		r.Model,
146		r.MaxTokens,
147		r.Temperature,
148		r.LLMTimeout,
149	)
150	if err != nil {
151		return fmt.Errorf("error initialising LLM client: %w", err)
152	}
153	llm.SetLogger(progCtx) // Pass the new progress context
154
155	// Step 4: Get summary from LLM
156	r.Progress.SetStage("Getting summary")
157	r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles)))
158	r.Progress.StartWaiting()
159
160	// callCtx wraps the parent context with a timeout derived from LLMTimeout.
161	// Both signal cancellation and timeout will abort the LLM call.
162	callCtx := ctx
163	if r.LLMTimeout > 0 {
164		var cancel context.CancelFunc
165		callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.LLMTimeout)*time.Second)
166		defer cancel()
167	}
168
169	summary, usage, err := llm.SummariseArticles(
170		processedArticles,
171		r.Prompt,
172		r.SystemPrompt,
173		callCtx,
174	)
175	if err != nil {
176		return fmt.Errorf("error getting summary from LLM: %w", err)
177	}
178
179	r.Summary = summary
180	if usage != nil {
181		r.Progress.SetTokenEstimate(tokenEst, tokenEst+usage.CompletionTokens)
182	}
183	return nil
184}
185
186// WasInterrupted reports whether the runtime was interrupted by a signal during execution.
187func (r *Runtime) WasInterrupted() bool {
188	return r.Interrupted
189}
190func (r *Runtime) WriteOutput(writer io.Writer) error {
191	if r.Plain {
192		if _, err := fmt.Fprint(writer, r.Summary); err != nil {
193			return fmt.Errorf("error writing plain output: %w", err)
194		}
195		return nil
196	}
197
198	formatter, err := output.NewFormatter(r.Output)
199	if err != nil {
200		return fmt.Errorf("error creating formatter: %w", err)
201	}
202
203	outputData := map[string]any{
204		"title":          "LLM Aggregator Summary - " + time.Now().Format("2006-01-02 15:04"),
205		"prompt":         r.Prompt,
206		"model":          r.Model,
207		"articles_count": len(r.Articles),
208		"summary":        r.Summary,
209		"timestamp":      time.Now().Format(time.RFC3339),
210	}
211
212	if r.IncludeArticles {
213		outputData["articles"] = r.Articles
214	}
215
216	formattedOutput, err := formatter.FormatData(outputData)
217	if err != nil {
218		return fmt.Errorf("error formatting output: %w", err)
219	}
220
221	if _, err := fmt.Fprint(writer, formattedOutput); err != nil {
222		return fmt.Errorf("error writing output: %w", err)
223	}
224
225	return nil
226}
227
228// WriteOutputToFile writes the output to the specified file
229func (r *Runtime) WriteOutputToFile() error {
230	if r.OutputFile == "" {
231		return r.WriteOutput(os.Stdout)
232	}
233
234	file, err := os.Create(r.OutputFile)
235	if err != nil {
236		return fmt.Errorf("error creating output file: %w", err)
237	}
238	defer file.Close() //nolint:errcheck
239
240	return r.WriteOutput(file)
241}