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,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
|