rubyllm-semantic_router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +21 -0
- data/.rspec +3 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +68 -0
- data/LICENSE.txt +21 -0
- data/README.md +262 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/rubyllm/semantic_router/configuration.rb +27 -0
- data/lib/rubyllm/semantic_router/errors.rb +59 -0
- data/lib/rubyllm/semantic_router/router.rb +462 -0
- data/lib/rubyllm/semantic_router/routing_decision.rb +68 -0
- data/lib/rubyllm/semantic_router/strategies/base.rb +57 -0
- data/lib/rubyllm/semantic_router/strategies/semantic.rb +244 -0
- data/lib/rubyllm/semantic_router/version.rb +7 -0
- data/lib/rubyllm/semantic_router.rb +43 -0
- data/mise.toml +2 -0
- data/rubyllm-semantic_router.gemspec +39 -0
- metadata +122 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module SemanticRouter
|
|
5
|
+
module Strategies
|
|
6
|
+
# Semantic routing strategy using embeddings and kNN search
|
|
7
|
+
#
|
|
8
|
+
# This strategy:
|
|
9
|
+
# 1. Generates an embedding for the user's message
|
|
10
|
+
# 2. Finds the nearest routing examples using cosine similarity
|
|
11
|
+
# 3. Routes to the agent associated with the best match
|
|
12
|
+
# 4. Falls back if confidence is below threshold
|
|
13
|
+
class Semantic < Base
|
|
14
|
+
def route(message, agents:, examples:, current_agent:, config:, find_examples: nil)
|
|
15
|
+
# If custom find_examples provided, use it
|
|
16
|
+
# Otherwise, check if we have examples to search
|
|
17
|
+
has_search = find_examples.respond_to?(:call) ||
|
|
18
|
+
(examples && !examples_empty?(examples))
|
|
19
|
+
|
|
20
|
+
unless has_search
|
|
21
|
+
return apply_fallback(
|
|
22
|
+
config: config,
|
|
23
|
+
current_agent: current_agent,
|
|
24
|
+
default_agent: config.default_agent
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generate embedding for the message
|
|
29
|
+
embedding = generate_embedding(message, config.embedding_model)
|
|
30
|
+
|
|
31
|
+
# Find nearest neighbors using custom search or built-in
|
|
32
|
+
matches = if find_examples.respond_to?(:call)
|
|
33
|
+
find_with_custom_search(find_examples, embedding, config.k_neighbors)
|
|
34
|
+
else
|
|
35
|
+
find_nearest_neighbors(examples, embedding, config)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# No matches found
|
|
39
|
+
if matches.empty?
|
|
40
|
+
return apply_fallback(
|
|
41
|
+
config: config,
|
|
42
|
+
current_agent: current_agent,
|
|
43
|
+
default_agent: config.default_agent
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get best match and calculate confidence
|
|
48
|
+
best_match = matches.first
|
|
49
|
+
confidence = calculate_confidence(best_match)
|
|
50
|
+
|
|
51
|
+
# Check threshold
|
|
52
|
+
if confidence < config.similarity_threshold
|
|
53
|
+
return apply_fallback(
|
|
54
|
+
config: config,
|
|
55
|
+
current_agent: current_agent,
|
|
56
|
+
default_agent: config.default_agent
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Return semantic match decision
|
|
61
|
+
RoutingDecision.new(
|
|
62
|
+
agent: extract_agent_name(best_match),
|
|
63
|
+
confidence: confidence,
|
|
64
|
+
matched_example: extract_example_text(best_match),
|
|
65
|
+
reason: :semantic_match
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def examples_empty?(examples)
|
|
72
|
+
if examples.respond_to?(:empty?)
|
|
73
|
+
examples.empty?
|
|
74
|
+
elsif examples.respond_to?(:count)
|
|
75
|
+
examples.count.zero?
|
|
76
|
+
else
|
|
77
|
+
!examples || examples.to_a.empty?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def generate_embedding(message, model)
|
|
82
|
+
response = RubyLLM.embed(message, model: model)
|
|
83
|
+
vectors = response.vectors
|
|
84
|
+
# RubyLLM returns vector directly for single input, array of vectors for batch
|
|
85
|
+
vectors.first.is_a?(Array) ? vectors.first : vectors
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
raise EmbeddingError, e
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def find_nearest_neighbors(examples, embedding, config)
|
|
91
|
+
# Support both ActiveRecord (with neighbor gem) and in-memory arrays
|
|
92
|
+
if examples.respond_to?(:nearest_neighbors)
|
|
93
|
+
# ActiveRecord with neighbor gem
|
|
94
|
+
examples.nearest_neighbors(:embedding, embedding, distance: :cosine)
|
|
95
|
+
.limit(config.k_neighbors)
|
|
96
|
+
.to_a
|
|
97
|
+
else
|
|
98
|
+
# In-memory array - calculate distances manually
|
|
99
|
+
find_nearest_in_memory(examples.to_a, embedding, config.k_neighbors)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_with_custom_search(find_examples, embedding, limit)
|
|
104
|
+
# Call the custom search function
|
|
105
|
+
results = find_examples.call(embedding, limit: limit)
|
|
106
|
+
return [] if results.nil? || results.empty?
|
|
107
|
+
|
|
108
|
+
# Normalize results to have a consistent interface
|
|
109
|
+
results.map do |result|
|
|
110
|
+
CustomSearchMatch.new(result)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def find_nearest_in_memory(examples, query_embedding, k)
|
|
115
|
+
# Calculate cosine distance for each example
|
|
116
|
+
scored = examples.map do |example|
|
|
117
|
+
distance = cosine_distance(query_embedding, example.embedding)
|
|
118
|
+
[example, distance]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Sort by distance (ascending) and take top k
|
|
122
|
+
scored.sort_by { |_, distance| distance }
|
|
123
|
+
.first(k)
|
|
124
|
+
.map { |example, distance| InMemoryMatch.new(example, distance) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def cosine_distance(a, b)
|
|
128
|
+
# Cosine distance = 1 - cosine similarity
|
|
129
|
+
dot_product = a.zip(b).sum { |x, y| x * y }
|
|
130
|
+
magnitude_a = Math.sqrt(a.sum { |x| x**2 })
|
|
131
|
+
magnitude_b = Math.sqrt(b.sum { |x| x**2 })
|
|
132
|
+
|
|
133
|
+
return 1.0 if magnitude_a.zero? || magnitude_b.zero?
|
|
134
|
+
|
|
135
|
+
1.0 - (dot_product / (magnitude_a * magnitude_b))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def calculate_confidence(match)
|
|
139
|
+
# Convert cosine distance to similarity (confidence)
|
|
140
|
+
# Distance is 0-2 for cosine, similarity is 0-1
|
|
141
|
+
distance = extract_distance(match)
|
|
142
|
+
[1.0 - distance, 0.0].max
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def extract_distance(match)
|
|
146
|
+
if match.respond_to?(:neighbor_distance)
|
|
147
|
+
match.neighbor_distance
|
|
148
|
+
elsif match.respond_to?(:distance)
|
|
149
|
+
match.distance
|
|
150
|
+
else
|
|
151
|
+
1.0 # Maximum distance if we can't determine
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def extract_agent_name(match)
|
|
156
|
+
if match.respond_to?(:agent_name)
|
|
157
|
+
match.agent_name
|
|
158
|
+
elsif match.respond_to?(:example) && match.example.respond_to?(:agent_name)
|
|
159
|
+
match.example.agent_name
|
|
160
|
+
else
|
|
161
|
+
raise Error, "Cannot extract agent_name from match: #{match.inspect}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def extract_example_text(match)
|
|
166
|
+
if match.respond_to?(:example_text)
|
|
167
|
+
match.example_text
|
|
168
|
+
elsif match.respond_to?(:example) && match.example.respond_to?(:example_text)
|
|
169
|
+
match.example.example_text
|
|
170
|
+
elsif match.respond_to?(:text)
|
|
171
|
+
match.text
|
|
172
|
+
else
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Wrapper for in-memory matches to provide a consistent interface
|
|
178
|
+
class InMemoryMatch
|
|
179
|
+
attr_reader :example, :distance
|
|
180
|
+
|
|
181
|
+
def initialize(example, distance)
|
|
182
|
+
@example = example
|
|
183
|
+
@distance = distance
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def agent_name
|
|
187
|
+
example.agent_name
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def example_text
|
|
191
|
+
example.example_text
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def neighbor_distance
|
|
195
|
+
distance
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Wrapper for custom search results (from find_examples callable)
|
|
200
|
+
# Accepts hashes or objects with agent_name, example_text, distance/score
|
|
201
|
+
class CustomSearchMatch
|
|
202
|
+
attr_reader :result
|
|
203
|
+
|
|
204
|
+
def initialize(result)
|
|
205
|
+
@result = result
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def agent_name
|
|
209
|
+
fetch(:agent_name) || fetch(:agent)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def example_text
|
|
213
|
+
fetch(:example_text) || fetch(:text)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def distance
|
|
217
|
+
# Support both distance (lower is better) and score (higher is better)
|
|
218
|
+
if (d = fetch(:distance))
|
|
219
|
+
d
|
|
220
|
+
elsif (s = fetch(:score))
|
|
221
|
+
1.0 - s # Convert score to distance
|
|
222
|
+
else
|
|
223
|
+
0.0
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def neighbor_distance
|
|
228
|
+
distance
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def fetch(key)
|
|
234
|
+
if result.is_a?(Hash)
|
|
235
|
+
result[key] || result[key.to_s]
|
|
236
|
+
elsif result.respond_to?(key)
|
|
237
|
+
result.send(key)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "ruby_llm"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# ruby_llm not available, will need to be provided externally
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
require "neighbor"
|
|
11
|
+
rescue LoadError
|
|
12
|
+
# neighbor not available, in-memory mode only
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "semantic_router/version"
|
|
16
|
+
require_relative "semantic_router/errors"
|
|
17
|
+
require_relative "semantic_router/configuration"
|
|
18
|
+
require_relative "semantic_router/routing_decision"
|
|
19
|
+
require_relative "semantic_router/strategies/base"
|
|
20
|
+
require_relative "semantic_router/strategies/semantic"
|
|
21
|
+
require_relative "semantic_router/router"
|
|
22
|
+
|
|
23
|
+
module RubyLLM
|
|
24
|
+
module SemanticRouter
|
|
25
|
+
class << self
|
|
26
|
+
attr_accessor :configuration
|
|
27
|
+
|
|
28
|
+
def new(**options)
|
|
29
|
+
Router.new(**options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def configure
|
|
33
|
+
self.configuration ||= Configuration.new
|
|
34
|
+
yield(configuration) if block_given?
|
|
35
|
+
configuration
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset_configuration!
|
|
39
|
+
self.configuration = Configuration.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/mise.toml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path("../lib", __FILE__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require "rubyllm/semantic_router/version"
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = "rubyllm-semantic_router"
|
|
9
|
+
spec.version = RubyLLM::SemanticRouter::VERSION
|
|
10
|
+
spec.authors = ["Chris Hasiński"]
|
|
11
|
+
spec.email = ["krzysztof.hasinski@gmail.com"]
|
|
12
|
+
|
|
13
|
+
spec.summary = "Semantic routing for RubyLLM multi-agent systems"
|
|
14
|
+
spec.description = "Route messages to specialized RubyLLM chat agents based on semantic similarity using embeddings and kNN lookup"
|
|
15
|
+
spec.homepage = "https://github.com/khasinski/ruby_llm-semantic_router"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
19
|
+
|
|
20
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
|
25
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
26
|
+
end
|
|
27
|
+
spec.bindir = "exe"
|
|
28
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
29
|
+
spec.require_paths = ["lib"]
|
|
30
|
+
|
|
31
|
+
# Runtime dependencies
|
|
32
|
+
spec.add_dependency "ruby_llm", "~> 1.0"
|
|
33
|
+
|
|
34
|
+
# Development dependencies
|
|
35
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
|
36
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
37
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
|
38
|
+
# spec.add_development_dependency "neighbor", ">= 0.3" # Optional: for ActiveRecord tests
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubyllm-semantic_router
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Chris Hasiński
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-12-31 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: ruby_llm
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: bundler
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rspec
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.0'
|
|
69
|
+
description: Route messages to specialized RubyLLM chat agents based on semantic similarity
|
|
70
|
+
using embeddings and kNN lookup
|
|
71
|
+
email:
|
|
72
|
+
- krzysztof.hasinski@gmail.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- ".gitignore"
|
|
78
|
+
- ".rspec"
|
|
79
|
+
- Gemfile
|
|
80
|
+
- Gemfile.lock
|
|
81
|
+
- LICENSE.txt
|
|
82
|
+
- README.md
|
|
83
|
+
- Rakefile
|
|
84
|
+
- bin/console
|
|
85
|
+
- bin/setup
|
|
86
|
+
- lib/rubyllm/semantic_router.rb
|
|
87
|
+
- lib/rubyllm/semantic_router/configuration.rb
|
|
88
|
+
- lib/rubyllm/semantic_router/errors.rb
|
|
89
|
+
- lib/rubyllm/semantic_router/router.rb
|
|
90
|
+
- lib/rubyllm/semantic_router/routing_decision.rb
|
|
91
|
+
- lib/rubyllm/semantic_router/strategies/base.rb
|
|
92
|
+
- lib/rubyllm/semantic_router/strategies/semantic.rb
|
|
93
|
+
- lib/rubyllm/semantic_router/version.rb
|
|
94
|
+
- mise.toml
|
|
95
|
+
- rubyllm-semantic_router.gemspec
|
|
96
|
+
homepage: https://github.com/khasinski/ruby_llm-semantic_router
|
|
97
|
+
licenses:
|
|
98
|
+
- MIT
|
|
99
|
+
metadata:
|
|
100
|
+
homepage_uri: https://github.com/khasinski/ruby_llm-semantic_router
|
|
101
|
+
source_code_uri: https://github.com/khasinski/ruby_llm-semantic_router
|
|
102
|
+
changelog_uri: https://github.com/khasinski/ruby_llm-semantic_router/blob/main/CHANGELOG.md
|
|
103
|
+
post_install_message:
|
|
104
|
+
rdoc_options: []
|
|
105
|
+
require_paths:
|
|
106
|
+
- lib
|
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: 3.1.0
|
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
requirements: []
|
|
118
|
+
rubygems_version: 3.5.22
|
|
119
|
+
signing_key:
|
|
120
|
+
specification_version: 4
|
|
121
|
+
summary: Semantic routing for RubyLLM multi-agent systems
|
|
122
|
+
test_files: []
|