all repos — llm_aggregator @ 13547fc860356e4f64bfa097f7681fdd90f33df7

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

internal/processor/processor.go (view raw)

  1package processor
  2
  3import (
  4	"fmt"
  5	"sort"
  6	"strings"
  7	"time"
  8
  9	"llm_aggregator/internal/aggregator"
 10	"llm_aggregator/internal/progress"
 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 new ContentProcessor with the specified options.
 23func NewContentProcessor(maxTotalArticles, maxContentPerArticle int, filterKeywords, excludeKeywords []string) *ContentProcessor {
 24	// Convert keywords to lowercase for case-insensitive matching
 25	filterLower := make([]string, len(filterKeywords))
 26	for i, kw := range filterKeywords {
 27		filterLower[i] = strings.ToLower(kw)
 28	}
 29
 30	excludeLower := make([]string, len(excludeKeywords))
 31	for i, kw := range excludeKeywords {
 32		excludeLower[i] = strings.ToLower(kw)
 33	}
 34
 35	return &ContentProcessor{
 36		maxTotalArticles:     maxTotalArticles,
 37		maxContentPerArticle: maxContentPerArticle,
 38		filterKeywords:       filterLower,
 39		excludeKeywords:      excludeLower,
 40	}
 41}
 42
 43// SetLogger sets the logger for the processor
 44func (cp *ContentProcessor) SetLogger(logger *progress.Context) {
 45	cp.logger = logger
 46}
 47
 48// ProcessArticles processes articles: filter, sort, and prepare for LLM.
 49func (cp *ContentProcessor) ProcessArticles(articles []*aggregator.Article, sortBy string, reverse bool) []map[string]any {
 50	if len(articles) == 0 {
 51		if cp.logger != nil {
 52			cp.logger.Logf("Warning: No articles to process")
 53		}
 54		return []map[string]any{}
 55	}
 56
 57	// Filter articles
 58	filteredArticles := cp.filterArticles(articles)
 59
 60	// Sort articles
 61	sortedArticles := cp.sortArticles(filteredArticles, sortBy, reverse)
 62
 63	// Limit total articles
 64	if len(sortedArticles) > cp.maxTotalArticles {
 65		if cp.logger != nil {
 66			cp.logger.Logf("Limiting articles from %d to %d", len(sortedArticles), cp.maxTotalArticles)
 67		}
 68		sortedArticles = sortedArticles[:cp.maxTotalArticles]
 69	}
 70
 71	// Prepare articles for LLM
 72	processed := cp.prepareForLLM(sortedArticles)
 73
 74	if cp.logger != nil {
 75		cp.logger.Logf("Processed %d articles (from %d original)", len(processed), len(articles))
 76	}
 77
 78	return processed
 79}
 80
 81func (cp *ContentProcessor) filterArticles(articles []*aggregator.Article) []*aggregator.Article {
 82	if len(cp.filterKeywords) == 0 && len(cp.excludeKeywords) == 0 {
 83		return articles
 84	}
 85
 86	filtered := []*aggregator.Article{}
 87
 88	for _, article := range articles {
 89		include := true
 90
 91		// Check if article should be excluded
 92		if len(cp.excludeKeywords) > 0 {
 93			articleText := strings.ToLower(article.Title + " " + article.Content)
 94			for _, keyword := range cp.excludeKeywords {
 95				if strings.Contains(articleText, keyword) {
 96					if cp.logger != nil {
 97						cp.logger.Logf("Excluding article due to keyword '%s': %s", keyword, article.Title)
 98					}
 99					include = false
100					break
101				}
102			}
103		}
104
105		// Check if article should be included (only if we have inclusion filters)
106		if include && len(cp.filterKeywords) > 0 {
107			articleText := strings.ToLower(article.Title + " " + article.Content)
108			include = false
109			for _, keyword := range cp.filterKeywords {
110				if strings.Contains(articleText, keyword) {
111					include = true
112					break
113				}
114			}
115		}
116
117		if include {
118			filtered = append(filtered, article)
119		}
120	}
121
122	if cp.logger != nil {
123		cp.logger.Logf(
124			"Filtered %d articles to %d (inclusion: %v, exclusion: %v)",
125			len(articles), len(filtered), cp.filterKeywords, cp.excludeKeywords,
126		)
127	}
128
129	return filtered
130}
131
132func (cp *ContentProcessor) sortArticles(articles []*aggregator.Article, sortBy string, reverse bool) []*aggregator.Article {
133	if len(articles) == 0 {
134		return articles
135	}
136
137	// Create a copy to avoid modifying the original slice
138	sortedArticles := make([]*aggregator.Article, len(articles))
139	copy(sortedArticles, articles)
140
141	// Define sort functions
142	switch strings.ToLower(sortBy) {
143	case "date":
144		sort.Slice(sortedArticles, func(i, j int) bool {
145			iTime := sortedArticles[i].Published
146			jTime := sortedArticles[j].Published
147			if iTime.IsZero() {
148				iTime = time.Time{}
149			}
150			if jTime.IsZero() {
151				jTime = time.Time{}
152			}
153			if reverse {
154				return iTime.After(jTime)
155			}
156			return iTime.Before(jTime)
157		})
158	case "title":
159		sort.Slice(sortedArticles, func(i, j int) bool {
160			iTitle := strings.ToLower(sortedArticles[i].Title)
161			jTitle := strings.ToLower(sortedArticles[j].Title)
162			if reverse {
163				return iTitle > jTitle
164			}
165			return iTitle < jTitle
166		})
167	case "source":
168		sort.Slice(sortedArticles, func(i, j int) bool {
169			iSource := strings.ToLower(sortedArticles[i].SourceFeed)
170			jSource := strings.ToLower(sortedArticles[j].SourceFeed)
171			if reverse {
172				return iSource > jSource
173			}
174			return iSource < jSource
175		})
176	default:
177		// Default to date sorting
178		sort.Slice(sortedArticles, func(i, j int) bool {
179			iTime := sortedArticles[i].Published
180			jTime := sortedArticles[j].Published
181			if iTime.IsZero() {
182				iTime = time.Time{}
183			}
184			if jTime.IsZero() {
185				jTime = time.Time{}
186			}
187			if reverse {
188				return iTime.After(jTime)
189			}
190			return iTime.Before(jTime)
191		})
192	}
193
194	return sortedArticles
195}
196
197func (cp *ContentProcessor) prepareForLLM(articles []*aggregator.Article) []map[string]any {
198	processed := make([]map[string]any, len(articles))
199
200	for i, article := range articles {
201		// Process content
202		content := article.Content
203		if len(content) > cp.maxContentPerArticle {
204			content = content[:cp.maxContentPerArticle] + "... [truncated]"
205		}
206
207		// Create map
208		articleMap := map[string]any{
209			"title":       article.Title,
210			"link":        article.Link,
211			"content":     content,
212			"author":      article.Author,
213			"source_feed": article.SourceFeed,
214			"summary":     article.Summary,
215		}
216
217		if !article.Published.IsZero() {
218			articleMap["published"] = article.Published
219		}
220
221		processed[i] = articleMap
222	}
223
224	return processed
225}
226
227// EstimateTokenCount estimates token count for articles (rough approximation).
228// Note: This is a rough estimate (1 token ≈ 4 characters for English).
229// Actual tokenisation may vary.
230func (cp *ContentProcessor) EstimateTokenCount(articles []map[string]any) int {
231	totalChars := 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 {
237					totalChars += len(str)
238				}
239			}
240		}
241	}
242
243	// Rough estimate: 1 token ≈ 4 characters for English
244	estimatedTokens := totalChars / 4
245
246	if cp.logger != nil {
247		cp.logger.Logf("Estimated tokens: %d (~%d chars)", estimatedTokens, totalChars)
248	}
249
250	return estimatedTokens
251}
252
253// CreateConciseContext creates a concise context from articles, respecting token limits.
254func (cp *ContentProcessor) CreateConciseContext(articles []map[string]any, maxTotalTokens int) string {
255	if len(articles) == 0 {
256		return "No articles available."
257	}
258
259	contextParts := []string{}
260	currentTokens := 0
261
262	for i, article := range articles {
263		// Create article summary
264		articleText := fmt.Sprintf("Article %d: %s\n", i+1, article["title"])
265
266		// Add source if available
267		if source, ok := article["source_feed"].(string); ok && source != "" {
268			articleText += fmt.Sprintf("Source: %s\n", source)
269		}
270
271		// Add publication date if available
272		if published, ok := article["published"]; ok {
273			switch pub := published.(type) {
274			case time.Time:
275				if !pub.IsZero() {
276					articleText += fmt.Sprintf("Published: %s\n", pub.Format("2006-01-02"))
277				}
278			case string:
279				articleText += fmt.Sprintf("Published: %s\n", pub)
280			}
281		}
282
283		// Add content (truncated if needed)
284		content := ""
285		if c, ok := article["content"].(string); ok {
286			content = c
287		}
288
289		maxContentTokens := maxTotalTokens / len(articles) // Rough allocation
290		maxContentChars := maxContentTokens * 4
291
292		if len(content) > maxContentChars {
293			content = content[:maxContentChars] + "... [truncated]"
294		}
295
296		articleText += fmt.Sprintf("Content: %s\n", content)
297
298		// Estimate tokens for this article
299		articleTokens := len(articleText) / 4
300
301		// Check if adding this article would exceed limit
302		if currentTokens+articleTokens > maxTotalTokens {
303			if cp.logger != nil {
304				cp.logger.Logf(
305					"Reached token limit (%d). Included %d of %d articles.",
306					maxTotalTokens, i, len(articles),
307				)
308			}
309			break
310		}
311
312		contextParts = append(contextParts, articleText)
313		currentTokens += articleTokens
314	}
315
316	context := strings.Join(contextParts, "\n---\n")
317
318	if cp.logger != nil {
319		cp.logger.Logf("Created context with %d articles, ~%d tokens", len(contextParts), currentTokens)
320	}
321
322	return context
323}
324