Skip to content

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

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

feat: add keywords CLI tool for text vectorization (#122)#163
Yegorov wants to merge 5 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 backed by a new Classifier::Keywords::CLI class that wraps the existing TF-IDF engine, enabling users to fit a vocabulary, extract weighted terms from text or files, and inspect model statistics from the command line.

  • New keywords binary and CLI class (exe/keywords, lib/classifier/keywords/cli.rb): full command dispatch (fit, extract, info, bare text), option parsing with OptionParser, streaming multi-file support via a new MultiIO wrapper, and structured output/exit-code handling.
  • New MultiIO and stem_to_word_hash helpers (lib/classifier/streaming/multi_io.rb, lib/classifier/extensions/word_hash.rb): MultiIO sequences multiple IO streams as one, used by fit_from_stream; stem_to_word_hash maps stem symbols back to their most-frequent original form so the CLI can display human-readable terms.
  • Supporting updates: classifier.gemspec registers the new executable, .rubocop.yml exempts the new CLI file from length/complexity cops, TFIDF exposes min_df/max_df readers, and LineReader type annotations are broadened to accept MultiIO.

Confidence Score: 5/5

  • This PR is safe to merge — all changes are additive, no existing behaviour is altered, and the new code is well-tested.
  • The new keywords command and its supporting classes are cleanly isolated from existing classifiers. File handle cleanup was addressed in earlier iterations, stream routing is correct, and the 240-line test suite exercises every command path and option-validation edge case. The only findings are minor style simplifications.
  • No files require special attention.

Important Files Changed

Filename Overview
lib/classifier/keywords/cli.rb New CLI class for TF-IDF keyword extraction; well-structured with proper error handling and streaming support, but contains two redundant @exit_code = 0 assignments in the version/help option handlers.
lib/classifier/extensions/word_hash.rb Adds stem_to_word_hash and its private helper; the helper contains a double-negation guard (next unless !cond) and a two-pass hash value extraction that can be simplified to transform_values!.
lib/classifier/streaming/multi_io.rb New utility class that sequences multiple IO streams into one; clean, minimal, and correctly returns an Enumerator when called without a block.
exe/keywords New binary entrypoint that mirrors exe/classifier; correctly delegates to the CLI class and forwards exit code, stdout, and stderr.
lib/classifier/tfidf.rb Exposes min_df and max_df via attr_reader and updates the @rbs signature for fit_from_stream to accept MultiIO; minimal, safe change.
test/keywords/cli_test.rb Comprehensive test suite covering all CLI commands, option validation, error paths, and edge cases; thorough and well-organized.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([keywords CLI invoked]) --> B[parse_options]
    B --> C{early exit?\n-h / -v}
    C -- yes --> D([output & exit 0])
    C -- no --> E[execute_command]
    E --> F{command?}
    F -- fit --> G[command_fit]
    F -- extract --> H[command_extract]
    F -- info --> I[command_info]
    F -- bare text / default --> J[command_keywords]

    G --> G1{args empty?}
    G1 -- yes --> G2[stdin / StringIO]
    G1 -- no --> G3[File.open each path]
    G2 & G3 --> G4[MultiIO]
    G4 --> G5[TFIDF#fit_from_stream]
    G5 --> G6[save_to_file]

    H --> H1{args empty?}
    H1 -- yes --> H2[stdin text]
    H1 -- no --> H3{file exists?}
    H3 -- yes --> H4[File.read]
    H3 -- no --> H5[use arg as text]
    H2 & H4 & H5 --> TF

    J --> J1{no args\nno stdin\ntty?}
    J1 -- yes --> J2([show_getting_started])
    J1 -- no --> J3[build document string]
    J3 --> TF

    I --> I1[TFIDF#load_from_file]
    I1 --> I2([format & output stats])

    TF[transform document] --> T1[stem_to_word_hash]
    T1 --> T2[TFIDF#load_from_file]
    T2 --> T3[TFIDF#transform → sort]
    T3 --> T4([output term:score pairs])
Loading

Reviews (3): Last reviewed commit: "fix: greptile comments (#122)" | Re-trigger Greptile

Comment thread lib/classifier/keywords/cli.rb Outdated
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
Comment thread lib/classifier/keywords/cli.rb Outdated
@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]

Comment thread lib/classifier/keywords/cli.rb Outdated

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]

Comment thread lib/classifier/keywords/cli.rb Outdated
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.

@cardmagic cardmagic left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Deep review

Solid, well-tested implementation that tracks the issue closely: separate tool, transform-as-default, stdin-friendly, reuses TFIDF, clean command dispatch. MultiIO is minimal and correct, the LineReader integration is sound (it consumes via each_line, and estimate_line_count is guarded so multi-file fit degrades gracefully), rubocop is clean, and CI/typecheck are green. The earlier Greptile items all landed in commit 2. Verified locally by building + installing the gem and driving the CLI through ~25 scenarios.

One blocking issue and a ring of robustness/spec-fidelity gaps:

🔴 Blocking — keywords doesn't install. classifier.gemspec (not touched by this PR) still has s.executables = ['classifier']. exe/keywords ships inside the gem via s.files, but RubyGems only generates a binstub for names in executables. I built and installed the gem into an isolated GEM_HOME: only classifier appears in bin/, no keywords. So after gem install classifier, the tool the issue asks for isn't on PATH. Fix: s.executables = %w[classifier keywords]. The tests miss this because they call Classifier::Keywords::CLI directly instead of the installed binary. (Inline note left on exe/keywords.)

Robustness (details inline): fit silently saves an empty model (exit 0) when a glob matches nothing, when an explicitly-named file doesn't exist, or on empty stdin; --ngram validation accepts garbage like 1abc,2xyz; usage errors split between exit 1 and exit 2; raw Ruby exceptions leak to users (missing model, directory arg, negative -n).

Spec deviations vs #122: -q is effectively a no-op for the primary transform output; output tokens are Porter-stemmed (rubi, eleg, program) rather than the whole words the issue's examples show; fit granularity is one-document-per-line, which drives IDF and diverges from "build vocabulary from files".

Docs: README has a ## Command Line section for classifier but nothing for the new keywords command — it ships undiscoverable.

None of this is architectural; all are small, localized fixes. Minimum before merge: the gemspec executables line and the silent-empty-model guard.

Comment thread exe/keywords
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 lib/classifier/keywords/cli.rb
Comment thread lib/classifier/keywords/cli.rb
def mapping_stem_to_word_for_words(words, min_word_length)
h = {}
words.map { _1.tap(&:downcase!) }.tally.each do |word, count|
next unless !CORPUS_SKIP_WORDS.include?(word) && word.length >= min_word_length

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]

end

# @rbs (Array[String], Integer) -> Hash[Symbol, Integer]
def mapping_stem_to_word_for_words(words, min_word_length)

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 mapping_stem_to_word_for_words is too high. [17.97/15]

word_hash_for_words(gsub(/[^\w\s]/, '').split, min_word_length)
end

# Builds a mapping between stemmed roots and their most frequent original words.

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. [82/80]

Comment thread lib/classifier/keywords/cli.rb Outdated
tfidf = TFIDF.load_from_file(@options[:model])
vector = tfidf.transform(document).sort_by { |_, v| v }.reverse
vector = vector.first(@options[:top]) if @options[:top]
@output << vector.map { |k, v| "#{stem_map[k]}:#{v.round(2)}" }.join(' ')

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]

@output << 'Run "keywords --help" for full usage.'
end

def transform(document)

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 transform is too high. [17.58/15]

end

opts.on('--ngram MIN,MAX', Array, 'N-gram range (default: 1,1)') do |range|
raise OptionParser::InvalidArgument, 'requires exactly two values' if range.count != 2

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]

