Class: Assistant::ToolRouter
- Inherits:
-
Object
- Object
- Assistant::ToolRouter
- Defined in:
- app/services/assistant/tool_router.rb
Overview
Automatic tool routing: decides which tool services to activate based on
the user's query and conversation history.
Routing is AI-primary — a fast/cheap LLM call reads the tool catalog and
selects the set of tools needed. Two explicit-intent
mechanisms bypass the AI entirely:
- Chip badges — user clicks a tool chip, sent as forced_tools[]
- @mentions — user types @Search Console in the query
Conversation history carries forward stateful services (blog editing, etc.)
so follow-up messages don't lose context. If the AI call fails or times
out, content is activated as a safe universal fallback.
Usage:
services = Assistant::ToolRouter.route(
"Show me sales YTD",
conversation: @conversation,
permitted_services: %w[content app_db postgres_versions]
)
=> ["content", "app_db"]
Constant Summary collapse
- AI_ROUTER_MODEL =
AiModelConstants.id(:tool_routing)
- AI_TIMEOUT_SECONDS =
Maximum time (seconds) to wait for the AI classification call.
Set high enough to accommodate RubyLLM's built-in retry loop
(up to 5 retries with exponential backoff on transient errors). 20- MENTION_ALIASES =
Canonical kebab-case handles for @mentions.
The autocomplete inserts these exact handles (e.g. @google-analytics).
Additional aliases map common abbreviations and free-typed variants.Format: downcased alias → service key
{ # Canonical handles (kebab-case, inserted by autocomplete) 'app-db' => 'app_db', 'content-search' => 'content', 'image-management' => 'image_management', 'faq-management' => 'faq_management', 'versions-db' => 'postgres_versions', 'google-analytics' => 'google_analytics', 'search-console' => 'search_console', 'google-ads' => 'google_ads', 'ahrefs' => 'ahrefs', 'blog-management' => 'blog_management', 'product-catalog' => 'product_catalog', 'spec-management' => 'product_spec_management', 'keywords-people-use' => 'keywords_people_use', 'basecamp' => 'basecamp', # Short aliases for manual typing 'db' => 'app_db', 'sql' => 'app_db', 'content' => 'content', 'images' => 'image_management', 'faq' => 'faq_management', 'versions' => 'postgres_versions', 'audit' => 'postgres_versions', 'ga4' => 'google_analytics', 'ga' => 'google_analytics', 'gsc' => 'search_console', 'ads' => 'google_ads', 'blog' => 'blog_management', 'products' => 'product_catalog', 'catalog' => 'product_catalog', 'sku' => 'product_catalog', 'specs' => 'product_spec_management', 'spec' => 'product_spec_management', 'kpu' => 'keywords_people_use', 'bc' => 'basecamp', 'todos' => 'basecamp', # Conversation memory 'conversation-memory' => 'conversation_memory', 'my-conversations' => 'conversation_memory', 'memory' => 'conversation_memory', # Brain management 'brain-management' => 'brain_management', 'brain' => 'brain_management', # Support Cases 'support-cases' => 'support_cases', 'support' => 'support_cases', 'tickets' => 'support_cases', 'cases' => 'support_cases', # Sales Management 'sales-management' => 'sales_management', 'sales' => 'sales_management', 'pipeline' => 'sales_management', 'reps' => 'sales_management', 'team' => 'sales_management', 'workload' => 'sales_management', 'schedule' => 'sales_management', # Gamma 'gamma' => 'gamma', 'gamma.app' => 'gamma', 'presentation' => 'gamma', 'slide-deck' => 'gamma', 'slides' => 'gamma' }.freeze
- MENTION_PATTERN =
Regex that matches @mentions in the query.
Captures the handle after @ (kebab-case words like google-analytics,
or simple single words like ga4). /@([\w][\w-]*)/i- STATEFUL_SERVICES =
Services that represent active multi-step workflows where follow-up messages
(corrections, refinements, re-runs) need the same tools as the prior turn
even if the follow-up message contains no explicit keywords for that service.
Example: "change the C$ symbol" doesn't mention "blog" but still needs blog_management.
gamma is intentionally excluded — it is opt-in only (@gamma mention or chip badge). %w[ blog_management product_catalog product_spec_management image_management faq_management basecamp content keywords_people_use seo_audit app_db support_cases sales_management ].freeze
- SERVICE_BUNDLES =
Services that must always be bundled together when the primary service is active.
Blog work consistently needs content search (for internal link lookups) and
FAQ management (for creating/finding FAQs embedded in posts). These are bundled
automatically rather than relying on the AI classifier to remember them every time. { 'blog_management' => %w[content faq_management] }.freeze
- SERVICE_DEPENDENCIES =
Implicit co-dependencies: when a service is activated, these companion
services are also loaded because the workflow inherently uses their tools.
Example: blog_management calls find_faqs (content), find_images (content),
and image embeds (image_management) during blog editing. { 'blog_management' => %w[content image_management faq_management], 'faq_management' => %w[content] }.freeze
- ALWAYS_ON_SERVICES =
Lightweight services always included when the user's role permits them.
brain_management gives permitted users propose_brain_entry to capture
corrections reactively. search_brain (read-only retrieval) lives in the
content service so ALL users can access brain entries without needing
brain_management permission. %w[brain_management].freeze
- CRM_URL_PATTERN =
CRM URL pattern — when a query contains a CRM URL, the user is referencing
internal data that requires database access. This bypasses the AI classifier
to prevent misrouting (e.g. campaign link clicks classified as content search). %r{crm\.warmlyyours\.com/}i- BASECAMP_URL_PATTERN =
Basecamp URL pattern — when a query contains a Basecamp URL, the user wants
to interact with that Basecamp resource (todo, message, comment, etc.). %r{(\d+\.)?basecamp\.com/}i- BLOG_POST_URL_PATTERN =
Blog post URL pattern — matches both public-facing and CRM blog URLs:
www.warmlyyours.com/en-US/posts/...
crm.warmlyyours.com/posts/... %r{warmlyyours\.com/([\w-]+/)?posts/}i- SUPPORT_CASE_URL_PATTERN =
%r{crm\.warmlyyours\.com/support_cases}i- SEO_ACTION_ITEM_PATTERN =
SEO action item pattern — matches "Action Item #12345" or "action items"
in user queries, signaling that seo_audit + blog_management are needed. /action\s+items?\b|#\d{4,6}\b.*(?:mark|complet|status|pending)/i
Class Method Summary collapse
-
.extract_mentioned_services(query) ⇒ Array<String>
Public: extract @mentions from a query and return resolved service keys.
-
.resolve_handles(handles) ⇒ Array<String>
Public: resolve an array of tool handles (from form params) to service keys.
-
.route(query, conversation: nil, permitted_services: [], forced_services: []) ⇒ Array<String>
Route a user query to the appropriate tool services.
-
.strip_mentions(query) ⇒ String
Strip @mentions from the query so the LLM sees clean text.
Class Method Details
.extract_mentioned_services(query) ⇒ Array<String>
Public: extract @mentions from a query and return resolved service keys.
Used by the controller to identify which tools were explicitly forced.
252 253 254 |
# File 'app/services/assistant/tool_router.rb', line 252 def self.extract_mentioned_services(query) extract_mentions(query).uniq end |
.resolve_handles(handles) ⇒ Array<String>
Public: resolve an array of tool handles (from form params) to service keys.
Handles are kebab-case strings sent by the chip badges (e.g. "google-analytics").
261 262 263 264 265 266 267 268 |
# File 'app/services/assistant/tool_router.rb', line 261 def self.resolve_handles(handles) return [] if handles.blank? handles.filter_map do |h| raw = h.to_s.strip.downcase ChatToolBuilder.service_key_for_handle(raw) || MENTION_ALIASES[raw] end.uniq end |
.route(query, conversation: nil, permitted_services: [], forced_services: []) ⇒ Array<String>
Route a user query to the appropriate tool services.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'app/services/assistant/tool_router.rb', line 179 def self.route(query, conversation: nil, permitted_services: [], forced_services: []) services = Set.new # 1. Explicitly forced tools (chip badges) — highest priority, always honoured. services.merge(forced_services) if forced_services.present? # 2. @mentions in text — always honoured. mentioned = extract_mentions(query) services.merge(mentioned) # 2b. URL detection — unambiguous signals that bypass AI classification. # Support-case URLs are handled by curated tools; avoid pulling raw SQL (app_db) unless the query is broader CRM. services << 'app_db' if query.match?(CRM_URL_PATTERN) && !query.match?(SUPPORT_CASE_URL_PATTERN) services << 'basecamp' if query.match?(BASECAMP_URL_PATTERN) services << 'blog_management' if query.match?(BLOG_POST_URL_PATTERN) services << 'support_cases' if query.match?(SUPPORT_CASE_URL_PATTERN) # 2c. SEO action item references — "Action Item #22824", "SEO action items", etc. if query.match?(SEO_ACTION_ITEM_PATTERN) services << 'seo_audit' services << 'blog_management' end # 3. AI classification — the primary routing mechanism. ai_services = ai_classify(query, permitted_services) services.merge(ai_services) # 4. Conversation continuity if conversation history_services = ChatToolBuilder.services_from_history(conversation) # 4a. Inherit parent conversation's tool services for forked conversations. # When a user forks ("continue in new conversation"), the new conversation # has no tool-call history yet, so services_from_history returns []. # Carry forward the parent's persisted tool_services so the user doesn't # have to re-enable badges for the same workflow. if history_services.empty? && conversation.parent_conversation_id.present? parent = AssistantConversation.find_by(id: conversation.parent_conversation_id) history_services = (parent&.tool_services || []).select(&:present?) if parent end # Capture current-turn signals before merging history so the # vague follow-up check isn't short-circuited by carried-forward services. auto_detected = services - forced_services.to_set - mentioned.to_set stateful_from_history = history_services & STATEFUL_SERVICES services.merge(stateful_from_history) # For vague follow-ups where AI found nothing new, carry forward # ALL prior services (e.g. "try again", "do the prior week"). services.merge(history_services) if auto_detected.empty? end # 5. Fallback: if nothing matched, activate content as a universal default. services << 'content' if services.empty? # Expand implicit co-dependencies (blog_management needs content, etc.) (services) # 6. Always-on services for permitted users only. # brain_management → propose_brain_entry (captures corrections). # search_brain is in the content service (available to everyone). ALWAYS_ON_SERVICES.each { |s| services << s if permitted_services.include?(s) } # Intersect with what the user is permitted to access services.to_a & permitted_services end |
.strip_mentions(query) ⇒ String
Strip @mentions from the query so the LLM sees clean text.
Public because the controller needs it to clean the message before
passing it to the worker.
303 304 305 306 307 308 309 310 311 |
# File 'app/services/assistant/tool_router.rb', line 303 def self.strip_mentions(query) return query unless query.include?('@') cleaned = query.gsub(MENTION_PATTERN) do |full| raw = Regexp.last_match(1).strip.downcase resolve_mention(raw) ? '' : full end cleaned.gsub(/\s{2,}/, ' ').strip end |