Class: Assistant::ChatToolBuilder

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/chat_tool_builder.rb

Overview

Builds RubyLLM::Tool subclasses from the same logic the MCP server tools use.
This allows the assistant chat to call tools natively without an HTTP hop to the MCP server.

Supports two categories of tools:

  • Content tools: Wrap ApplicationTool descendants (semantic_search, find_products, etc.)
  • Other services: Delegated to focused builders (PostgresChatToolBuilder, AnalyticsToolBuilder, etc.)

Usage:
tools = Assistant::ChatToolBuilder.tools_for_services(['content', 'app_db'])
chat.with_tools(*tools)

Central catalog of handles, labels, and routing — splitting would scatter
the service → builder mapping this class exists to hold.
rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists

Constant Summary collapse

MAX_TOOL_RESULT_CHARS =

Maximum characters for any single tool result (SQL, API, content searches).
Prevents a single tool call from consuming too much of the context window.
~15K chars ≈ ~4K tokens. Most results are useful in the first few thousand
chars; the tail is pagination noise. get_blog_post has its own 25K in-tool
truncation and is also compacted immediately post-exchange (see ContextCompactor).

15_000
WEB_FETCH_MAX_CHARS =

Higher limit for web fetch results. HTML-stripped plain text is dense and
a product/category page can have many items. Sonnet 4.6 has a 200K token
context window, so 60K chars (~15K tokens) is well within budget.

60_000
MAX_SQL_ROWS =

Maximum rows returned by execute_sql (wide tables can generate huge JSON)

50
CHAT_CONTENT_TOOLS =

Content tools exposed to the AI Assistant chat.
The MCP server exposes all ApplicationTool descendants (for external clients like Cursor),
but the chat only needs a focused set to avoid tool-choice confusion for the LLM:

  • semantic_search: primary content discovery (posts, showcases, videos, products, etc.)
  • find_images: dedicated image search with rich URL output (CRM, ImageKit, HTML tags, thumbnails)
  • find_faqs: FAQ-specific search with product_line filtering and answer content
  • find_call_recordings: permission-gated call transcript search
    Removed: find_products, find_publications, find_reviews — these are thin wrappers
    around semantic_search with richer formatting. The LLM can use semantic_search
    with the types parameter instead (e.g. types: ["products"]).
%w[semantic_search find_images find_faqs find_call_recordings create_faq].freeze
SUPPORT_CASE_TOOLS =
%w[find_support_cases search_support_notes get_support_case].freeze
TOOL_SERVICE_MAP =

Reverse mapping: tool name pattern → service key.
Used to detect which services a conversation needs based on its tool call history.
Order matters: more specific patterns first.
Includes legacy 'postgres_production_*' names for conversations created before the rename.

[
  { pattern: /\Aapp_db_/,              service: 'app_db' },
  { pattern: /\Apostgres_production_/, service: 'app_db' },
  { pattern: /\Aversions_db_/,         service: 'postgres_versions' },
  { pattern: /\Apostgres_versions_/,   service: 'postgres_versions' },
  { pattern: /\Aga4_/,                 service: 'google_analytics' },
  { pattern: /\Agsc_/,                 service: 'search_console' },
  { pattern: /\Agoogle_ads_/,          service: 'google_ads' },
  { pattern: /\Aahrefs_/,              service: 'ahrefs' },
  { pattern: /\A(list|create|update|get|patch)_blog_(post|tags)\z/, service: 'blog_management' },
  { pattern: /\Arefresh_blog_oembeds\z/, service: 'blog_management' },
  { pattern: /\Ainsert_/, service: 'blog_management' },
  { pattern: /\A(list_product_lines|get_product|search_products|browse_product_line|search_specs|get_spec|get_spec_scope)\z/, service: 'product_catalog' },
  { pattern: /\Aupdate_catalog_item_state\z/, service: 'product_management' },
  { pattern: /\A(update_spec|clone_spec_to_|enqueue_spec_refresh)\w*\z/, service: 'product_spec_management' },
  { pattern: /\Akwpu_/, service: 'keywords_people_use' },
  { pattern: /\Abasecamp_/,          service: 'basecamp' },
  { pattern: /\Aweather_/,           service: 'weather' },
  { pattern: /\Afind_images\z/, service: 'image_management' },
  { pattern: /\Acreate_faq\z/, service: 'faq_management' },
  { pattern: /\Asearch_activity_notes\z/, service: 'app_db' },
  { pattern: /\A(find_support_cases|search_support_notes|get_support_case)\z/, service: 'support_cases' },
  { pattern: /\A(semantic_search|find_faqs|find_call_recordings)\z/, service: 'content' },
  { pattern: /\A(search|list)_my_conversations\z/, service: 'conversation_memory' },
  { pattern: /\Afetch_url\z/,                      service: 'web_fetch' },
  { pattern: /\Aseo_/,                             service: 'seo_audit' },
  { pattern: /\Agamma_/,                           service: 'gamma' },
  { pattern: /\Asearch_brain\z/, service: 'content' },
  { pattern: /\Apropose_brain_entry\z/, service: 'brain_management' },
  { pattern: /\A(find_employee|get_team_availability|get_pipeline_summary|get_rep_workload|get_rep_performance|get_recent_calls)\z/, service: 'sales_management' }
].freeze
STICKY_SERVICE_PATTERNS =

