all repos — llm_aggregator @ 2e7ca2fff0eca5f948de3dec07502b00d7f03039

A CLI tool to aggregate RSS feeds and summarise them with LLMs

internal/processor/processor.go (view raw)

  1package processor
  2
  3import (
  4	"sort"
  5	"strings"
  6	"time"
  7
  8	"codeberg.org/maxwelljensen/llm_aggregator/internal/aggregator"
  9	"codeberg.org/maxwelljensen/llm_aggregator/internal/progress"
 10	"codeberg.org/maxwelljensen/llm_aggregator/internal/tokeniser"
 11)
 12
 13// ContentProcessor processes and prepares aggregated content for LLM analysis.
 14type ContentProcessor struct {
 15	maxTotalArticles     int
 16	maxContentPerArticle int
 17	filterKeywords       []string
 18	excludeKeywords      []string
 19	logger               *progress.Context
 20}
 21
 22// NewContentProcessor creates a processor that filters and truncates articles.
 23// Keyword comparison is case-insensitive.
 24func NewContentProcessor(maxTotalArticles, maxContentPerArticle int, filterKeywords, excludeKeywords []string) *ContentProcessor {
 25	// Convert keywords to lowercase for case-insensitive matching
 26	filterLower := make([]string, len(filterKeywords))
 27	for i, kw := range filterKeywords {
 28		filterLower[i] = strings.ToLower(kw)
 29	}
 30
 31	excludeLower := make([]string, len(excludeKeywords))
 32	for i, kw := range excludeKeywords {
 33		excludeLower[i] = strings.ToLower(kw)
 34	}
 35
 36	return &ContentProcessor{
 37		maxTotalArticles:     maxTotalArticles,
 38		maxContentPerArticle: maxContentPerArticle,
 39		filterKeywords:       filterLower,
 40		excludeKeywords:      excludeLower,
 41	}
 42}
 43
 44// SetLogger sets the logger for the processor
 45func (cp *ContentProcessor) SetLogger(logger *progress.Context) {
 46	cp.logger = logger
 47}
 48
 49// ProcessArticles applies keyword filtering, sorting, and a ceiling on total count,
 50// then converts each article to a map for the LLM.
 51func (cp *ContentProcessor) ProcessArticles(articles []*aggregator.Article, sortBy string, reverse bool) []map[string]any {
 52	if len(articles) == 0 {
 53		if cp.logger != nil {
 54			cp.logger.Logf("Warning: No articles to process")
 55		}
 56		return []map[string]any{}
 57	}
 58
 59	// Filter articles
 60	filteredArticles := cp.filterArticles(articles)
 61
 62	// Sort articles
 63	sortedArticles := cp.sortArticles(filteredArticles, sortBy, reverse)
 64
 65	// Limit total articles
 66	if len(sortedArticles) > cp.maxTotalArticles {
 67		if cp.logger != nil {
 68			cp.logger.Logf("Limiting articles from %d to %d", len(sortedArticles), cp.maxTotalArticles)
 69		}
 70		sortedArticles = sortedArticles[:cp.maxTotalArticles]
 71	}
 72
 73	// Prepare articles for LLM
 74	processed := cp.prepareForLLM(sortedArticles)
 75
 76	if cp.logger != nil {
 77		cp.logger.Logf("Processed %d articles (from %d original)", len(processed), len(articles))
 78	}
 79
 80	return processed
 81}
 82
 83// filterArticles applies include/exclude keyword filters to the article list.
 84// Exclusions take priority over inclusions. If no filters are set, all articles pass.
 85func (cp *ContentProcessor) filterArticles(articles []*aggregator.Article) []*aggregator.Article {
 86	if len(cp.filterKeywords) == 0 && len(cp.excludeKeywords) == 0 {
 87		return articles
 88	}
 89
 90	filtered := []*aggregator.Article{}
 91
 92	for _, article := range articles {
 93		include := true
 94
 95		if len(cp.excludeKeywords) > 0 {
 96			articleText := strings.ToLower(article.Title + " " + article.Content)
 97			for _, keyword := range cp.excludeKeywords {
 98				if strings.Contains(articleText, keyword) {
 99					if cp.logger != nil {
100						cp.logger.Logf("Excluding article due to keyword '%s': %s", keyword, article.Title)
101					}
102					include = false
103					break
104				}
105			}
106		}
107
108		if include && len(cp.filterKeywords) > 0 {
109			articleText := strings.ToLower(article.Title + " " + article.Content)
110			include = false
111			for _, keyword := range cp.filterKeywords {
112				if strings.Contains(articleText, keyword) {
113					include = true
114					break
115				}
116			}
117		}
118
119		if include {
120			filtered = append(filtered, article)
121		}
122	}
123
124	if cp.logger != nil {
125		cp.logger.Logf(
126			"Filtered %d articles to %d (inclusion: %v, exclusion: %v)",
127			len(articles), len(filtered), cp.filterKeywords, cp.excludeKeywords,
128		)
129	}
130
131	return filtered
132}
133
134func (cp *ContentProcessor) sortArticles(articles []*aggregator.Article, sortBy string, reverse bool) []*aggregator.Article {
135	if len(articles) == 0 {
136		return articles
137	}
138
139	sortedArticles := make([]*aggregator.Article, len(articles))
140	copy(sortedArticles, articles)
141
142	switch strings.ToLower(sortBy) {
143	case "date":
144		// Zero times sort to the end when reverse=false (oldest last)
145		sort.Slice(sortedArticles, func(i, j int) bool {
146			iTime := sortedArticles[i].Published
147			jTime := sortedArticles[j].Published
148			if iTime.IsZero() {
149				iTime = time.Time{}
150			}
151			if jTime.IsZero() {
152				jTime = time.Time{}
153			}
154			if reverse {
155				return iTime.After(jTime)
156			}
157			return iTime.Before(jTime)
158		})
159	case "title":
160		sort.Slice(sortedArticles, func(i, j int) bool {
161			iTitle := strings.ToLower(sortedArticles[i].Title)
162			jTitle := strings.ToLower(sortedArticles[j].Title)
163			if reverse {
164				return iTitle > jTitle
165			}
166			return iTitle < jTitle
167		})
168	case "source":
169		sort.Slice(sortedArticles, func(i, j int) bool {
170			iSource := strings.ToLower(sortedArticles[i].SourceFeed)
171			jSource := strings.ToLower(sortedArticles[j].SourceFeed)
172			if reverse {
173				return iSource > jSource
174			}
175			return iSource < jSource
176		})
177	default:
178		// Unknown sort key: fall back to date order
179		sort.Slice(sortedArticles, func(i, j int) bool {
180			iTime := sortedArticles[i].Published
181			jTime := sortedArticles[j].Published
182			if iTime.IsZero() {
183				iTime = time.Time{}
184			}
185			if jTime.IsZero() {
186				jTime = time.Time{}
187			}
188			if reverse {
189				return iTime.After(jTime)
190			}
191			return iTime.Before(jTime)
192		})
193	}
194
195	return sortedArticles
196}
197
198func (cp *ContentProcessor) prepareForLLM(articles []*aggregator.Article) []map[string]any {
199	processed := make([]map[string]any, len(articles))
200
201	for i, article := range articles {
202		// Process content
203		content := article.Content
204		if len(content) > cp.maxContentPerArticle {
205			content = content[:cp.maxContentPerArticle] + "... [truncated]"
206		}
207
208		// Create map
209		articleMap := map[string]any{
210			"title":       article.Title,
211			"link":        article.Link,
212			"content":     content,
213			"author":      article.Author,
214			"source_feed": article.SourceFeed,
215			"summary":     article.Summary,
216		}
217
218		if !article.Published.IsZero() {
219			articleMap["published"] = article.Published
220		}
221
222		processed[i] = articleMap
223	}
224
225	return processed
226}
227
228// EstimateTokenCount uses tiktoken to estimate total token count across all articles.
229// Falls back to charĂ·4 on encoding errors (logged as warnings).
230func (cp *ContentProcessor) EstimateTokenCount(articles []map[string]any, model string) int {
231	totalTokens := 0
232
233	for _, article := range articles {
234		for _, field := range []string{"title", "content", "author", "source_feed"} {
235			if val, ok := article[field]; ok && val != nil {
236				if str, ok := val.(string); ok && str != "" {
237					tokens, err := tokeniser.CountTokens(str, model)
238					if err != nil {
239						// Fallback to rough estimate on error
240						if cp.logger != nil {
241							cp.logger.Logf("Warning: token count error for %s: %v", field, err)
242						}
243						totalTokens += len(str) / 4
244						continue
245					}
246					totalTokens += tokens
247				}
248			}
249		}
250	}
251
252	if cp.logger != nil {
253		cp.logger.Logf("Estimated tokens: %d", totalTokens)
254	}
255
256	return totalTokens
257}