all repos — llm_aggregator @ 13547fc860356e4f64bfa097f7681fdd90f33df7

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

feat: add verbose flag to control progress and status messages

- Make CLI silent by default; only final output and errors are displayed
  - Move all progress logs, warnings, and debug messages behind
    `-v/--verbose`
- Introduce logger interface (`progress.Context`) for consistent logging
  - Add `NoopLogger` for silent mode, `SimpleLogger` for verbose output
- Update components to use logger instead of direct `fmt.Print` calls
  - `aggregator.FeedAggregator` accepts logger via
    `NewFeedAggregatorWithProgress`
  - `processor.ContentProcessor` gains `SetLogger` method
  - `llm.DeepseekClient` gains `SetLogger` method
- Modify `runtime.Runtime` to create appropriate logger from `Verbose`
  flag
- Update CLI flag description: `--verbose` now says "Show verbose
  output"
- Remove redundant conditional logging blocks (if verbose) from
  `cmd/llm_aggregator.go`
- Ensure token estimates, filtering summaries, and API call logs only
  appear in verbose mode
Maxwell Jensen 85795372+maxwelljens@users.noreply.github.com
Tue, 21 Apr 2026 15:41:41 +0200
commit

13547fc860356e4f64bfa097f7681fdd90f33df7

parent

7b6943f1a3f252697b0e16d94c9635a5e064090b

M CHANGELOG.mdCHANGELOG.md

@@ -8,6 +8,11 @@ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased] +### Changed +- Moved all progress and status messages behind `-v/--verbose` flag +- Default CLI mode is now silent except for final output and errors +- Components (aggregator, processor, LLM client) use logger interface controlled by verbose flag + ## [0.1.0] - 2026-04-21 ### Added

@@ -43,4 +48,4 @@ - Initial API endpoint mismatch (using `/responses` instead of `/chat/completions`)

- Nil pointer dereferences when handling optional feed metadata (author, dates) - String repetition syntax errors in Go (replaced `"="*80` with `strings.Repeat`) - CLI help/version flag handling to show information without requiring other arguments -- Type compatibility issues with `openai-go` v3 API (message parameters, token usage fields)+- Type compatibility issues with `openai-go` v3 API (message parameters, token usage fields)
M cmd/llm_aggregator.gocmd/llm_aggregator.go

@@ -49,6 +49,7 @@ rt.SystemPrompt = args.SystemPrompt

rt.Output = args.Output rt.OutputFile = args.OutputFile rt.IncludeArticles = args.IncludeArticles + rt.Verbose = args.Verbose // Validate API key if rt.APIKey == "" {

@@ -136,9 +137,8 @@ }

} func runWithoutTUI(rt *runtime.Runtime, verbose bool) { - if verbose { - fmt.Printf("Aggregating feeds from: %s\n", rt.FeedsFile) - } + // Set verbose flag on runtime + rt.Verbose = verbose // Execute the runtime err := rt.Execute(context.Background())

@@ -147,29 +147,17 @@ fmt.Fprintf(os.Stderr, "Error: %v\n", err)

os.Exit(1) } - if verbose { - fmt.Printf("Aggregated %d articles\n", len(rt.Articles)) - fmt.Printf("Requesting summary from DeepSeek with model: %s\n", rt.Model) - } - // Write output if rt.OutputFile != "" { if err := rt.WriteOutputToFile(); err != nil { fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) os.Exit(1) - } - if verbose { - fmt.Printf("Output written to: %s\n", rt.OutputFile) } } else { if err := rt.WriteOutput(os.Stdout); err != nil { fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) os.Exit(1) } - } - - if verbose { - fmt.Println("Processing completed successfully") } }
M internal/aggregator/aggregator.gointernal/aggregator/aggregator.go

