all repos — llm_aggregator @ 9193c5f19131e5fc0d91414c8aa23d036bfb0aa0

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/llm"
 12	"llm_aggregator/internal/output"
 13	"llm_aggregator/internal/processor"
 14	"llm_aggregator/internal/progress"
 15)
 16
 17// Runtime holds the execution context for the aggregator
 18type Runtime struct {
 19	// Configuration
 20	FeedsFile          string
 21	MaxArticlesPerFeed int
 22	MaxDaysOld         int
 23	MaxTotalArticles   int
 24	IncludeKeywords    []string
 25	ExcludeKeywords    []string
 26	APIKey             string
 27	Model              string
 28	MaxTokens          int
 29	Temperature        float64
 30	Prompt             string
 31	SystemPrompt       string
 32	Output             string
 33	OutputFile         string
 34	IncludeArticles    bool
 35	Verbose            bool
 36
 37	// State
 38	Articles []map[string]any
 39	Summary  string
 40	Error    error
 41
 42	// Logger for verbose output
 43	Progress progress.Progress
 44}
 45
 46// NewRuntime creates a new runtime with default values
 47func NewRuntime() *Runtime {
 48	return &Runtime{
 49		MaxArticlesPerFeed: 10,
 50		MaxDaysOld:         7,
 51		MaxTotalArticles:   50,
 52		Model:              "deepseek-chat",
 53		MaxTokens:          2000,
 54		Temperature:        0.7,
 55		Output:             "text",
 56		Progress:           &progress.NoopLogger{},
 57	}
 58}
 59
 60// Execute runs the full aggregation pipeline
 61func (r *Runtime) Execute(ctx context.Context) error {
 62	// The logger/progress handler is injected, so we don't create it here.
 63	// We wrap it in a context to pass to sub-modules that expect a *progress.Context.
 64	progCtx := progress.NewContext(r.Progress)
 65
 66	// Step 1: Aggregate feeds
 67	r.Progress.SetStage("Aggregating feeds")
 68	r.Progress.SetSubStage(fmt.Sprintf("Parsing feeds from %s", r.FeedsFile))
 69
 70	feedAgg := aggregator.NewFeedAggregatorWithProgress(
 71		r.MaxArticlesPerFeed,
 72		r.MaxDaysOld,
 73		5000,    // max content length
 74		progCtx, // MODIFIED: Pass the new progress context
 75	)
 76
 77	articles, err := feedAgg.ParseFeedsFromFile(r.FeedsFile)
 78	if err != nil {
 79		return fmt.Errorf("error aggregating feeds: %w", err)
 80	}
 81	if len(articles) == 0 {
 82		return fmt.Errorf("no articles found after parsing feeds")
 83	}
 84	r.Progress.SetArticleCount(len(articles), 0)
 85
 86	// Step 2: Process content
 87	r.Progress.SetStage("Processing articles")
 88	r.Progress.SetSubStage(fmt.Sprintf("Filtering and sorting %d articles", len(articles)))
 89
 90	contentProc := processor.NewContentProcessor(
 91		r.MaxTotalArticles,
 92		3000, // max content per article
 93		r.IncludeKeywords,
 94		r.ExcludeKeywords,
 95	)
 96	contentProc.SetLogger(progCtx) // MODIFIED: Pass the new progress context
 97
 98	processedArticles := contentProc.ProcessArticles(articles, "date", true)
 99
100	if len(processedArticles) == 0 {
101		return fmt.Errorf("no articles passed filtering")
102	}
103	r.Progress.SetArticleCount(len(articles), len(articles)-len(processedArticles))
104
105	r.Articles = processedArticles
106
107	// Step 3: Initialise LLM client
108	r.Progress.SetStage("Connecting to LLM")
109	r.Progress.SetSubStage(fmt.Sprintf("Using model: %s", r.Model))
110
111	deepseek, err := llm.NewLLMClient(
112		r.APIKey,
113		"", // default base URL
114		r.Model,
115		r.MaxTokens,
116		r.Temperature,
117	)
118	if err != nil {
119		return fmt.Errorf("error initialising LLM client: %w", err)
120	}
121	deepseek.SetLogger(progCtx) // MODIFIED: Pass the new progress context
122
123	// Step 4: Get summary from LLM
124	r.Progress.SetStage("Getting summary")
125	r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles)))
126
127	summary, err := deepseek.SummariseArticles(
128		processedArticles,
129		r.Prompt,
130		r.SystemPrompt,
131	)
132	if err != nil {
133		return fmt.Errorf("error getting summary from LLM: %w", err)
134	}
135
136	r.Summary = summary
137	r.Progress.SetArticleCount(len(processedArticles), len(processedArticles))
138	return nil
139}
140
141// WriteOutput writes the formatted output to the specified writer
142func (r *Runtime) WriteOutput(writer io.Writer) error {
143	formatter, err := output.NewFormatter(r.Output)
144	if err != nil {
145		return fmt.Errorf("error creating formatter: %w", err)
146	}
147
148	outputData := map[string]any{
149		"title":          fmt.Sprintf("LLM Aggregator Summary - %s", time.Now().Format("2006-01-02 15:04")),
150		"prompt":         r.Prompt,
151		"model":          r.Model,
152		"articles_count": len(r.Articles),
153		"summary":        r.Summary,
154		"timestamp":      time.Now().Format(time.RFC3339),
155	}
156
157	if r.IncludeArticles {
158		outputData["articles"] = r.Articles
159	}
160
161	formattedOutput, err := formatter.FormatData(outputData)
162	if err != nil {
163		return fmt.Errorf("error formatting output: %w", err)
164	}
165
166	if _, err := fmt.Fprint(writer, formattedOutput); err != nil {
167		return fmt.Errorf("error writing output: %w", err)
168	}
169
170	return nil
171}
172
173// WriteOutputToFile writes the output to the specified file
174func (r *Runtime) WriteOutputToFile() error {
175	if r.OutputFile == "" {
176		return r.WriteOutput(os.Stdout)
177	}
178
179	file, err := os.Create(r.OutputFile)
180	if err != nil {
181		return fmt.Errorf("error creating output file: %w", err)
182	}
183	defer file.Close()
184
185	return r.WriteOutput(file)
186}