all repos — llm_aggregator @ 29a4710e981512a82abba77ec8d7b43e413453e7

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