@@ -67,25 +67,13 @@ feedURLs = append(feedURLs, line)

} } - if fa.progressCtx != nil { - fa.progressCtx.Logf("Found %d feed URLs in %s", len(feedURLs), filePath) - } else { - fmt.Printf("Found %d feed URLs in %s\n", len(feedURLs), filePath) - } + fa.progressCtx.Logf("Found %d feed URLs in %s", len(feedURLs), filePath) for i, feedURL := range feedURLs { - if fa.progressCtx != nil { - fa.progressCtx.Logf("Processing feed %d/%d: %s", i+1, len(feedURLs), feedURL) - } else { - fmt.Printf("Processing feed %d/%d: %s\n", i+1, len(feedURLs), feedURL) - } + fa.progressCtx.Logf("Processing feed %d/%d: %s", i+1, len(feedURLs), feedURL) feedArticles, err := fa.ParseFeed(feedURL) if err != nil { - if fa.progressCtx != nil { - fa.progressCtx.Warningf("Failed to parse feed %s: %v", feedURL, err) - } else { - fmt.Printf("Warning: Failed to parse feed %s: %v\n", feedURL, err) - } + fa.progressCtx.Warningf("Failed to parse feed %s: %v", feedURL, err) continue } articles = append(articles, feedArticles...)

@@ -109,11 +97,7 @@ if feedTitle == "" {

feedTitle = feedURL } - if fa.progressCtx != nil { - fa.progressCtx.Logf("Parsing feed: %s (%d entries)", feedTitle, len(feed.Items)) - } else { - fmt.Printf("Parsing feed: %s (%d entries)\n", feedTitle, len(feed.Items)) - } + fa.progressCtx.Logf("Parsing feed: %s (%d entries)", feedTitle, len(feed.Items)) var cutoffTime time.Time if fa.maxDaysOld > 0 {

@@ -125,11 +109,7 @@

for i, item := range feed.Items[:maxItems] { article, err := fa.extractArticle(item, feedTitle, cutoffTime) if err != nil { - if fa.progressCtx != nil { - fa.progressCtx.Warningf("Failed to extract article %d from %s: %v", i, feedURL, err) - } else { - fmt.Printf("Warning: Failed to extract article %d from %s: %v\n", i, feedURL, err) - } + fa.progressCtx.Warningf("Failed to extract article %d from %s: %v", i, feedURL, err) continue } if article != nil {

@@ -157,11 +137,7 @@ published := fa.parsePublishedDate(item)

// Check if article is too old if !cutoffTime.IsZero() && !published.IsZero() && published.Before(cutoffTime) { - if fa.progressCtx != nil { - fa.progressCtx.Debugf("Skipping old article: %s (%s)", title, published.Format("2006-01-02")) - } else { - fmt.Printf("Skipping old article: %s (%s)\n", title, published.Format("2006-01-02")) - } + fa.progressCtx.Debugf("Skipping old article: %s (%s)", title, published.Format("2006-01-02")) return nil, nil }
M internal/cli/args.gointernal/cli/args.go

@@ -41,7 +41,7 @@

// System options SystemPrompt string `arg:"--system-prompt" help:"Custom system prompt for Deepseek"` TUI bool `arg:"--tui" help:"Enable TUI interface with progress bar"` - Verbose bool `arg:"-v,--verbose" help:"Enable verbose logging"` + Verbose bool `arg:"-v,--verbose" help:"Show verbose output"` ShowVersion bool `arg:"--version" help:"Show version"` }

@@ -115,4 +115,3 @@ }

return &args, nil } -
M internal/llm/deepseek.gointernal/llm/deepseek.go

@@ -7,6 +7,8 @@ "os"

"strings" "time" + "llm_aggregator/internal/progress" + "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" )

@@ -17,6 +19,7 @@ client openai.Client

model string maxTokens int temperature float64 + logger *progress.Context } // NewDeepseekClient creates a new Deepseek client.

@@ -57,14 +60,17 @@ option.WithAPIKey(apiKey),

option.WithBaseURL(baseURL), ) - fmt.Printf("Initialised Deepseek client with model: %s\n", model) - return &DeepseekClient{ client: client, model: model, maxTokens: maxTokens, temperature: temperature, }, nil +} + +// SetLogger sets the logger for the Deepseek client +func (dc *DeepseekClient) SetLogger(logger *progress.Context) { + dc.logger = logger } // SummariseArticles summarises a list of articles based on user prompt.

@@ -168,7 +174,9 @@

func (dc *DeepseekClient) callAPIWithMessages(messages []openai.ChatCompletionMessageParamUnion) (string, error) { ctx := context.Background() - fmt.Printf("Calling Deepseek API with model: %s\n", dc.model) + if dc.logger != nil { + dc.logger.Logf("Calling Deepseek API with model: %s", dc.model) + } response, err := dc.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ Model: dc.model,

@@ -200,11 +208,13 @@

outputText := response.Choices[0].Message.Content // Print token usage - fmt.Printf( - "Deepseek API response: %d prompt tokens, %d completion tokens\n", - response.Usage.PromptTokens, - response.Usage.CompletionTokens, - ) + if dc.logger != nil { + dc.logger.Logf( + "Deepseek API response: %d prompt tokens, %d completion tokens", + response.Usage.PromptTokens, + response.Usage.CompletionTokens, + ) + } return outputText, nil }
M internal/processor/processor.gointernal/processor/processor.go

@@ -7,6 +7,7 @@ "strings"

"time" "llm_aggregator/internal/aggregator" + "llm_aggregator/internal/progress" ) // ContentProcessor processes and prepares aggregated content for LLM analysis.

@@ -15,6 +16,7 @@ maxTotalArticles int

maxContentPerArticle int filterKeywords []string excludeKeywords []string + logger *progress.Context } // NewContentProcessor creates a new ContentProcessor with the specified options.

@@ -36,12 +38,19 @@ maxContentPerArticle: maxContentPerArticle,

filterKeywords: filterLower, excludeKeywords: excludeLower, } +} + +// SetLogger sets the logger for the processor +func (cp *ContentProcessor) SetLogger(logger *progress.Context) { + cp.logger = logger } // ProcessArticles processes articles: filter, sort, and prepare for LLM. func (cp *ContentProcessor) ProcessArticles(articles []*aggregator.Article, sortBy string, reverse bool) []map[string]any { if len(articles) == 0 { - fmt.Println("Warning: No articles to process") + if cp.logger != nil { + cp.logger.Logf("Warning: No articles to process") + } return []map[string]any{} }

