Class: Assistant::ChatToolBuilder
- Inherits:
-
Object
- Object
- Assistant::ChatToolBuilder
- 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 thetypesparameter 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. [ [/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
-
.chat_services ⇒ Object
Services that can be offered in the assistant chat.
-
.default_services_for(account) ⇒ Array<String>
Determine default tool services for a given account based on role.
-
.role_for(account) ⇒ Symbol
Determine the role tier for an account (for DB tool access control).
-
.service_for_tool(tool_name) ⇒ String?
Resolve a tool name to its service key.
-
.service_key_for_handle(handle) ⇒ String?
Resolve a UI chip / @mention handle (kebab-case) to a service key.
- .service_keys_by_handle ⇒ Object
-
.services_from_history(conversation) ⇒ Array<String>
Detect all service keys referenced by tool calls in a conversation.
- .sticky_service_keys_for_plan(steps, permitted_keys, goal: nil) ⇒ Object
- .tool_service_keys_for_step(step_description, permitted_keys, sticky_keys: []) ⇒ Object
-
.tools_for_services(service_keys, role: :employee, allowed_objects: nil, audit_context: {}, account: nil, provider: :anthropic, include_plan_tools: true) ⇒ Array<RubyLLM::Tool>
Instantiated tool objects ready for +chat.with_tools+.
Instance Method Summary collapse
- #build_gamma_tools ⇒ Object
- #build_mcp_tools(tool_names) ⇒ Object
- #ensure_mcp_tools_loaded! ⇒ Object
-
#gamma_tools ⇒ Object
─── Gamma Tools ─────────────────────────────────────────────────.
-
#normalize_schema(raw_schema) ⇒ Object
Normalize MCP input_schema to RubyLLM-compatible JSON Schema.
-
#safe_build_tools(label) ⇒ Object
─── Shared Helpers ───────────────────────────────────────────────.
-
#sales_management_tools ⇒ Object
─── Sales Management Tools ──────────────────────────────────────.
- #truncate_result(result_str, max_chars: MAX_TOOL_RESULT_CHARS) ⇒ Object
Class Method Details
.chat_services ⇒ Object
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.
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(account) core = if account.is_admin? ROLE_DEFAULTS[:admin] elsif account.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: account) (core + tool_services).uniq end |
.role_for(account) ⇒ Symbol
Determine the role tier for an account (for DB tool access control)
306 307 308 309 310 311 312 313 314 |
# File 'app/services/assistant/chat_tool_builder.rb', line 306 def self.role_for(account) if account.is_admin? :admin elsif account.is_manager? :manager else :employee end end |
.service_for_tool(tool_name) ⇒ String?
Resolve a tool name to its service key.
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).
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_handle ⇒ Object
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, ), acc| acc[[: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.
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
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+.
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: 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_tools ⇒ Object
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_tools ⇒ Object
─── 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.}") [] end |
#sales_management_tools ⇒ Object
─── 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 |