Class: Insika::Routing
- Inherits:
-
Object
- Object
- Insika::Routing
- Defined in:
- lib/insika/routing.rb
Overview
WS4: intent routing as DATA. When AgentProfile#routes is present, the
turn's message is classified into one of the configured routes with a cheap
model BEFORE the agent chat is assembled. This class owns the PURE parts —
normalizing the route config, generating the classifier prompt from the
route descriptions, and parsing the model's answer back into a route — so
they are testable without a provider. The ask itself and its usage are the
Executor's (a pre-stage call counted in the turn's usage and trace).
Config shape (string keys at the persistence boundary — the pack and the Studio store it like any other free-form hash):
{ "shopping" => "the customer wants to browse products",
"order" => { "description" => "asks about an existing order",
"delegate" => "order-agent" }, # hand the turn to that agent
"human" => { "description" => "the customer is frustrated or asks for a person",
"stuck" => true, "message" => "..." }, # end the turn :stuck (WS5)
"default" => "shopping", # deterministic fallback
"model" => "deepseek-v4-flash" } # the CHEAP classifier
default/model/provider are reserved top-level keys; everything else is
a route name (single token). A route value is a description String or a Hash
with description + optional delegate (an existing agent the turn is
handed to) / stuck (the turn ends with the WS5 stuck outcome) / message
(the consumer-facing lead-in for a stuck route).
Defined Under Namespace
Classes: Entry
Constant Summary collapse
- RESERVED =
%w[model provider default].freeze
- NAME =
A name the classifier can actually answer with: one lowercase token.
/\A[a-z0-9][a-z0-9_-]*\z/- TOKEN_FIELDS =
The turn's usage fields the classifier call can contribute.
%i[input_tokens output_tokens cached_tokens cache_creation_tokens total_tokens].freeze
Class Method Summary collapse
-
.classifier_prompt(meta) ⇒ Object
The classifier's instructions, auto-generated from the route descriptions (config-over-code: no per-route prompt file).
-
.normalize(routes) ⇒ Object
-> { entries: [Entry], default: String, model: String, provider: String } | nil (routes absent/empty = routing off).
-
.parse(text, meta) ⇒ Object
-> Symbol: the route the model named; the DEFAULT when it named nothing usable (prose, punctuation, an unknown name, empty).
- .stringify(hash) ⇒ Object
Class Method Details
.classifier_prompt(meta) ⇒ Object
The classifier's instructions, auto-generated from the route descriptions (config-over-code: no per-route prompt file).
74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/insika/routing.rb', line 74 def classifier_prompt() lines = [:entries].map do |e| "-#{e.name}: #{e.description.empty? ? e.name : e.description}" end <<~PROMPT Classify the customer's message into exactly one of these intents. Reply with ONLY the intent name — nothing else. #{lines.join("\n")} PROMPT end |
.normalize(routes) ⇒ Object
-> { entries: [Entry], default: String, model: String, provider: String } | nil (routes absent/empty = routing off). Raises ValidationError on a route name the classifier could never answer with (spaces/symbols) or on a config with zero routes — a routing config that silently routes nothing is a config error, not a quiet off.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/insika/routing.rb', line 43 def normalize(routes) return nil unless routes.is_a?(Hash) && !routes.empty? bad = routes.keys.map(&:to_s).reject { |k| RESERVED.include?(k) || k.match?(NAME) } unless bad.empty? raise Insika::ValidationError, "route names may not contain spaces or symbols: #{bad.join(', ')}" end entries = routes.each_with_object([]) do |(name, value), acc| next if RESERVED.include?(name.to_s) cfg = value.is_a?(Hash) ? stringify(value) : { "description" => value.to_s } acc << Entry.new(name: name.to_s, description: cfg["description"].to_s, delegate: cfg["delegate"].to_s, stuck: cfg["stuck"] == true, message: cfg["message"].to_s) end raise Insika::ValidationError, "routes must define at least one route" if entries.empty? default = routes["default"].to_s default = entries.first.name if default.empty? unless entries.any? { |e| e.name == default } raise Insika::ValidationError, "default route '#{default}' is not a configured route" end { entries: entries, default: default, model: routes["model"].to_s, provider: routes["provider"].to_s } end |
.parse(text, meta) ⇒ Object
-> Symbol: the route the model named; the DEFAULT when it named nothing usable (prose, punctuation, an unknown name, empty). Deterministic by construction — a chatty or confused classifier can never invent a route.
89 90 91 92 93 94 |
# File 'lib/insika/routing.rb', line 89 def parse(text, ) token = text.to_s.strip.split(/\s+/).first.to_s.downcase .delete_suffix(".").delete_suffix(",") names = [:entries].map(&:name) (names.include?(token) ? token : [:default]).to_sym end |
.stringify(hash) ⇒ Object
97 98 99 |
# File 'lib/insika/routing.rb', line 97 def self.stringify(hash) hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v } end |