Pick a subset of service keys for a plan step based on the step description.
param step_description [String]
param permitted_keys [Array] caller's enabled tool services (intersected)
Scan all plan step descriptions and return service keys that should
persist ("stick") across every step. When any step in a plan mentions
blog-related work, blog_management tools stay available for every step
so the model doesn't lose access mid-plan.

Returns:

  • (Array<String>)

    non-empty subset of +permitted_keys+

[
  [/blog|post|article|patch|oembed/i, %w[blog_management]],
  [/seo|action.?item|recommendation|sitemap/i, %w[seo_audit]]
].freeze
ROLE_DEFAULTS =

Core services always available (role-based defaults below add DB access).
External tool services (google_analytics, etc.) are resolved separately
from DataDomainPolicy.tool_services_for and merged in default_services_for.

{
  admin:    %w[conversation_memory content product_catalog app_db postgres_versions web_fetch seo_audit brain_management sales_management],
  manager:  %w[conversation_memory content product_catalog app_db web_fetch seo_audit brain_management sales_management],
  employee: %w[conversation_memory content product_catalog app_db web_fetch brain_management]
}.freeze
POWER_USER_ROLES =

Role classification for DB tool access.
Power users get full schema exploration tools; restricted users
get only describe_available_data + a restricted execute_sql.

%i[admin manager].freeze
STEP_DESCRIPTION_SERVICE_PATTERNS =

Build RubyLLM::Tool instances for the given service keys
param service_keys [Array] e.g. ['content', 'app_db']
param role [Symbol] :admin, :manager, or :employee (controls DB tool access)
param allowed_objects [Set, nil] domain-resolved set of allowed view/table names (nil = unrestricted)
param audit_context [Hash] optional context for SQL audit logging
param account [Account, nil] the CRM account (needed for per-account services like Basecamp)
Heuristic mapping: step description substring → tool service keys (for isolated plan-step execution).
Intersected with the user's permitted services. If nothing matches, full +service_keys+ is used.

