Skip to content

fix: router keyword matching now uses whole words, not substrings - #36

Merged
Amayyas merged 1 commit into
mainfrom
fix/router-word-boundary-matching
Jul 30, 2026
Merged

fix: router keyword matching now uses whole words, not substrings#36
Amayyas merged 1 commit into
mainfrom
fix/router-word-boundary-matching

Conversation

@Amayyas

@Amayyas Amayyas commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • Router#coding_task? matched keywords as substrings of the raw message, so "classical"/"classic" matched "class" and "errors" matched "error" — misrouting research-flavored questions to :coder. Verified empirically before fixing (see linked issue).
  • Switched to splitting the message into words and checking Array#intersect?, which is also the semantically correct call now that both sides are genuinely arrays (previously it had to be disabled via rubocop comment because the receiver was a String).
  • Added regression specs for the fixed cases, plus one spec documenting a residual, inherent limitation (the word "function" is genuinely ambiguous even as a whole word) rather than silently leaving it uncovered.

Closes #3

Test plan

  • bundle exec rspec — 34/34 passing (30 previous + 4 new regression cases)
  • gem build chorus-llm.gemspec — builds cleanly
  • CI green (tests, RuboCop, bundler-audit, build)

Summary by CodeRabbit

  • Bug Fixes

    • Improved message routing so coding-related keywords are recognized only when they appear as complete words.
    • Prevented unrelated words containing coding keywords from being incorrectly routed to the coding assistant.
  • Tests

    • Added coverage for keyword-substring routing scenarios and expected routing behavior.

CODER_KEYWORDS.any? { |k| normalized.include?(k) } matched substrings,
so "classical"/"classic" matched "class" and "errors" matched "error",
misrouting research questions to :coder. Verified empirically before
the fix (all 4 examples below misrouted):

  "What is the classical explanation for gravity?"
  "Can you summarize this classic novel?"
  "How does a democracy function in practice?"
  "What errors did historians identify in this account?"

Switched to splitting the message into words and checking array
intersection, which also makes Array#intersect? the actually-correct
call (previously disabled here because the receiver was a String).