end

opts.on('-n', '--top N', Integer, 'Show top N terms only') do |n|
raise OptionParser::InvalidArgument, 'must be positive' unless n.positive?

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]

opts.banner = 'Usage: keywords [text] [options] [command] [arguments]'
opts.separator ''
opts.separator 'Commands:'
opts.separator ' fit <files...> Fit the model from files or stdin (each line is treated as a separate document)'

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. [127/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. [42/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. [40.61/15]
Metrics/CyclomaticComplexity: Cyclomatic complexity for parse_options is too high. [7/6]
Metrics/MethodLength: Method has too many lines. [47/10]

@Yegorov
Yegorov requested a review from cardmagic July 21, 2026 14:44
@cardmagic

Copy link
Copy Markdown
Owner

Hi @Yegorov could you please iterate on the greptile comments until greptile gives this a 5/5 rating. Thank you!

@exit_code = 0
end

def run

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]


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. [211/100]
Style/Documentation: Missing top-level class documentation comment.

def test_stem_to_word_hash
hash = { rubi: 'ruby', program: 'programming', eleg: 'elegance', mean: 'means', defin: 'defines' }

assert_equal(hash, 'Ruby programming is elegant. Ruby means elegance. Elegance defines Ruby'.stem_to_word_hash)

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. [115/80]

end

def test_stem_to_word_hash
hash = { rubi: 'ruby', program: 'programming', eleg: 'elegance', mean: 'means', defin: 'defines' }

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. [102/80]

Comment thread test/keywords/cli_test.rb

def test_keywords_command_output_original_words
make_model
result = run_cli('-m', @model_path, 'Dogs and cats are great. Large dog. Smart dog')

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]

