Class: Assistant::ToolRouter

Inherits:
Object
  • Object
show all
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:

  1. Chip badges — user clicks a tool chip, sent as forced_tools[]
  2. @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

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',
  'technical-articles'   => 'technical_article_management',
  'versions-db'          => 'postgres_versions',
  'google-analytics'     => 'google_analytics',
  'search-console'       => 'search_console',
  'google-ads'           => 'google_ads',
  'ahrefs'               => 'ahrefs',
  'adlabs'               => 'adlabs',
  'amazon-ads'           => 'adlabs',
  'blog-management'      => 'blog_management',
  'email-management'     => 'email_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',
  'tech-articles' => 'technical_article_management',
  'versions' => 'postgres_versions',
  'audit'    => 'postgres_versions',
  'ga4'      => 'google_analytics',
  'ga'       => 'google_analytics',
  'gsc'      => 'search_console',
  'ads'      => 'google_ads',
  'blog'     => 'blog_management',
  'email' => 'email_management',
  'email-template' => 'email_management',
  'campaign'     => 'email_management',
  'campaigns'    => 'email_management',
  'newsletter'   => 'email_management',
  'audience'        => 'email_management',
  'audiences'       => 'email_management',
  'mailing-list'    => 'email_management',
  'subscriber-list' => 'email_management',
  'email-compose'   => 'email_compose',
  'compose'         => 'email_compose',
  'customer-search' => 'customer_search',
  'advanced-search' => 'customer_search',
  'products' => 'product_catalog',
  'catalog'  => 'product_catalog',
  'sku'      => 'product_catalog',
  'specs'    => 'product_spec_management',
  'spec'     => 'product_spec_management',
  'kpu'      => 'keywords_people_use',
  'ppc'      => 'adlabs',
  'amazon-advertising' => 'adlabs',
  '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.
For instance, "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 email_management email_compose customer_search product_catalog product_spec_management
  image_management faq_management technical_article_management basecamp
  content keywords_people_use seo_audit
  app_db support_cases sales_management adlabs
].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.
For instance, 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],
  'email_management' => %w[content image_management product_catalog customer_search],
  'faq_management' => %w[content],
  'technical_article_management' => %w[content support_cases]
}.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 =

Regex pattern matching support case url.

%r{crm\.warmlyyours\.com/support_cases}i
EMAIL_TEMPLATE_URL_PATTERN =

CRM email-template URL — pasting one means the user wants to read/edit that
template, so route to email_management (curated tools) rather than raw SQL.

%r{crm\.warmlyyours\.com/email_templates}i
TECHNICAL_ARTICLE_URL_PATTERN =

Technical Article URLs always use the curated workflow tools, never raw SQL.

%r{crm\.warmlyyours\.com/(?:[\w-]+/)?article_technicals/}i
TECHNICAL_ARTICLE_CONFLICTING_SERVICES =
%w[app_db blog_management].freeze
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
CRM_IDENTIFIER_PATTERN =

CRM identifier patterns — when a query references ON12345 (opportunity)
or CN12345 (party), load sales_management so get_opportunity_brief /
get_customer_brief are available. Without this, the AI classifier
routes these queries to app_db only, the brief tools aren't loaded,
and Sunny falls back to hand-rolled SQL — which is exactly the
workflow the briefs were built to replace.

/\b(?:ON|CN|SQ)\d{5,}\b/i

Class Method Summary collapse

Class Method Details

.build_tool_menu(permitted_services) ⇒ Object

Build a compact menu of available tools for the AI prompt.
Public so QueryClassifier can reuse it without duplicating catalog logic.



440
441
442
443
444
445
446
447
448
# File 'app/services/assistant/tool_router.rb', line 440

def self.build_tool_menu(permitted_services)
  catalog = ChatToolBuilder.chat_services
  permitted_services.filter_map do |key|
    meta = catalog[key]
    next unless meta

    "- #{key}: #{meta[:description]}"
  end
end

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

Parameters:

  • query (String)

Returns:

  • (Array<String>)

    unique resolved service keys



320
321
322
# File 'app/services/assistant/tool_router.rb', line 320

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").

Parameters:

  • handles (Array<String>)

    tool handles from the client

Returns:

  • (Array<String>)

    unique resolved service keys (invalid handles silently ignored)



329
330
331
332
333
334
335
336
# File 'app/services/assistant/tool_router.rb', line 329

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: [], classifier_result: nil) ⇒ Array<String>

Route a user query to the appropriate tool services.

Parameters:

  • query (String)

    the user's message

  • conversation (AssistantConversation, nil) (defaults to: nil)

    current conversation (for history)

  • permitted_services (Array<String>) (defaults to: [])

    services the user's role allows

  • forced_services (Array<String>) (defaults to: [])

    service keys explicitly forced by chip badges

  • classifier_result (Assistant::QueryClassifier::Result, nil) (defaults to: nil)

    pre-computed classification
    to avoid a duplicate AI call when the caller has already run QueryClassifier

Returns:

  • (Array<String>)

    service keys to activate



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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'app/services/assistant/tool_router.rb', line 213

def self.route(query, conversation: nil, permitted_services: [], forced_services: [], classifier_result: nil)
  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)
  explicit_services = Set.new(Array(forced_services) + 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.
  curated_technical_article_url = curated_technical_article_url?(query, permitted_services)
  curated_crm_url = query.match?(SUPPORT_CASE_URL_PATTERN) ||
                    query.match?(EMAIL_TEMPLATE_URL_PATTERN) ||
                    curated_technical_article_url
  services << 'app_db' if query.match?(CRM_URL_PATTERN) && !curated_crm_url
  services << 'basecamp' if query.match?(BASECAMP_URL_PATTERN)
  if query.match?(BLOG_POST_URL_PATTERN)
    services << 'blog_management'
    explicit_services << 'blog_management'
  end
  services << 'support_cases' if query.match?(SUPPORT_CASE_URL_PATTERN)
  services << 'email_management' if query.match?(EMAIL_TEMPLATE_URL_PATTERN)
  services << 'technical_article_management' if curated_technical_article_url

  # 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'
    explicit_services << 'blog_management'
  end

  # 2d. CRM identifier references (ON12345 / CN12345 / SQ12345) — load
  # sales_management so the bundled brief tools (get_opportunity_brief,
  # get_customer_brief) are available. The AI classifier alone routes
  # these to app_db only, which forces Sunny to hand-roll SQL and
  # negates the whole point of the bundled tools. app_db comes along
  # for follow-up queries that need columns the brief doesn't return.
  if query.match?(CRM_IDENTIFIER_PATTERN)
    services << 'sales_management'
    services << 'app_db'
    explicit_services << 'app_db'
  end

  # 3. AI classification — the primary routing mechanism.
  # When the caller has already run QueryClassifier (e.g. the controller),
  # reuse its tools to avoid a duplicate LLM round-trip.
  ai_services = if classifier_result
                  classifier_result.tools
                else
                  ai_classify(query, permitted_services)
                end
  services.merge(curated_ai_services(ai_services, curated_technical_article_url))

  # 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 || []).compact_blank 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

  # A Technical Article URL is an unambiguous request for the curated
  # workflow. Do not let classifier or conversation history silently add
  # raw SQL or blog mutation tools; explicit chips/@mentions still win.
  enforce_technical_article_routing!(services, curated_technical_article_url, explicit_services)

  # 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.)
  expand_dependencies!(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.

Parameters:

  • query (String)

Returns:

  • (String)

    query with @mentions removed



412
413
414
415
416
417
418
419
420
# File 'app/services/assistant/tool_router.rb', line 412

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