@@ -53,14 +62,18 @@ sortedArticles := cp.sortArticles(filteredArticles, sortBy, reverse)

// Limit total articles if len(sortedArticles) > cp.maxTotalArticles { - fmt.Printf("Limiting articles from %d to %d\n", len(sortedArticles), cp.maxTotalArticles) + if cp.logger != nil { + cp.logger.Logf("Limiting articles from %d to %d", len(sortedArticles), cp.maxTotalArticles) + } sortedArticles = sortedArticles[:cp.maxTotalArticles] } // Prepare articles for LLM processed := cp.prepareForLLM(sortedArticles) - fmt.Printf("Processed %d articles (from %d original)\n", len(processed), len(articles)) + if cp.logger != nil { + cp.logger.Logf("Processed %d articles (from %d original)", len(processed), len(articles)) + } return processed }

@@ -80,7 +93,9 @@ if len(cp.excludeKeywords) > 0 {

articleText := strings.ToLower(article.Title + " " + article.Content) for _, keyword := range cp.excludeKeywords { if strings.Contains(articleText, keyword) { - fmt.Printf("Excluding article due to keyword '%s': %s\n", keyword, article.Title) + if cp.logger != nil { + cp.logger.Logf("Excluding article due to keyword '%s': %s", keyword, article.Title) + } include = false break }

@@ -104,10 +119,12 @@ filtered = append(filtered, article)

} } - fmt.Printf( - "Filtered %d articles to %d (inclusion: %v, exclusion: %v)\n", - len(articles), len(filtered), cp.filterKeywords, cp.excludeKeywords, - ) + if cp.logger != nil { + cp.logger.Logf( + "Filtered %d articles to %d (inclusion: %v, exclusion: %v)", + len(articles), len(filtered), cp.filterKeywords, cp.excludeKeywords, + ) + } return filtered }

@@ -226,7 +243,9 @@

// Rough estimate: 1 token ≈ 4 characters for English estimatedTokens := totalChars / 4 - fmt.Printf("Estimated tokens: %d (~%d chars)\n", estimatedTokens, totalChars) + if cp.logger != nil { + cp.logger.Logf("Estimated tokens: %d (~%d chars)", estimatedTokens, totalChars) + } return estimatedTokens }

@@ -281,10 +300,12 @@ articleTokens := len(articleText) / 4

// Check if adding this article would exceed limit if currentTokens+articleTokens > maxTotalTokens { - fmt.Printf( - "Reached token limit (%d). Included %d of %d articles.\n", - maxTotalTokens, i, len(articles), - ) + if cp.logger != nil { + cp.logger.Logf( + "Reached token limit (%d). Included %d of %d articles.", + maxTotalTokens, i, len(articles), + ) + } break }

@@ -294,7 +315,9 @@ }

context := strings.Join(contextParts, "\n---\n") - fmt.Printf("Created context with %d articles, ~%d tokens\n", len(contextParts), currentTokens) + if cp.logger != nil { + cp.logger.Logf("Created context with %d articles, ~%d tokens", len(contextParts), currentTokens) + } return context }
M internal/runtime/runtime.gointernal/runtime/runtime.go

@@ -11,6 +11,7 @@ "llm_aggregator/internal/aggregator"

"llm_aggregator/internal/llm" "llm_aggregator/internal/output" "llm_aggregator/internal/processor" + "llm_aggregator/internal/progress" ) // Runtime holds the execution context for the aggregator

@@ -31,11 +32,15 @@ SystemPrompt string

Output string OutputFile string IncludeArticles bool + Verbose bool // State Articles []map[string]any Summary string Error error + + // Logger for verbose output + logger *progress.Context } // NewRuntime creates a new runtime with default values

@@ -54,11 +59,19 @@ }

// Execute runs the full aggregation pipeline func (r *Runtime) Execute(ctx context.Context) error { + // Setup logger based on verbose flag + if r.Verbose { + r.logger = progress.NewContext(progress.NewSimpleLogger(os.Stdout, true)) + } else { + r.logger = progress.NewContext(&progress.NoopLogger{}) + } + // Step 1: Aggregate feeds - feedAgg := aggregator.NewFeedAggregator( + feedAgg := aggregator.NewFeedAggregatorWithProgress( r.MaxArticlesPerFeed, r.MaxDaysOld, 5000, // max content length + r.logger, ) articles, err := feedAgg.ParseFeedsFromFile(r.FeedsFile)

@@ -77,6 +90,7 @@ 3000, // max content per article

r.IncludeKeywords, r.ExcludeKeywords, ) + contentProc.SetLogger(r.logger) processedArticles := contentProc.ProcessArticles(articles, "date", true)

@@ -97,6 +111,7 @@ )

if err != nil { return fmt.Errorf("error initialising Deepseek client: %w", err) } + deepseek.SetLogger(r.logger) // Step 4: Get summary from Deepseek summary, err := deepseek.SummariseArticles(