Comment thread test/keywords/cli_test.rb

def test_fit_command_with_invalid_ngram_value_not_integers
result = run_cli(
'-m', @model_path, '--min-df', '2', '--max-df', '0.5', '--ngram', '1abc,2xyz',

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]

Comment thread test/keywords/cli_test.rb

def test_fit_command_with_invalid_ngram_three_value
result = run_cli(
'-m', @model_path, '--min-df', '2', '--max-df', '0.5', '--ngram', '1,2,3',

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. [82/80]

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. [189/100]

@Yegorov

Yegorov commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@greptile-apps review this PR, please.

Comment thread lib/classifier/keywords/cli.rb Outdated
if @args.empty?
streams = [@stdin ? StringIO.new(@stdin.to_s) : $stdin]
else
files = @args.map { |arg| Dir.glob(arg).map { |f| File.expand_path(f) } }.flatten.uniq

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]

end
end

def command_fit

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 command_fit is too high. [30.68/15]
Metrics/MethodLength: Method has too many lines. [19/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. [210/100]
Style/Documentation: Missing top-level class documentation comment.

@Yegorov

Yegorov commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@greptile-apps can you write summary with confidence score, please.

@Yegorov

Yegorov commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Hello @cardmagic, all greptile comments fixed. Can you take a look, please?

@cardmagic cardmagic left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Follow-up QA pass

Re-verified every item from my earlier review against the current head (38d85a5) by building the gem and installing it into an isolated GEM_HOME, then driving the real keywords binary through ~35 scenarios.

All previously-raised items are fixed — the gemspec executables blocker (binstub confirmed present after gem install), the silent-empty-model saves, --ngram validation, exit-code consistency, the raw-exception leaks, and the README section. Output is now de-stemmed (elegant:0.62 programming:0.51 ruby:0.43), which matches the examples in #122. Tests pass (697 runs, 0 failures) and rubocop is clean. Also withdrawing my earlier -q note — suppressing chatter while keeping data output is the correct Unix semantics.

One new regression turned up, introduced by the de-stemming fix in a85bfae, plus two smaller inconsistencies. Details inline.

Minimum before merge: the label_for fix on transform.

Comment thread lib/classifier/keywords/cli.rb Outdated
Comment thread lib/classifier/keywords/cli.rb Outdated
Comment thread lib/classifier/keywords/cli.rb
end

def label_for(key, stem_map)
key.to_s.split('_').map { |part| stem_map[part.to_sym] || part }.join(' ')

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. [82/80]

tfidf = TFIDF.load_from_file(@options[:model])
vector = tfidf.transform(document).sort_by { |_, v| v }.reverse
vector = vector.first(@options[:top]) if @options[:top]
@output << vector.map { |k, v| "#{label_for(k, stem_map)}:#{v.round(2)}" }.join(' ')

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. [92/80]

@output << 'Run "keywords --help" for full usage.'
end

def transform(document)

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 transform is too high. [15.56/15]

transform(document)
end

def command_info

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]

