Skip to content

feat: add keywords CLI tool for text vectorization (#122)#163

Open
Yegorov wants to merge 2 commits into
cardmagic:masterfrom
Yegorov:122
Open

feat: add keywords CLI tool for text vectorization (#122)#163
Yegorov wants to merge 2 commits into
cardmagic:masterfrom
Yegorov:122

Conversation

@Yegorov

@Yegorov Yegorov commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Closes #122

Comment thread lib/classifier/tfidf.rb
Comment thread test/streaming/multi_io_test.rb
Comment thread test/streaming/multi_io_test.rb
Comment thread lib/classifier/streaming/multi_io.rb
Comment thread lib/classifier/streaming/multi_io.rb
Comment thread test/keywords/cli_test.rb
Comment thread test/keywords/cli_test.rb
Comment thread test/keywords/cli_test.rb
Comment thread lib/classifier/keywords/cli.rb
Comment thread lib/classifier/keywords/cli.rb
@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a keywords CLI tool for TF-IDF-based keyword extraction, backed by a new MultiIO streaming adapter and small expansions to TFIDF's public API (min_df/max_df readers, a MultiIO RBS overload). The implementation is well-tested and the command-dispatch structure is clean.

  • lib/classifier/keywords/cli.rb introduces the main CLI class with fit, extract, info, and bare-text commands; the command_fit path opens file handles that are never explicitly closed, and number_with_delimiter has a typo in its keyword argument (delemiter:).
  • lib/classifier/streaming/multi_io.rb is a minimal, correct implementation that sequences multiple IO streams through each_line.
  • Several inline comments (# remove 'fit', # remove 'extract', # remove 'info', the UTF-8 preamble comment) restate exactly what the adjacent code already says and should be removed.

Confidence Score: 3/5

Safe to merge after addressing the unclosed file handles in command_fit; the rest of the changes are additive and well-tested.

The command_fit path calls File.open for each matched file and never closes those handles — on large corpora or under error conditions the descriptors stay open until GC collects them. The number_with_delimiter typo is a real defect in the method signature. Everything else (MultiIO, TFIDF attr_reader additions, tests) is straightforward and correct.

lib/classifier/keywords/cli.rb — specifically the file-handle management in command_fit and the keyword argument typo in number_with_delimiter.

Important Files Changed

Filename Overview
lib/classifier/keywords/cli.rb New CLI class for keyword extraction; has an unclosed file handle leak in command_fit, a typo in number_with_delimiter, redundant inline comments, and a nested conditional in command_extract
exe/keywords New CLI entry point; structurally sound but has a redundant comment above the UTF-8 encoding lines
lib/classifier/streaming/multi_io.rb New MultiIO class that sequences multiple IO streams; clean and minimal implementation
lib/classifier/tfidf.rb Exposes min_df and max_df via attr_reader and adds a MultiIO overload to the fit_from_stream RBS signature; no logic changes
test/keywords/cli_test.rb Comprehensive tests covering fit, extract, keywords, info, stdin, --help, --version, and invalid options
test/streaming/multi_io_test.rb Tests both the enumerator and block-based paths of MultiIO#each_line
.rubocop.yml Adds keywords/cli.rb to the existing BlockLength, MethodLength, and AbcSize exclusion lists, consistent with the existing cli.rb entries
lib/classifier/streaming.rb Adds require for the new multi_io.rb file; no other changes

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[exe/keywords: ARGV] --> B[CLI.new.run]
    B --> C[parse_options]
    C -->|--help / --version| D[throw :done → output result]
    C --> E[execute_command]
    E -->|exit_code != 0 or output present| F[return early]
    E -->|args.first == 'fit'| G[command_fit]
    E -->|args.first == 'extract'| H[command_extract]
    E -->|args.first == 'info'| I[command_info]
    E -->|else| J[command_keywords]
    G -->|args empty| G1[stdin / $stdin]
    G -->|args present| G2[Dir.glob → File.open streams]
    G1 & G2 --> G3[TFIDF#fit_from_stream via MultiIO]
    G3 --> G4[tfidf.save_to_file]
    H -->|args empty| H1[stdin / $stdin.read]
    H -->|file exists| H2[File.read]
    H -->|arg not a file| H3[treat arg as literal text]
    H1 & H2 & H3 --> H4[transform → @output]
    I --> I1[TFIDF.load_from_file → format stats → @output]
    J -->|no args, no stdin, tty?| J1[show_getting_started]
    J -->|otherwise| J2[stdin / @args.join → transform → @output]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[exe/keywords: ARGV] --> B[CLI.new.run]
    B --> C[parse_options]
    C -->|--help / --version| D[throw :done → output result]
    C --> E[execute_command]
    E -->|exit_code != 0 or output present| F[return early]
    E -->|args.first == 'fit'| G[command_fit]
    E -->|args.first == 'extract'| H[command_extract]
    E -->|args.first == 'info'| I[command_info]
    E -->|else| J[command_keywords]
    G -->|args empty| G1[stdin / $stdin]
    G -->|args present| G2[Dir.glob → File.open streams]
    G1 & G2 --> G3[TFIDF#fit_from_stream via MultiIO]
    G3 --> G4[tfidf.save_to_file]
    H -->|args empty| H1[stdin / $stdin.read]
    H -->|file exists| H2[File.read]
    H -->|arg not a file| H3[treat arg as literal text]
    H1 & H2 & H3 --> H4[transform → @output]
    I --> I1[TFIDF.load_from_file → format stats → @output]
    J -->|no args, no stdin, tty?| J1[show_getting_started]
    J -->|otherwise| J2[stdin / @args.join → transform → @output]
Loading
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
lib/classifier/keywords/cli.rb:128-134
**File handles opened but never closed**

`File.open(f)` inside `command_fit` creates file descriptors that are never explicitly closed. If `fit_from_stream` raises mid-stream, or if a large number of files is passed, these handles remain open until GC runs. Use `ensure` or restructure to close after use — for example, open, process, then close each stream, or collect the paths and let `MultiIO` open/close them internally.

### Issue 2 of 5
lib/classifier/keywords/cli.rb:246
Typo in the keyword argument name: `delemiter` should be `delimiter`. Even though this is a private method, the misspelling is inconsistent and would be confusing if the argument ever needs to be overridden internally.

```suggestion
      def number_with_delimiter(number, delimiter: ',')
```

### Issue 3 of 5
lib/classifier/keywords/cli.rb:125-126
These inline comments just restate what `@args.shift` already communicates through context (the enclosing method is named `command_fit`, `command_extract`, and `command_info`). Per the de-AI review rule, comments that repeat what the code already shows should be removed.

```suggestion
      def command_fit
        @args.shift
```

In this review, suggest simpl... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

### Issue 4 of 5
lib/classifier/keywords/cli.rb:149-159
Nested conditional in a value assignment. The inner `if File.exist?` inside the `else` branch can be flattened using an early assignment with a guard, making the logic easier to follow at a glance.

```suggestion
        if @args.empty?
          document = @stdin ? @stdin.to_s : $stdin.read
        else
          file = File.expand_path(@args.first)
          document = File.exist?(file) ? File.read(file) : @args.first
        end
```

In this review, suggest simpl... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

### Issue 5 of 5
exe/keywords:4-6
This comment restates exactly what the two `Encoding` assignments already express. Per the de-AI review rule, comments that repeat what the code shows should be removed.

```suggestion
Encoding.default_external = Encoding::UTF_8
Encoding.default_internal = Encoding::UTF_8
```

In this review, suggest simpl... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

Reviews (1): Last reviewed commit: "feat: add keywords CLI tool for text vec..." | Re-trigger Greptile

Comment thread lib/classifier/keywords/cli.rb
Comment thread lib/classifier/keywords/cli.rb Outdated
Comment thread lib/classifier/keywords/cli.rb Outdated
Comment thread lib/classifier/keywords/cli.rb
Comment thread exe/keywords
@output << 'General Options:'
@output << ' -m, --model FILE Model file (default: ./keywords.json)'
@output << ' -n, --top N Show top N terms only (e.g. keywords -n 5 "text...")'
@output << ' -q Quiet mode (clean output for scripting/pipelines)'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [95/80]

@output << ''
@output << 'General Options:'
@output << ' -m, --model FILE Model file (default: ./keywords.json)'
@output << ' -n, --top N Show top N terms only (e.g. keywords -n 5 "text...")'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [98/80]

@output << ' # Max DF: 1.0'
@output << ''
@output << 'General Options:'
@output << ' -m, --model FILE Model file (default: ./keywords.json)'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [83/80]

end

def show_getting_started
@output << 'Keywords - Keyword extraction and term analysis using TF-IDF'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [81/80]

transform(document)
end

def show_getting_started

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/AbcSize: Assignment Branch Condition size for show_getting_started is too high. [41/15]
Metrics/MethodLength: Method has too many lines. [41/10]

end
end

def execute_command

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/MethodLength: Method has too many lines. [12/10]


opts.on('--ngram MIN,MAX', Array, 'N-gram range (default: 1,1)') do |range|
invalid_range = range.count != 2 || !range.all? { |n| n =~ /\d+/ }
raise OptionParser::InvalidArgument, 'must have only 2 integers' if invalid_range

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [93/80]

@options[:max_df] = n
end

opts.on('--ngram MIN,MAX', Array, 'N-gram range (default: 1,1)') do |range|

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [85/80]

@options[:min_df] = n
end

opts.on('--max-df N', Float, 'Maximum document frequency ratio (default: 1.0)') do |n|

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [96/80]

@options[:top] = n
end

opts.on('--min-df N', Integer, 'Minimum document frequency (default: 1)') do |n|

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [90/80]

opts.separator ''
opts.separator 'Options:'

opts.on('-m', '--model FILE', 'Model file (default: ./keywords.json)') do |file|

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [90/80]

opts.banner = 'Usage: keywords [text] [options] [command] [arguments]'
opts.separator ''
opts.separator 'Commands:'
opts.separator ' fit <files...> Fit the model from files or stdin'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [81/80]

private

def parse_options
@parser = OptionParser.new do |opts|

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/BlockLength: Block has too many lines. [39/25]


private

def parse_options

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/AbcSize: Assignment Branch Condition size for parse_options is too high. [33.9/15]
Metrics/MethodLength: Method has too many lines. [44/10]

rescue StandardError => e
@error << "Error: #{e.message}"
@exit_code = 1
{ output: @output.join("\n"), error: @error.join("\n"), exit_code: @exit_code }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [87/80]

# @rbs @exit_code: Integer
# @rbs @parser: OptionParser

def initialize(args, stdin: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/MethodLength: Method has too many lines. [13/10]


module Classifier
module Keywords
class CLI

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/ClassLength: Class has too many lines. [200/100]
Style/Documentation: Missing top-level class documentation comment.

@@ -0,0 +1,252 @@
# rbs_inline: enabled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/FrozenStringLiteralComment: Missing magic comment # frozen_string_literal: true.


module Classifier
module Streaming
# A utility class that wraps multiple IO-like streams and treats them as a single,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [86/80]

@@ -0,0 +1,42 @@
# frozen_string_literal: true
# rbs_inline: enabled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/EmptyLineAfterMagicComment: Add an empty line after magic comments.

Comment thread test/keywords/cli_test.rb Outdated
end

def test_keywords_without_args
skip(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/MultilineIfModifier: Favor a normal unless-statement over a modifier clause in a multiline statement.

Comment thread test/keywords/cli_test.rb
require 'classifier/keywords/cli'

module Keywords
class CLITest < Minitest::Test

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/ClassLength: Class has too many lines. [122/100]

Comment thread test/keywords/cli_test.rb
require 'classifier/keywords/cli'

module Keywords
class CLITest < Minitest::Test

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/ClassLength: Class has too many lines. [124/100]

@Yegorov

Yegorov commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@cardmagic can you take a look, please?
houndci-bot comments look out of place as they go agains rubocop settings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add keywords CLI tool for text vectorization

2 participants