all repos — llm_aggregator @ 7b6943f1a3f252697b0e16d94c9635a5e064090b

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