From 3dc01ff5a6b0d7550c60b256b2b06c4a507cade3 Mon Sep 17 00:00:00 2001 From: Amayyas Date: Thu, 30 Jul 2026 12:15:12 +0200 Subject: [PATCH] fix: match router keywords on whole words, not substrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/chorus/router.rb | 17 +++++++++++------ spec/chorus/router_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lib/chorus/router.rb b/lib/chorus/router.rb index 7bd9b12..f377ba8 100644 --- a/lib/chorus/router.rb +++ b/lib/chorus/router.rb @@ -21,13 +21,18 @@ def route(message) private + # Whole-word matching, not substring: a naive `include?` check would + # match "class" inside "classical" or "error" inside "errors", routing + # unrelated messages to :coder. Splitting into words first avoids that + # class of false positive. + # + # This doesn't make keyword matching perfect — a word like "function" is + # genuinely ambiguous ("a function" vs. "how it functions") and will + # still misroute some messages either way. That's an inherent limitation + # of keyword matching, not a bug; see the LLM-based router follow-up. def coding_task?(message) - normalized = message.downcase - # rubocop:disable Style/ArrayIntersect -- `normalized` is a String, not - # an Array; Array#intersect? would raise a TypeError here. This is a - # substring check on each keyword, not an array-element intersection. - CODER_KEYWORDS.any? { |keyword| normalized.include?(keyword) } - # rubocop:enable Style/ArrayIntersect + words = message.downcase.scan(/\w+/) + CODER_KEYWORDS.intersect?(words) end end end diff --git a/spec/chorus/router_spec.rb b/spec/chorus/router_spec.rb index 1ed4703..5284d59 100644 --- a/spec/chorus/router_spec.rb +++ b/spec/chorus/router_spec.rb @@ -24,4 +24,30 @@ it "is case-insensitive when matching keywords" do expect(router.route("THERE IS A BUG HERE")).to eq(:coder) end + + # Regression cases for a substring-matching bug: a naive `include?` check + # against the raw message matched "class" inside "classical"/"classic" and + # "error" inside "errors", misrouting these to :coder. + describe "whole-word matching (regression)" do + cases = { + "What is the classical explanation for gravity?" => :research, + "Can you summarize this classic novel?" => :research, + "What errors did historians identify in this account?" => :research + } + + cases.each do |message, expected_agent| + it "does not misroute #{message.inspect} on a keyword substring" do + expect(router.route(message)).to eq(expected_agent) + end + end + + it "documents a known residual limitation: standalone ambiguous keywords still misroute" do + # "function" is a whole word here, not a substring match, but it's + # inherently ambiguous ("a function" vs. "how it functions"). Keyword + # matching can't disambiguate that — only a semantic/LLM-based router + # can. This spec exists so the behavior is a documented, intentional + # limitation rather than a silent surprise. + expect(router.route("How does a democracy function in practice?")).to eq(:coder) + end + end end