[
  [/blog|post|article|content|patch|oembed|table|alt.?text|heading|h[1-6]|title|summary|paragraph|internal.?link|comparison|intro|conclusion|rewrite|edit|update.*content/i, %w[blog_management content]],
  [/sql|query|database|kpi|sales|order|customer|execute_sql/i, %w[app_db]],
  [/seo|sitemap|inbound|outbound|schema|action.?item/i, %w[seo_audit]],
  [/image|photo|picture|thumbnail/i, %w[image_management content]],
  [/faq/i, %w[faq_management content]],
  [/search|semantic|find_|look.?up|keyword/i, %w[content product_catalog]],
  [/product|sku|catalog|spec|towel|heating/i, %w[product_catalog]],
  [/gamma|presentation|deck/i, %w[gamma]],
  [/basecamp/i, %w[basecamp]],
  [/weather/i, %w[weather]],
  [/fetch|url|http/i, %w[web_fetch]],
  [/ga4|analytics|traffic/i, %w[google_analytics]],
  [/search.?console|gsc/i, %w[search_console]],
  [/google.?ads/i, %w[google_ads]],
  [/ahrefs|backlink/i, %w[ahrefs]],
  [/support.?case|ticket/i, %w[support_cases]],
  [/brain|rule/i, %w[brain_management content]],
  [/employee|pipeline|rep\.|sales.?team/i, %w[sales_management app_db]]
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.chat_servicesObject

Services that can be offered in the assistant chat.



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
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
# File 'app/services/assistant/chat_tool_builder.rb', line 156

def self.chat_services
  {
    'conversation_memory' => { label: 'My Conversations', handle: 'conversation-memory', icon: 'fa-clock-rotate-left', description: 'Search past conversations to recall previous analyses, findings, and answers' },
    'content' => { label: 'Content Search', handle: 'content-search', icon: 'fa-magnifying-glass', description: 'Search products, FAQs, posts, and more' },
    'image_management' => { label: 'Image Management', handle: 'image-management', icon: 'fa-images', description: 'Search and manage the image library with keyword, AI, or hybrid modes' },
    'faq_management' => { label: 'FAQ Management', handle: 'faq-management', icon: 'fa-circle-question', description: 'Search, create, and manage FAQ articles with product line and tag associations' },
    'app_db' => {
      label: 'App DB', handle: 'app-db', icon: 'fa-database',
      description:
        'Read-only SQL queries against the application database — sales, orders, customers, ' \
        'campaigns, email marketing, link clicks, communications, inventory, KPIs, and more'
    },
    'postgres_versions' => { label: 'Versions DB', handle: 'versions-db', icon: 'fa-clock-rotate-left', description: 'Read-only queries against audit trail' },
    'google_analytics' => { label: 'Google Analytics', handle: 'google-analytics', icon: 'fa-chart-line', description: 'GA4 page views, sessions, engagement, traffic sources' },
    'search_console' => { label: 'Search Console', handle: 'search-console', icon: 'fa-magnifying-glass-chart', description: 'Google search clicks, impressions, CTR, positions' },
    'google_ads' => { label: 'Google Ads', handle: 'google-ads', icon: 'fa-rectangle-ad', description: 'Keyword volume, campaign data, GAQL queries' },
    'ahrefs' => { label: 'Ahrefs SEO', handle: 'ahrefs', icon: 'fa-link', description: 'Backlinks, organic traffic, keyword rankings' },
    'product_catalog' => {
      label: 'Product Catalog', handle: 'product-catalog', icon: 'fa-box-open',
      description: 'Look up product SKUs, search the catalog, browse product lines — with live pricing, ' \
                   'availability, and rendering flags. Also use for product compatibility questions: which ' \
                   'heating system is best for a given room type, floor covering, or installation scenario ' \
                   '(e.g. "best floor heating for a sunroom", "what works under tile", ' \
                   '"recommendation for concrete subfloor")'
    },
    'product_management' => { label: 'Product Management', handle: 'product-management', icon: 'fa-toggle-on', description: 'Activate, hide, or discontinue products by firing catalog item state transitions — item_manager only' },
    'product_spec_management' => { label: 'Spec Management', handle: 'spec-management', icon: 'fa-sliders', description: 'Search, inspect, update, and clone product specifications — item_manager only' },
    'blog_management' => { label: 'Blog Management', handle: 'blog-management', icon: 'fa-pen-to-square', description: 'Create, update, and manage blog posts with embedded images, videos, FAQs, and products' },
    'keywords_people_use' => { label: 'KeywordsPeopleUse', handle: 'keywords-people-use', icon: 'fa-key', description: 'Keyword research, People Also Ask questions, and autocomplete suggestions' },
    'basecamp' => { label: 'Basecamp', handle: 'basecamp', icon: 'fa-campground', description: 'Projects, todos, search, and team collaboration' },
    'weather'  => { label: 'Weather', handle: 'weather', icon: 'fa-cloud-sun', description: 'Current conditions, forecasts, and historical weather for any location via Visual Crossing' },
    'web_fetch' => { label: 'Web Fetch', handle: 'web-fetch', icon: 'fa-globe', description: 'Fetch and read the content of any public URL (web pages, articles, docs, competitor sites)' },
    'seo_audit' => {
      label: 'SEO Audit', handle: 'seo-audit', icon: 'fa-network-wired',
      description: 'SEO action items and recommendations — view, update status (mark completed/pending), ' \
                   'internal link graph, page crawl data, AI SEO reports, link gap analysis, and orphan page detection'
    },
    'gamma' => { label: 'Gamma', handle: 'gamma', icon: 'fa-display', description: 'Create AI-generated presentations, documents, social posts, and webpages with Gamma' },
    'support_cases' => { label: 'Support Cases', handle: 'support-cases', icon: 'fa-headset', description: 'Search support cases, activity notes, and communications for customer issues, complaints, and resolutions' },
    'brain_management' => { label: 'Brain Management', handle: 'brain-management', icon: 'fa-brain', description: 'Propose new learned rules for the Sunny brain when you self-correct or receive domain feedback' },
    'sales_management' => {
      label: 'Sales Management', handle: 'sales-management', icon: 'fa-users-gear',
      description: 'Sales force management tools — look up employees by name, check who is working today, ' \
                   'review deal pipelines by rep, assess activity workloads, get performance snapshots, ' \
                   'and review recent call activity'
    }
  }.freeze
end

.default_services_for(account) ⇒ Array<String>

Determine default tool services for a given account based on role.
Combines core services (DB/content) with external tool services from DataDomainPolicy.

Parameters:

  • account (Account)

    the user's account record

Returns:

  • (Array<String>)

    default service keys



289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'app/services/assistant/chat_tool_builder.rb', line 289

def self.default_services_for()
  core = if .is_admin?
           ROLE_DEFAULTS[:admin]
         elsif .is_manager?
           ROLE_DEFAULTS[:manager]
         else
           ROLE_DEFAULTS[:employee]
         end

  # Merge in external tool services (google_analytics, ahrefs, etc.)
  tool_services = Assistant::DataDomainPolicy.tool_services_for(account: )

  (core + tool_services).uniq
end

.role_for(account) ⇒ Symbol

Determine the role tier for an account (for DB tool access control)

Returns:

  • (Symbol)

    :admin, :manager, or :employee



306
307
308
309
310
311
312
313
314
# File 'app/services/assistant/chat_tool_builder.rb', line 306

def self.role_for()
  if .is_admin?
    :admin
  elsif .is_manager?
    :manager
  else
    :employee
  end
end

.service_for_tool(tool_name) ⇒ String?

Resolve a tool name to its service key.

Parameters:

  • tool_name (String)

    e.g. "app_db_execute_sql"

Returns:

  • (String, nil)

    service key or nil if unknown



139
140
141
# File 'app/services/assistant/chat_tool_builder.rb', line 139

def self.service_for_tool(tool_name)
  TOOL_SERVICE_MAP.find { |entry| tool_name.match?(entry[:pattern]) }&.dig(:service)
end

.service_key_for_handle(handle) ⇒ String?

Resolve a UI chip / @mention handle (kebab-case) to a service key.
Used by ToolRouter so badge hidden inputs always map to catalog entries
(avoids drift when a new service is added to +chat_services+ but MENTION_ALIASES lags).

Parameters:

  • handle (String)

    e.g. "seo-audit", "web-fetch"

Returns:

  • (String, nil)

    service key e.g. "seo_audit", or nil if unknown



211
212
213
214
215
216
# File 'app/services/assistant/chat_tool_builder.rb', line 211

def self.service_key_for_handle(handle)
  return nil if handle.blank?

  h = handle.to_s.strip.downcase
  service_keys_by_handle[h]
end

.service_keys_by_handleObject



218
219
220
221
222
# File 'app/services/assistant/chat_tool_builder.rb', line 218

def self.service_keys_by_handle
  @service_keys_by_handle ||= chat_services.each_with_object({}) do |(key, meta), acc|
    acc[meta[:handle].to_s.downcase] = key
  end
end

.services_from_history(conversation) ⇒ Array<String>

Detect all service keys referenced by tool calls in a conversation.
Queries through assistant_messages → assistant_tool_calls to find unique tool names.

Parameters:

Returns:

  • (Array<String>)

    unique service keys



147
148
149
150
151
152
153
# File 'app/services/assistant/chat_tool_builder.rb', line 147

def self.services_from_history(conversation)
  tool_names = AssistantToolCall
               .joins(:assistant_message)
               .where(assistant_messages: { assistant_conversation_id: conversation.id })
               .distinct.pluck(:name)
  tool_names.filter_map { |name| service_for_tool(name) }.uniq
end

.sticky_service_keys_for_plan(steps, permitted_keys, goal: nil) ⇒ Object

Parameters:

  • steps (Array<Hash, String>)

    the plan's step descriptions

  • permitted_keys (Array<String>)

    the caller's enabled tool services

  • goal (String, nil) (defaults to: nil)

    optional plan goal — scanned alongside step
    descriptions because the goal is usually the most reliable anchor
    of what the plan is about (the editor's wording, not the model's
    step rephrasing). Conversation 1657 lost blog_management on step 3
    ("Move the final CTA block before the FAQ block") despite the goal
    explicitly saying "blog post #5122".



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'app/services/assistant/chat_tool_builder.rb', line 232

def self.sticky_service_keys_for_plan(steps, permitted_keys, goal: nil)
  keys = Array(permitted_keys).map(&:to_s).uniq
  return [] if keys.empty?

  texts = []
  texts << goal.to_s if goal.present?
  Array(steps).each do |step|
    texts << (step.is_a?(Hash) ? step['description'].to_s : step.to_s)
  end
  return [] if texts.all?(&:blank?)

  sticky = []
  texts.each do |desc|
    STICKY_SERVICE_PATTERNS.each do |re, svc_keys|
      sticky.concat(svc_keys) if desc.match?(re)
    end
  end
  (sticky.uniq & keys)
end

.tool_service_keys_for_step(step_description, permitted_keys, sticky_keys: []) ⇒ Object



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
# File 'app/services/assistant/chat_tool_builder.rb', line 252

def self.tool_service_keys_for_step(step_description, permitted_keys, sticky_keys: [])
  keys = Array(permitted_keys).map(&:to_s).uniq
  return keys if step_description.blank? || keys.empty?

  desc = step_description.to_s
  matched = []
  STEP_DESCRIPTION_SERVICE_PATTERNS.each do |re, svc_keys|
    matched.concat(svc_keys) if desc.match?(re)
  end
  matched.uniq!
  return keys if matched.empty?

  intersection = matched & keys
  result = intersection.presence || keys

  # Merge in sticky keys (e.g. blog_management when any plan step is blog-related)
  sticky = Array(sticky_keys) & keys
  result = (result + sticky).uniq if sticky.any?

  # Isolated SEO steps often need blog slug resolution (list_blog_posts,
  # get_blog_post). Those tools live in blog_management, not content.
  # Prefer blog_management when available; fall back to content for
  # semantic_search if blog_management isn't permitted.
  if result.include?('seo_audit')
    if keys.include?('blog_management') && result.exclude?('blog_management')
      result = (result + ['blog_management']).uniq
    elsif keys.include?('content') && result.exclude?('content')
      result = (result + ['content']).uniq
    end
  end
  result
end

.tools_for_services(service_keys, role: :employee, allowed_objects: nil, audit_context: {}, account: nil, provider: :anthropic, include_plan_tools: true) ⇒ Array<RubyLLM::Tool>

Returns instantiated tool objects ready for +chat.with_tools+.

Parameters:

  • service_keys (#to_a)

    list of service key strings (e.g. %w[content app_db])

  • role (Symbol) (defaults to: :employee)

    :admin, :manager, or :employee (DB tool access control)

  • allowed_objects (Set, nil) (defaults to: nil)

    domain-resolved allowed view/table names, or nil for unrestricted

  • audit_context (Hash) (defaults to: {})

    optional keys such as +:conversation_id+, +:user_id+ for SQL audit logging

  • account (Account, nil) (defaults to: nil)

    CRM account when a builder needs account context (e.g. Basecamp)

  • provider (Symbol) (defaults to: :anthropic)

    LLM provider (:anthropic, :gemini, :openai); guards +cache_control+ on Anthropic only

  • include_plan_tools (Boolean) (defaults to: true)

    when false, omit declare_plan / mark_step_complete (plan step executor)

Returns:

  • (Array<RubyLLM::Tool>)

    instantiated tool objects ready for +chat.with_tools+



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'app/services/assistant/chat_tool_builder.rb', line 324

def self.tools_for_services(service_keys, role: :employee, allowed_objects: nil, audit_context: {}, account: nil, provider: :anthropic, include_plan_tools: true)
  tools = service_keys.flat_map do |key|
    build_service_tools(key, role: role, allowed_objects: allowed_objects, audit_context: audit_context, account: )
  end.compact

  conversation_id = audit_context[:conversation_id]
  tools.concat(Assistant::PlanToolBuilder.tools(conversation_id: conversation_id)) if tools.any? && conversation_id.present? && include_plan_tools

  # Mark the last tool with an Anthropic cache_control breakpoint so the entire
  # tool array (including STYLE_GUIDE descriptions) is cached across turns.
  # provider_params on an instance delegates to self.class, so we set it on the class.
  #
  # IMPORTANT: tool builder methods are memoized (e.g. @content_tools ||= ...), so the
  # same tool CLASS instances are reused across requests. Always clear cache_control from
  # ALL tools first to prevent accumulation across requests with different service sets
  # (Anthropic allows a maximum of 4 cache_control blocks per request).
  #
  # For non-Anthropic providers (Gemini, OpenAI) cache_control is not supported and
  # must not be sent — clear any residual marks and skip setting a new one.
  if tools.any?
    tools.each do |tool|
      existing = tool.class.provider_params
      next unless existing.key?('cache_control')

      tool.class.instance_variable_set(:@provider_params, existing.except('cache_control'))
    end

    if provider == :anthropic
      last_tool_class = tools.last.class
      last_tool_class.instance_variable_set(
        :@provider_params,
        last_tool_class.provider_params.merge('cache_control' => { 'type' => 'ephemeral' })
      )
    end
  end

  tools
end

Instance Method Details

#build_gamma_toolsObject



645
646
647
# File 'app/services/assistant/chat_tool_builder.rb', line 645

public def build_gamma_tools
  safe_build_tools('Gamma') { Assistant::GammaToolBuilder.tools }
end

#build_mcp_tools(tool_names) ⇒ Object



664
665
666
667
668
669
# File 'app/services/assistant/chat_tool_builder.rb', line 664

public def build_mcp_tools(tool_names)
  ensure_mcp_tools_loaded!
  ApplicationTool.descendants
                 .select { |mcp_class| tool_names.include?(mcp_class.tool_name) }
                 .filter_map { |mcp_class| wrap_content_tool(mcp_class) }
end

#ensure_mcp_tools_loaded!Object



671
672
673
674
675
# File 'app/services/assistant/chat_tool_builder.rb', line 671

public def ensure_mcp_tools_loaded!
  return if ApplicationTool.descendants.any?

  Rails.root.glob('app/mcp/tools/**/*.rb').each { |tool_path| require_dependency tool_path }
end

#gamma_toolsObject

─── Gamma Tools ─────────────────────────────────────────────────



641
642
643
# File 'app/services/assistant/chat_tool_builder.rb', line 641

public def gamma_tools
  @gamma_tools ||= build_gamma_tools
end

#normalize_schema(raw_schema) ⇒ Object

Normalize MCP input_schema to RubyLLM-compatible JSON Schema



634
635
636
637
# File 'app/services/assistant/chat_tool_builder.rb', line 634

public def normalize_schema(raw_schema)
  schema = raw_schema.deep_symbolize_keys
  { type: 'object' }.merge(schema)
end

#safe_build_tools(label) ⇒ Object

─── Shared Helpers ───────────────────────────────────────────────



657
658
659
660
661
662
# File 'app/services/assistant/chat_tool_builder.rb', line 657

public def safe_build_tools(label)
  yield
rescue StandardError => e
  Rails.logger.warn("[ChatToolBuilder] Failed to build #{label} tools: #{e.message}")
  []
end

#sales_management_toolsObject

─── Sales Management Tools ──────────────────────────────────────



651
652
653
# File 'app/services/assistant/chat_tool_builder.rb', line 651

public def sales_management_tools
  @sales_management_tools ||= safe_build_tools('sales management') { Assistant::SalesManagementToolBuilder.tools }
end

#truncate_result(result_str, max_chars: MAX_TOOL_RESULT_CHARS) ⇒ Object



625
626
627
628
629
630
631
# File 'app/services/assistant/chat_tool_builder.rb', line 625

public def truncate_result(result_str, max_chars: MAX_TOOL_RESULT_CHARS)
  return result_str if result_str.length <= max_chars

  truncated = result_str[0...max_chars]
  truncated + "\n\n[RESULT TRUNCATED — #{result_str.length} chars total, showing first #{max_chars}. " \
              'Refine your query with more specific filters or a LIMIT clause to get smaller results.]'
end