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,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module SemanticRouter
5
+ # Configuration object for the router
6
+ RouterConfig = Struct.new(
7
+ :embedding_model,
8
+ :similarity_threshold,
9
+ :k_neighbors,
10
+ :fallback,
11
+ :default_agent,
12
+ :scope,
13
+ keyword_init: true
14
+ )
15
+
16
+ # Internal representation of agent config (extracted from chat objects)
17
+ AgentConfig = Struct.new(:name, :instructions, :tools, :model, :temperature, keyword_init: true) do
18
+ def tools
19
+ self[:tools] || []
20
+ end
21
+ end
22
+
23
+ # Main router class that routes messages to specialized agents
24
+ #
25
+ # @example
26
+ # router = RubyLLM::SemanticRouter.new(
27
+ # agents: {
28
+ # product: RubyLLM.chat(model: "gpt-4o-mini")
29
+ # .with_instructions("You're a product expert..."),
30
+ # support: RubyLLM.chat(model: "gpt-4o")
31
+ # .with_instructions("You help with issues...")
32
+ # .with_tools(DiagnosticTool)
33
+ # },
34
+ # default_agent: :product
35
+ # )
36
+ # router.add_example("Show me products", agent: :product)
37
+ # router.ask("What laptops do you have?")
38
+ #
39
+ class Router
40
+ attr_reader :agents, :current_agent, :last_routing_decision
41
+
42
+ # In-memory routing example for non-Rails usage
43
+ InMemoryExample = Struct.new(:agent_name, :example_text, :embedding, keyword_init: true)
44
+
45
+ def initialize(
46
+ agents:,
47
+ default_agent:,
48
+ fallback: nil,
49
+ similarity_threshold: nil,
50
+ embedding_model: nil,
51
+ k_neighbors: nil,
52
+ scope: nil,
53
+ strategy: nil,
54
+ examples: nil,
55
+ find_examples: nil
56
+ )
57
+ @agents = normalize_agents(agents)
58
+ @default_agent = default_agent.to_sym
59
+ @current_agent = @default_agent
60
+ @strategy = strategy || Strategies::Semantic.new
61
+ @examples = examples || []
62
+ @scope = scope
63
+ @find_examples = find_examples
64
+
65
+ validate_default_agent!
66
+
67
+ @config = build_config(
68
+ embedding_model: embedding_model,
69
+ similarity_threshold: similarity_threshold,
70
+ k_neighbors: k_neighbors,
71
+ fallback: fallback
72
+ )
73
+
74
+ @chat = nil
75
+ @last_routing_decision = nil
76
+ end
77
+
78
+ # Send a message to the router and get a response
79
+ #
80
+ # @param message [String] The user's message
81
+ # @yield [chunk] Optional block for streaming responses
82
+ # @return [RubyLLM::Message] The response from the selected agent
83
+ def ask(message, &block)
84
+ @last_routing_decision = route(message)
85
+
86
+ target_agent = @last_routing_decision.agent
87
+ switch_to(target_agent) if target_agent != @current_agent
88
+
89
+ if @last_routing_decision.needs_clarification?
90
+ inject_clarification_prompt
91
+ end
92
+
93
+ current_chat.ask(message, &block)
94
+ end
95
+
96
+ # Add a routing example
97
+ #
98
+ # @param text [String] Example user message
99
+ # @param agent [Symbol, String] Name of the agent this should route to
100
+ # @return [self]
101
+ def add_example(text, agent:)
102
+ agent_name = agent.to_sym
103
+ validate_agent_exists!(agent_name)
104
+
105
+ embedding = generate_embedding(text)
106
+
107
+ @examples << InMemoryExample.new(
108
+ agent_name: agent_name,
109
+ example_text: text,
110
+ embedding: embedding
111
+ )
112
+ self
113
+ end
114
+
115
+ # Import multiple routing examples at once (batch embedding)
116
+ #
117
+ # @param examples [Array<Hash>] Array of {text:, agent:} hashes
118
+ # @return [self]
119
+ def import_examples(examples)
120
+ return self if examples.empty?
121
+
122
+ examples.each { |e| validate_agent_exists!(e[:agent].to_sym) }
123
+
124
+ texts = examples.map { |e| e[:text] }
125
+ embeddings = generate_embeddings_batch(texts)
126
+
127
+ examples.each_with_index do |example, i|
128
+ @examples << InMemoryExample.new(
129
+ agent_name: example[:agent].to_sym,
130
+ example_text: example[:text],
131
+ embedding: embeddings[i]
132
+ )
133
+ end
134
+
135
+ self
136
+ end
137
+
138
+ # Preview routing without sending the message
139
+ #
140
+ # @param message [String] The message to test
141
+ # @return [RoutingDecision]
142
+ def match(message)
143
+ route(message)
144
+ end
145
+
146
+ # Get detailed routing info for debugging
147
+ #
148
+ # @param message [String] The message to analyze
149
+ # @return [Hash]
150
+ def debug_routing(message)
151
+ embedding = generate_embedding(message)
152
+
153
+ matches = if @examples.respond_to?(:nearest_neighbors)
154
+ @examples.nearest_neighbors(:embedding, embedding, distance: :cosine)
155
+ .limit(@config.k_neighbors * 2)
156
+ .to_a
157
+ else
158
+ find_nearest_in_memory(@examples.to_a, embedding, @config.k_neighbors * 2)
159
+ end
160
+
161
+ {
162
+ message: message,
163
+ threshold: @config.similarity_threshold,
164
+ current_agent: @current_agent,
165
+ would_route_to: match(message).agent,
166
+ top_matches: matches.map do |m|
167
+ {
168
+ agent: extract_agent_name(m),
169
+ example: extract_example_text(m),
170
+ confidence: calculate_confidence(m)
171
+ }
172
+ end
173
+ }
174
+ end
175
+
176
+ # Get the current chat object
177
+ def current_chat
178
+ @chat ||= build_chat_for_agent(@current_agent)
179
+ end
180
+
181
+ # Get all messages in the conversation
182
+ def messages
183
+ current_chat.messages
184
+ end
185
+
186
+ # Get the names of all registered agents
187
+ def agent_names
188
+ @agents.keys
189
+ end
190
+
191
+ # Get an agent config by name
192
+ def agent(name)
193
+ @agents[name.to_sym]
194
+ end
195
+
196
+ # Get all routing examples
197
+ def examples
198
+ @examples
199
+ end
200
+
201
+ # Clear all routing examples
202
+ def clear_examples!
203
+ @examples = []
204
+ self
205
+ end
206
+
207
+ # Use an external examples source (e.g., ActiveRecord model)
208
+ def with_examples(source)
209
+ @examples = source
210
+ self
211
+ end
212
+
213
+ # Manually switch to a specific agent
214
+ def switch_to(agent_name)
215
+ agent_name = agent_name.to_sym
216
+ validate_agent_exists!(agent_name)
217
+
218
+ return self if agent_name == @current_agent
219
+
220
+ agent = @agents[agent_name]
221
+
222
+ if @chat
223
+ @chat.with_instructions(agent.instructions, replace: true)
224
+ @chat.with_tools(*agent.tools, replace: true) if agent.tools.any?
225
+ @chat.with_model(agent.model) if agent.model
226
+ @chat.with_temperature(agent.temperature) if agent.temperature
227
+ end
228
+
229
+ @current_agent = agent_name
230
+ self
231
+ end
232
+
233
+ # Register event callbacks
234
+ def on(event, &block)
235
+ @callbacks ||= {}
236
+ @callbacks[event] = block
237
+ self
238
+ end
239
+
240
+ private
241
+
242
+ def route(message)
243
+ decision = @strategy.route(
244
+ message,
245
+ agents: @agents,
246
+ examples: scoped_examples,
247
+ current_agent: @current_agent,
248
+ config: @config,
249
+ find_examples: @find_examples
250
+ )
251
+
252
+ emit(:on_route, decision)
253
+ decision
254
+ end
255
+
256
+ def scoped_examples
257
+ return @examples unless @scope
258
+
259
+ if @examples.respond_to?(:where)
260
+ @examples.where(router_scope: @scope)
261
+ else
262
+ @examples.select { |e| e.respond_to?(:router_scope) ? e.router_scope == @scope : true }
263
+ end
264
+ end
265
+
266
+ def build_chat_for_agent(agent_name)
267
+ agent = @agents[agent_name]
268
+
269
+ chat = RubyLLM.chat
270
+ chat.with_instructions(agent.instructions)
271
+ chat.with_tools(*agent.tools) if agent.tools.any?
272
+ chat.with_model(agent.model) if agent.model
273
+ chat.with_temperature(agent.temperature) if agent.temperature
274
+
275
+ chat
276
+ end
277
+
278
+ def inject_clarification_prompt
279
+ instruction = @last_routing_decision.inject_instruction
280
+ return unless instruction
281
+
282
+ current_instructions = @agents[@current_agent].instructions
283
+ @chat.with_instructions("#{current_instructions}\n\n#{instruction}", replace: true)
284
+ end
285
+
286
+ def generate_embedding(text)
287
+ response = RubyLLM.embed(text, model: @config.embedding_model)
288
+ vectors = response.vectors
289
+ # RubyLLM returns the vector directly for single inputs,
290
+ # or wrapped in an array for batch inputs
291
+ vectors.first.is_a?(Array) ? vectors.first : vectors
292
+ rescue StandardError => e
293
+ raise EmbeddingError, e
294
+ end
295
+
296
+ def generate_embeddings_batch(texts)
297
+ response = RubyLLM.embed(texts, model: @config.embedding_model)
298
+ vectors = response.vectors
299
+ # For batch, RubyLLM returns array of vectors
300
+ # But if single text was passed, it returns vector directly
301
+ vectors.first.is_a?(Array) ? vectors : [vectors]
302
+ rescue StandardError => e
303
+ raise EmbeddingError, e
304
+ end
305
+
306
+ def find_nearest_in_memory(examples, query_embedding, k)
307
+ examples.map do |example|
308
+ distance = cosine_distance(query_embedding, example.embedding)
309
+ Strategies::Semantic::InMemoryMatch.new(example, distance)
310
+ end.sort_by(&:distance).first(k)
311
+ end
312
+
313
+ def cosine_distance(a, b)
314
+ dot_product = a.zip(b).sum { |x, y| x * y }
315
+ magnitude_a = Math.sqrt(a.sum { |x| x**2 })
316
+ magnitude_b = Math.sqrt(b.sum { |x| x**2 })
317
+ return 1.0 if magnitude_a.zero? || magnitude_b.zero?
318
+ 1.0 - (dot_product / (magnitude_a * magnitude_b))
319
+ end
320
+
321
+ def extract_agent_name(match)
322
+ match.respond_to?(:agent_name) ? match.agent_name : match.example&.agent_name
323
+ end
324
+
325
+ def extract_example_text(match)
326
+ match.respond_to?(:example_text) ? match.example_text : match.example&.example_text
327
+ end
328
+
329
+ def calculate_confidence(match)
330
+ distance = match.respond_to?(:neighbor_distance) ? match.neighbor_distance : match.distance
331
+ [1.0 - (distance || 1.0), 0.0].max.round(3)
332
+ end
333
+
334
+ def normalize_agents(agents)
335
+ raise NoAgentsError if agents.nil? || agents.empty?
336
+
337
+ unless agents.is_a?(Hash)
338
+ raise InvalidAgentError, "agents must be a Hash of { name: chat_object }"
339
+ end
340
+
341
+ agents.each_with_object({}) do |(name, chat_or_config), hash|
342
+ name = name.to_sym
343
+
344
+ # Check if it's a RubyLLM chat object (has with_instructions method)
345
+ if chat_or_config.respond_to?(:with_instructions)
346
+ hash[name] = extract_config_from_chat(name, chat_or_config)
347
+ elsif chat_or_config.is_a?(Hash)
348
+ # Legacy hash format for backwards compatibility
349
+ raise InvalidAgentError, "instructions required for agent :#{name}" unless chat_or_config[:instructions]
350
+
351
+ hash[name] = AgentConfig.new(
352
+ name: name,
353
+ instructions: chat_or_config[:instructions],
354
+ tools: Array(chat_or_config[:tools]),
355
+ model: chat_or_config[:model],
356
+ temperature: chat_or_config[:temperature]
357
+ )
358
+ elsif chat_or_config.respond_to?(:instructions)
359
+ # Already an AgentConfig-like object
360
+ hash[name] = chat_or_config
361
+ else
362
+ raise InvalidAgentError, "agent :#{name} must be a RubyLLM.chat object or a config hash"
363
+ end
364
+ end
365
+ end
366
+
367
+ def extract_config_from_chat(name, chat)
368
+ # Extract configuration from a RubyLLM chat object
369
+ instructions = extract_instructions(chat)
370
+ raise InvalidAgentError, "agent :#{name} must have instructions (use .with_instructions)" unless instructions
371
+
372
+ tools = extract_tools(chat)
373
+ model = extract_model(chat)
374
+ temperature = extract_temperature(chat)
375
+
376
+ AgentConfig.new(
377
+ name: name,
378
+ instructions: instructions,
379
+ tools: tools,
380
+ model: model,
381
+ temperature: temperature
382
+ )
383
+ end
384
+
385
+ def extract_instructions(chat)
386
+ # Try direct accessor first (for mocks/simple objects)
387
+ return chat.instructions if chat.respond_to?(:instructions) && chat.instructions
388
+
389
+ # RubyLLM stores instructions as a system message
390
+ if chat.respond_to?(:messages)
391
+ system_msg = chat.messages.find { |m| m.role == :system }
392
+ if system_msg&.content
393
+ return system_msg.content.respond_to?(:text) ? system_msg.content.text : system_msg.content.to_s
394
+ end
395
+ end
396
+
397
+ nil
398
+ end
399
+
400
+ def extract_tools(chat)
401
+ return [] unless chat.respond_to?(:tools)
402
+
403
+ tools = chat.tools
404
+ case tools
405
+ when Hash then tools.values
406
+ when Array then tools
407
+ else []
408
+ end
409
+ end
410
+
411
+ def extract_model(chat)
412
+ return chat.model_id if chat.respond_to?(:model_id) && chat.model_id
413
+
414
+ if chat.respond_to?(:model) && chat.model
415
+ model = chat.model
416
+ # RubyLLM returns a Model::Info object, extract the id
417
+ return model.respond_to?(:id) ? model.id : model.to_s
418
+ end
419
+
420
+ nil
421
+ end
422
+
423
+ def extract_temperature(chat)
424
+ if chat.respond_to?(:temperature_value)
425
+ chat.temperature_value
426
+ elsif chat.respond_to?(:temperature)
427
+ chat.temperature
428
+ else
429
+ chat.instance_variable_get(:@temperature)
430
+ end
431
+ end
432
+
433
+ def validate_default_agent!
434
+ return if @agents.key?(@default_agent)
435
+ raise AgentNotFoundError.new(@default_agent, @agents.keys)
436
+ end
437
+
438
+ def validate_agent_exists!(agent_name)
439
+ return if @agents.key?(agent_name)
440
+ raise AgentNotFoundError.new(agent_name, @agents.keys)
441
+ end
442
+
443
+ def build_config(embedding_model:, similarity_threshold:, k_neighbors:, fallback:)
444
+ global_config = SemanticRouter.configuration || Configuration.new
445
+
446
+ RouterConfig.new(
447
+ embedding_model: embedding_model || global_config.default_embedding_model,
448
+ similarity_threshold: similarity_threshold || global_config.default_similarity_threshold,
449
+ k_neighbors: k_neighbors || global_config.default_k_neighbors,
450
+ fallback: fallback || global_config.default_fallback,
451
+ default_agent: @default_agent,
452
+ scope: @scope
453
+ )
454
+ end
455
+
456
+ def emit(event, *args)
457
+ @callbacks ||= {}
458
+ @callbacks[event]&.call(*args)
459
+ end
460
+ end
461
+ end
462
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module SemanticRouter
5
+ # Value object representing a routing decision
6
+ class RoutingDecision
7
+ # The name of the agent to route to
8
+ attr_reader :agent
9
+
10
+ # Confidence score (0.0 - 1.0) of the routing decision
11
+ attr_reader :confidence
12
+
13
+ # The example text that matched (for debugging)
14
+ attr_reader :matched_example
15
+
16
+ # Reason for the decision (:semantic_match, :fallback, :kept_current, :needs_clarification)
17
+ attr_reader :reason
18
+
19
+ # Optional instruction to inject (used for ask_clarification)
20
+ attr_reader :inject_instruction
21
+
22
+ def initialize(agent:, confidence: 0.0, matched_example: nil, reason: :semantic_match, inject_instruction: nil)
23
+ @agent = agent&.to_sym
24
+ @confidence = confidence.to_f
25
+ @matched_example = matched_example
26
+ @reason = reason
27
+ @inject_instruction = inject_instruction
28
+ end
29
+
30
+ # Returns true if this was a confident semantic match
31
+ def confident?
32
+ reason == :semantic_match && confidence > 0
33
+ end
34
+
35
+ # Returns true if this decision used a fallback
36
+ def fallback?
37
+ %i[fallback kept_current needs_clarification].include?(reason)
38
+ end
39
+
40
+ # Returns true if clarification is needed
41
+ def needs_clarification?
42
+ reason == :needs_clarification
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ agent: agent,
48
+ confidence: confidence,
49
+ matched_example: matched_example,
50
+ reason: reason,
51
+ inject_instruction: inject_instruction
52
+ }.compact
53
+ end
54
+
55
+ def ==(other)
56
+ return false unless other.is_a?(RoutingDecision)
57
+
58
+ agent == other.agent &&
59
+ confidence == other.confidence &&
60
+ reason == other.reason
61
+ end
62
+
63
+ def inspect
64
+ "#<RoutingDecision agent=#{agent.inspect} confidence=#{confidence.round(3)} reason=#{reason.inspect}>"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module SemanticRouter
5
+ module Strategies
6
+ # Base class for routing strategies
7
+ #
8
+ # Subclasses must implement the #route method which receives:
9
+ # - message: The user's message to route
10
+ # - agents: Hash of agent_name => Agent objects
11
+ # - examples: The routing examples (ActiveRecord relation or array)
12
+ # - current_agent: The currently active agent name (symbol)
13
+ # - config: RouterConfig with threshold, fallback, etc.
14
+ #
15
+ # The #route method should return a RoutingDecision object
16
+ class Base
17
+ def route(message, agents:, examples:, current_agent:, config:)
18
+ raise NotImplementedError, "Subclasses must implement #route"
19
+ end
20
+
21
+ protected
22
+
23
+ # Apply fallback behavior based on configuration
24
+ #
25
+ # @param config [RouterConfig] Router configuration
26
+ # @param current_agent [Symbol] Currently active agent
27
+ # @param default_agent [Symbol] Default agent from router
28
+ # @return [RoutingDecision]
29
+ def apply_fallback(config:, current_agent:, default_agent:)
30
+ case config.fallback
31
+ when :default_agent
32
+ RoutingDecision.new(
33
+ agent: default_agent,
34
+ confidence: 0,
35
+ reason: :fallback
36
+ )
37
+ when :keep_current
38
+ RoutingDecision.new(
39
+ agent: current_agent || default_agent,
40
+ confidence: 0,
41
+ reason: :kept_current
42
+ )
43
+ when :ask_clarification
44
+ RoutingDecision.new(
45
+ agent: default_agent,
46
+ confidence: 0,
47
+ reason: :needs_clarification,
48
+ inject_instruction: "The user's message was unclear. Please ask them to clarify what they need help with."
49
+ )
50
+ else
51
+ raise InvalidFallbackError, config.fallback
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end