@stdin ? @stdin.to_s : $stdin.read
else
file = File.expand_path(@args.first)
raise UsageError, "File #{file.inspect} does not exists" unless File.exist?(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. [93/80]


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. [217/100]
Style/Documentation: Missing top-level class documentation comment.

Comment thread test/keywords/cli_test.rb
assert_match('must be positive', result[:error])
end

def test_keywords_command_with_bigram_model

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 test_keywords_command_with_bigram_model is too high. [16.03/15]

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. [246/100]

@Yegorov
Yegorov requested a review from cardmagic August 10, 2026 14:39

@cardmagic cardmagic left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

QA pass on 351f570

Re-verified by building the gem and installing it into an isolated GEM_HOME, then driving the real keywords binary through ~45 scenarios.

The blocking label_for regression is fixed. Confirmed across all three cases from my last review:

$ keywords -m ng.json "machine learning neural networks"    # bigram model
learning neural:0.48 machine learning:0.4 machine:0.4 neural networks:0.34 networks:0.34 ...

$ keywords -m mwl.json "go to db elegant store"             # model with min_word_length: 2
store:0.56 elegant:0.56 db:0.43 go:0.43

$ keywords -m uni.json "Ruby programming is elegant"        # unigram, unchanged
elegant:0.58 programming:0.58 ruby:0.58

Thanks for adding test_keywords_command_with_bigram_model. The extract typo guard and the info model guard are both fixed and now exit 2 with the friendly message. Everything from the earlier rounds still holds under regression check: gemspec binstub, empty-model guards, --ngram validation, -n positivity, README. Suite is 701 runs / 0 failures, rubocop clean, CI green.

Two new findings, both in command_fit, details inline. The first is the one I would like fixed before merge.

Also still open, lower priority:

  • The directory-argument errno leak from my earlier info comment was marked fixed but only the model guard half landed. keywords fit -m dir.json corpus still prints Error: Is a directory @ io_fillbuf - fd:6 ... and keywords extract corpus prints Error: Is a directory @ io_fread - ..., both exit 1.
  • --min-df -5 and --max-df 9.9 are usage errors but exit 1, since the message surfaces from TFIDF.new. --ngram bounds errors correctly exit 2. Same split we just fixed for --ngram, relocated.
  • label_for splits on _ before trying the whole key, so a literal snake_case token in the source text renders mangled: machine_learning prints as machine learn. One-line fix is to try stem_map[key.to_sym] first and fall back to the split.
  • Typo: "File #{file.inspect} does not exists" should be does not exist.
  • My own suggestion has a wrinkle worth a conscious decision: joining n-gram parts with a space puts a space inside labels (machine learning:0.32) in an otherwise space-separated term:score stream, which is ambiguous for scripting. Not asking you to change it, just flagging that we chose it.

if @args.empty?
streams = [@stdin ? StringIO.new(@stdin.to_s) : $stdin]
else
files = @args.map { |arg| Dir.glob(arg).map { |f| File.expand_path(f) } }.flatten.uniq

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nonexistent paths are silently dropped whenever any other file is valid. Dir.glob returns [] for a path that does not exist, and the zero-document guard on line 155 only fires when nothing at all was read. So a typo in one of several arguments produces a smaller model and reports success:

$ keywords fit -m mix.json corpus/a.txt corpus/NOPE.txt
Saved to "/path/mix.json"
$ echo $?
0
$ keywords info -m mix.json
Documents: 2                # a.txt only; a+b+c would be 6

keywords fit corpus/*.txt archive/*.txt with a wrong second path silently fits half the corpus, exit 0. Since IDF depends on the document set, the resulting model is quietly wrong rather than obviously broken.

This also makes the tool internally inconsistent as of this commit: command_extract now hard-fails with a UsageError on a nonexistent path (which is the right call), while command_fit ignores the identical typo. Same user error, two behaviors in one CLI.

Suggested fix, raise per argument rather than only on the aggregate:

files = @args.flat_map do |arg|
  matches = Dir.glob(arg)
  raise UsageError, "No files matched #{arg.inspect}" if matches.empty?

  matches.map { |f| File.expand_path(f) }
end.uniq

That keeps the exit-2 behavior for the all-empty case too, so the guard on line 155 then only needs to cover genuinely empty input (empty stdin, empty files).

streams = [@stdin ? StringIO.new(@stdin.to_s) : $stdin]
else
files = @args.map { |arg| Dir.glob(arg).map { |f| File.expand_path(f) } }.flatten.uniq
files.each { |f| streams << File.open(f) }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Every input file is opened at once, so large corpora hit the file-descriptor limit. command_fit materializes the full streams array before MultiIO reads a single line, so peak open descriptors equals the corpus size:

$ ( ulimit -n 256; keywords fit -m many.json many/*.txt )   # 600 files
Error: Too many open files @ rb_sysopen - /path/many/d324.txt
$ echo $?
1

Stock Linux ulimit -n is 1024, so a corpus of roughly a thousand files breaks the workflow this PR's own README leads with (keywords fit corpus/*.txt). macOS masks it because the default limit is much higher, which is probably why it has not shown up.

The fix is the one Greptile originally gestured at: give MultiIO the paths and let it open and close one stream at a time. Something like:

class MultiIO
  def initialize(sources)
    @sources = sources.dup
  end

  def each_line
    return enum_for(:each_line) unless block_given?

    @sources.each do |source|
      if source.respond_to?(:each_line)
        source.each_line { |line| yield line }
      else
        File.open(source) { |io| io.each_line { |line| yield line } }
      end
    end
  end
end

Then command_fit passes paths for the file case and the single $stdin / StringIO object for the stdin case, and the ensure block on line 160 goes away entirely, since File.open with a block closes on its own and on exception. That also resolves the handle-leak thread above more robustly than the current manual bookkeeping.

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

3 participants