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
@@ -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)
@@ -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") } }
@@ -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 }
@@ -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 } -
@@ -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 }
@@ -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 }
@@ -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(