The "function" case remains ambiguous even as a whole word ("a
function" vs. "how it functions") — that's a real limitation of
keyword matching, not something this fix can resolve, and is now a
documented regression spec rather than a silent gap. Tracked
separately as a follow-up to replace routing with an LLM classifier.

Closes #3
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Chorus::Router#coding_task? now matches coder keywords as whole words rather than substrings. Router specs cover previously misrouted research messages and document an existing ambiguous standalone keyword case.

Changes

Router matching

Layer / File(s) Summary
Whole-word routing and regression coverage
lib/chorus/router.rb, spec/chorus/router_spec.rb
coding_task? tokenizes messages and checks whole-word keyword intersection; regression cases verify that words such as “classical,” “classic,” and “errors” route to :research.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The whole-word fix and regression specs address most examples, but the 'function' case still routes to :coder, missing one acceptance criterion. Update coding_task? so all four reported examples, including 'function', route to :research and add matching regression coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: whole-word router keyword matching instead of substring matching.
Out of Scope Changes check ✅ Passed The changes stay focused on router matching logic and its specs, with no obvious unrelated code introduced.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/router-word-boundary-matching

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Amayyas Amayyas self-assigned this Jul 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/chorus/router.rb`:
- Around line 34-35: Update the tokenization in the router logic around
CODER_KEYWORDS to use a Unicode-aware word pattern such as \p{Word}+ so accented
words remain single tokens, and add a regression case covering Unicode input
like “classé” to verify coder keyword matching is not triggered by partial ASCII
tokenization.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a0fbb52-58ec-427d-8b41-ea9204cf1f42

📥 Commits

Reviewing files that changed from the base of the PR and between c8ae45e and 3dc01ff.

📒 Files selected for processing (2)
  • lib/chorus/router.rb
  • spec/chorus/router_spec.rb

Comment thread lib/chorus/router.rb
Comment on lines +34 to +35
words = message.downcase.scan(/\w+/)
CODER_KEYWORDS.intersect?(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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Ruby version:"
ruby -v

echo
echo "Locate router.rb:"
fd -a 'router\.rb$' . | sed 's#^\./##'

echo
echo "Relevant file excerpt:"
sed -n '1,120p' lib/chorus/router.rb 2>/dev/null || true

echo
echo "Unicode tokenization behavior:"
ruby - <<'RB'
messages = [
  "What does classé mean?",
  "Check the function.",
  "classé",
  "café résumé naïve",
]
messages.each do |message|
  puts message.inspect
  puts "  \\w+ => #{message.downcase.scan(/\w+/).inspect}"
  puts "  Unicode \\p{Word}+ => #{message.downcase.scan(/\p{Word}+/).inspect}"
end
RB

Repository: Amayyas/Chorus

Length of output: 2335


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ruby -E utf-8 -v - <<'RB'
messages = [
  "What does classé mean?",
  "Check the function.",
  "classé",
  "café résumé naïve",
  "I use the classical approach.",
]
messages.each do |message|
  puts message.inspect
  puts "  UTF8\\w+ => #{message.downcase.scan(/\w+/).inspect}"
  puts "  UTF8\\p{Word}+ => #{message.downcase.scan(/\p{Word}+/).inspect}"
end
RB

python3 - <<'PY'
import unicodedata
samples = ["what does classé mean?", "café résumé naïve", "I use the classical approach."]
for s in samples:
    words_w = []
    cur = []
    for ch in s:
        cat = unicodedata.category(ch)
        if cat.startswith('L') or cat.startswith('N') or ch in '_':
            cur.append(ch)
        else:
            if cur:
                words_w.append("".join(cur))
                cur = []
    if cur:
        words_w.append("".join(cur))
    print(s)
    print("  Unicode word segments:", words_w)
PY

Repository: Amayyas/Chorus

Length of output: 710


🌐 Web query:

Ruby Regexp /w/ Unicode property \p{Word} documentation

💡 Result:

In Ruby regular expressions, \p{Word} is a Unicode property that matches characters defined as word characters according to Unicode standards [1][2]. As of recent Ruby versions, \p{Word} (and the equivalent POSIX-style bracket expression [[:word:]]) matches a character if it belongs to one of the following Unicode categories or has one of the specified Unicode properties [1][2]: Unicode Categories: - Mark (M) [2] - Decimal Number (Nd) [2] - Connector Punctuation (Pc) [2] Unicode Properties: - Alpha (Alphabetic characters) [2] - Join_Control (specifically U+200C and U+200D) [2] Documentation Note: Historically, documentation for \p{Word} was less precise or incomplete regarding the specific Unicode categories and properties included [3][2][4]. Recent updates (e.g., in Ruby 3.3+) have aligned the documentation with current implementations and Unicode Technical Standard #18, which defines word characters to include alphabetic values, decimal numbers, and join control characters [2][5]. Be aware that bugs or inconsistencies between the documented behavior and the actual implementation of the underlying Unicode character database can occasionally occur across different Ruby versions [3][4]. Comparison with \w: - \w: In Ruby, \w is typically equivalent to [a-zA-Z0-9_] (when not using the /u flag for full Unicode support or when relying on standard ASCII behavior), though its exact behavior can vary depending on the regex engine's configuration and the version of Ruby [6]. - \p{Word}: Unlike \w, \p{Word} is specifically designed for Unicode-aware matching, providing a much broader set of characters that conform to global linguistic standards for what constitutes a word character [1][6].

Citations:


🌐 Web query:

site:ruby-doc.org/core Regexp \w Unicode \p{Word}

💡 Result:

In regular expression engines, the \w token and \p{Word} property serve different purposes regarding character matching, particularly concerning Unicode support [1][2]. \w (Word Character) The \w shorthand character class typically matches a limited set of ASCII characters. In most standard regex implementations—including Ruby's—it is equivalent to the character set [a-zA-Z0-9_] [1][3]. While some modern regex engines (such as those in PCRE or certain language-specific implementations) may be configured to treat \w as Unicode-aware, it is historically and primarily defined as matching ASCII letters, digits, and underscores [1][4]. \p{Word} (Unicode Property) The \p{Word} construct is a Unicode property escape [1][2]. It matches any character that is considered a "word" character according to the Unicode standard [1][4]. This is significantly broader than \w because it includes characters from various scripts, symbols, and marks [1][3]. Specifically, it matches any character belonging to the following Unicode General Categories [1][2]: - Letter (L) - Mark (M) - Number (N) - Connector_Punctuation (Pc) While \w is useful for matching technical identifiers constrained to ASCII, \p{Word} is the correct choice for internationalized applications requiring support for a wide range of global languages and character sets [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp)"
cat > "$tmp" <<'RB'
messages = [
  "What does classé mean?",
  "Check the function.",
  "classé",
  "café résumé naïve",
  "I use the classical approach.",
]
messages.each do |message|
  puts message.inspect
  puts "  UTF8\\w+ => #{message.downcase.scan(/\w+/).inspect}"
  puts "  UTF8\\p{Word}+ => #{message.downcase.scan(/\p{Word}+/).inspect}"
end
RB

ruby -E utf-8 -v "$tmp"

Repository: Amayyas/Chorus

Length of output: 777


Make tokenization Unicode-aware.

Ruby’s \w tokenizes classé as ["class", "mean"], causing accented words to still match :coder keywords. Use a Unicode word property such as /\p{Word}+/ and add a regression case for Unicode input.

Proposed fix
-      words = message.downcase.scan(/\w+/)
+      words = message.downcase.scan(/\p{Word}+/)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
words = message.downcase.scan(/\w+/)
CODER_KEYWORDS.intersect?(words)
words = message.downcase.scan(/\p{Word}+/)
CODER_KEYWORDS.intersect?(words)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/chorus/router.rb` around lines 34 - 35, Update the tokenization in the
router logic around CODER_KEYWORDS to use a Unicode-aware word pattern such as
\p{Word}+ so accented words remain single tokens, and add a regression case
covering Unicode input like “classé” to verify coder keyword matching is not
triggered by partial ASCII tokenization.

@Amayyas
Amayyas merged commit de1a455 into main Jul 30, 2026
7 checks passed
@Amayyas
Amayyas deleted the fix/router-word-boundary-matching branch July 30, 2026 10:23
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.

Router misroutes messages due to substring keyword matching

1 participant