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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module SemanticRouter
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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,2 @@
1
+ [tools]
2
+ ruby = "3.3.10"
@@ -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: []