Class: Assistant::CustomerSearchToolBuilder

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

Overview

Builds RubyLLM::Tool subclasses that expose the CRM advanced-search system
(CustomerSearch / view_customers ransack) to Sunny: discover the filter
vocabulary, read saved searches, and dry-run criteria — so dynamic
audiences (AudienceToolBuilder) are built from DISCOVERED criteria
instead of a hand-maintained cheat sheet.

Tools:
describe_customer_search — the full filter vocabulary: custom
(non-ransack) criteria with semantics, bounded fields with LIVE allowed
values, per-column type + prose from the data-model manifest, and the
predicate-suffix guide.
list_saved_customer_searches — employee-saved favorites + global
favorites, with their query_params (start from a human-built search).
run_customer_search — execute criteria: total count + a page of
matching customers + a CRM search_and_show link the user can open to
review/export (export stays in the CRM mass-export flow on purpose).
create_customer_search — save a validated search for the caller.
update_customer_search — modify one of the caller's saved searches.

Customer results are read-only; the two write tools only manage the caller's
saved-search records. Validation reuses the same strict ransack probe as
Audience#assert_valid_customer_search_params!.

Usage (via ChatToolBuilder):
tools = Assistant::CustomerSearchToolBuilder.tools(audit_context: { user_id: 42 })

Constant Summary collapse

ENUMERATION_CAP =

Values returned per bounded field before truncation (long live lists like
campaigns/product lines get cut, with truncated: true).

50
ENUMERATION_CACHE_TTL =
15.minutes
ENUMERATION_SOURCES =

Bounded filter fields → the LIVE enumerator the CRM criteria form uses.
A pointer table into the app's own methods, so values never go stale.

{
  'state' => -> { Customer.states_for_select },
  'creation_method' => -> { Customer.creation_methods.keys },
  'report_grouping' => -> { SalesGoal.channels_for_select },
  'rating' => -> { Customer.rating_options_for_select_with_rank },
  'store_id' => -> { Store.options_for_select },
  'catalog_id' => -> { Catalog.options_for_select },
  'profile' => -> { CustomerSearch.select_options_for_profile },
  'buying_group_id' => -> { BuyingGroup.options_for_select },
  'affiliations' => -> { Customer.affiliations_for_select },
  'main_address_state_code' => -> { State.states_for_select },
  'main_address_country_iso3' => -> { Country.countries_for_select },
  'campaign_id' => -> { Campaign.options_for_select },
  'has_ordered_product_line_id' => -> { ProductLine.select_options },
  'has_ordered_product_category_id' => -> { ProductCategory.select_options },
  'custom_drop_reason_codes' => -> { CustomerDropEvent.options_for_select },
  'contact_point_category' => -> { ContactPoint.category_options_for_select },
  'floor_types_interest_last_2_years' => -> { FloorType.options_for_select },
  'room_types_interest_last_2_years' => -> { RoomType.options_for_select },
  'data_import_id' => -> { DataImport.options_for_select },
  'has_ordered_type' => -> { %w[orders quotes both] }
}.freeze
CUSTOM_CRITERIA_GUIDE =

One-line semantics for the non-ransack custom criteria (consumed by
Models::CustomerSearchCriteria — see that concern for the full story).

{
  'has_ordered_item_id' => 'Has an invoiced order containing item id(s).',
  'has_ordered_product_category_id' => 'Has an invoiced order in product category id(s), descendants included (ltree).',
  'has_ordered_product_line_id' => 'Has an invoiced order in product line id(s), descendants included (ltree).',
  'has_ordered_days' => 'Ordered within the last N days.',
  'has_ordered_days_time_range_gteq' => 'Ordered at most N days ago (recent bound; pair with _lteq for a window).',
  'has_ordered_days_time_range_lteq' => 'Last ordered at least N days ago (older bound).',
  'minimum_number_of_orders' => 'At least N distinct matching orders.',
  'has_ordered_type' => "Which pipeline the has_ordered_* keys test: 'orders' (default), 'quotes', 'both'.",
  'has_not_ordered_days' => 'NO invoiced order shipped in the last N days — dormancy. Composes with the containment keys (e.g. a product line + 730 = dormant buyers of that line).',
  'has_not_quoted_days' => 'NO quote created in the last N days, any state — "hasn\'t engaged". Reminder lists regenerate at send time, so responders drop out automatically.',
  'campaign_id_in' => 'Active member of one of these campaigns.',
  'campaign_id_not_in' => 'NOT an active member of any of these campaigns.',
  'has_campaign_activity_status' => "With campaign_id_in: 'open' | 'any' | 'none' activity on those campaigns.",
  'custom_drop_reason_codes' => 'Dropped accounts with these reason codes.',
  'custom_drop_rep_ids' => 'Accounts dropped by these rep ids.',
  'profile_in' => "Profile id(s); sentinels 'ORG' (any organization) and 'TRADE' (all trade profiles, expanded live).",
  'profile_not_in' => 'Exclude profile id(s); same sentinels.',
  'is_certified_installer' => 'true = certified installers only.'
}.freeze
DISTANCE_GUIDE =

Distance-search keys (Search::DISTANCE_SEARCH_KEYS) semantics.

{
  'within_miles' => 'Radius in miles — pair with within_miles_of or target_coordinates.',
  'within_miles_of' => 'Center the radius on a ZIP/postcode.',
  'target_coordinates' => 'Center the radius on "lat,lng".',
  'cross_reference_customer_search_id' => 'Restrict to the result set of another saved CustomerSearch id.'
}.freeze
PREDICATE_GUIDE =

How ransack predicate suffixes map to column types — the shapes the CRM
criteria form produces.

<<~GUIDE
  Ransack predicate suffixes by column type:
  - text (full_name, customer_main_email, main_address_city, …): _cont (case-insensitive contains), _eq, _present
  - bounded / enum / foreign-key columns: _in / _not_in with values from `enumerations`
  - numeric / date (lifetime_revenue, trailing_twelve_months_revenue, last_order_date, customer_since, …): _eq / _gteq / _lteq
  - array columns (affiliations, floor_types_interest_last_2_years, room_types_interest_last_2_years): _overlap_array
  - booleans (open_sales_activity, has_online_account, email_unsubscribed, …): _eq true/false
  Special ransackable scopes: contact_point_cont (any email/phone contains),
  contact_point_category_cont, source_id (auto-includes descendant sources),
  watch, dealer_record_state, has_an_activity_of_type, has_an_open_activity_of_type,
  has_one_of_these_topic_responses, projects_per_year, number_of_offices, prospect,
  and the *_sales_rep_id_includes rep-name filters ('Unassigned' sentinel).
GUIDE

Class Method Summary collapse

Class Method Details

.build_create_customer_search_tool(audit_context) ⇒ Object



419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'app/services/assistant/customer_search_tool_builder.rb', line 419

def build_create_customer_search_tool(audit_context)
  captured_ctx = audit_context

  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      Save a customer advanced search as a named favorite for the current
      user — the same "save this search" the CRM UI offers. Criteria are
      validated before saving (bad keys return the ransack error). Returns
      the CRM link that runs it. Default is a persistent favorite; pass
      persist: false for a scratch search, and pinned: true to pin it.
    DESC

    parameters type: 'object',
           properties: {
             name: { type: 'string', description: 'Saved-search name. Required.' },
             customer_search_params: { type: 'object', description: 'Ransack customer-search criteria. Required.' },
             persist: { type: 'boolean', description: 'Keep as a favorite (default true).' },
             pinned: { type: 'boolean', description: 'Pin to the navbar (default false).' }
           },
           required: %w[name customer_search_params]

    define_method(:name) { 'create_customer_search' }

    define_method(:execute) do |**attributes|
      Assistant::CustomerSearchToolBuilder.create_customer_search(
        attributes, actor_id: captured_ctx[:user_id]
      )
    end
  end

  klass.new
end

.build_describe_customer_search_toolObject



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

def build_describe_customer_search_tool
  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      Discover the CRM customer advanced-search vocabulary BEFORE composing
      customer_search_params: custom (non-ransack) criteria and semantics,
      every searchable column with its type and description, the available
      bounded-field names, and the predicate-suffix guide
      (_cont/_in/_gteq/_overlap_array/…). Pass only the bounded `fields`
      whose LIVE allowed values you need (states, profiles, stores,
      campaigns, product lines, …); omitting fields avoids querying all
      option tables. Then iterate with estimate_audience_size /
      run_customer_search.
    DESC

    parameters type: 'object',
           properties: {
             fields: {
               type: 'array',
               items: { type: 'string', enum: ENUMERATION_SOURCES.keys },
               description: 'Bounded fields whose live allowed values to include. Optional; omit for none.'
             }
           },
           required: []

    define_method(:name) { 'describe_customer_search' }

    define_method(:execute) do |fields: nil, **_|
      Assistant::CustomerSearchToolBuilder.describe_customer_search(fields:)
    end
  end

  klass.new
end

.build_list_saved_customer_searches_tool(audit_context) ⇒ Object



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'app/services/assistant/customer_search_tool_builder.rb', line 362

def build_list_saved_customer_searches_tool(audit_context)
  captured_ctx = audit_context

  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      List YOUR saved customer searches (name + query_params + CRM link)
      and the built-in global favorites. Read one to START from a proven
      criteria set instead of composing from scratch — then adjust and
      dry-run with run_customer_search / estimate_audience_size.
    DESC

    parameters type: 'object',
           properties: {
             search: { type: 'string', description: 'Case-insensitive match on the saved-search name. Optional.' }
           },
           required: []

    define_method(:name) { 'list_saved_customer_searches' }

    define_method(:execute) do |search: nil, **_|
      Assistant::CustomerSearchToolBuilder.list_saved_customer_searches(
        search:, actor_id: captured_ctx[:user_id]
      )
    end
  end

  klass.new
end

.build_run_customer_search_toolObject



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'app/services/assistant/customer_search_tool_builder.rb', line 391

def build_run_customer_search_tool
  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      Execute a customer advanced search and return the total count, a page
      of matching customers (id, name, state, email, location, lifetime
      revenue, rep), and a CRM link that runs the same criteria in the
      search UI — open that link to review the full grid or export (the
      CRM's mass-export flow). Use this to sanity-check criteria before
      creating a dynamic audience from them. Read-only.
    DESC

    parameters type: 'object',
           properties: {
             customer_search_params: { type: 'object', description: 'Ransack customer-search criteria. Required.' },
             limit: { type: 'integer', description: 'Rows to return (default 25, max 100).' }
           },
           required: %w[customer_search_params]

    define_method(:name) { 'run_customer_search' }

    define_method(:execute) do |customer_search_params:, limit: 25, **_|
      Assistant::CustomerSearchToolBuilder.run_customer_search(customer_search_params:, limit:)
    end
  end

  klass.new
end

.build_update_customer_search_tool(audit_context) ⇒ Object



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'app/services/assistant/customer_search_tool_builder.rb', line 452

def build_update_customer_search_tool(audit_context)
  captured_ctx = audit_context

  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      Modify one of the current user's OWN saved customer searches:
      rename it, replace its criteria (validated before saving), or
      pin/unpin it. Another user's saved searches are neither listed nor
      modifiable by these tools.
    DESC

    parameters type: 'object',
           properties: {
             search_id: { type: 'integer', description: 'Saved search id (from list_saved_customer_searches). Required.' },
             name: { type: 'string', description: 'New name. Optional.' },
             customer_search_params: { type: 'object', description: 'Replacement criteria (full replace, not a merge). Optional.' },
             pinned: { type: 'boolean', description: 'Pin/unpin. Optional.' }
           },
           required: %w[search_id]

    define_method(:name) { 'update_customer_search' }

    define_method(:execute) do |**attributes|
      Assistant::CustomerSearchToolBuilder.update_customer_search(
        attributes, actor_id: captured_ctx[:user_id]
      )
    end
  end

  klass.new
end

.create_customer_search(attributes, actor_id:) ⇒ String

Persists a validated saved search for the caller.

Parameters:

  • attributes (Hash)

    tool keyword arguments

  • actor_id (Integer, nil)

    caller employee id

Returns:

  • (String)

    JSON tool response



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

def create_customer_search(attributes, actor_id:)
  return { error: 'name is required.' }.to_json if attributes[:name].blank?
  return criteria_shape_error unless valid_criteria_shape?(attributes[:customer_search_params])

  error = validate_criteria(attributes[:customer_search_params])
  return error if error

  saved_search = CustomerSearch.new(
    name: attributes[:name], query_params: attributes[:customer_search_params],
    persist: attributes.fetch(:persist, true).to_b,
    pinned: attributes.fetch(:pinned, false).to_b,
    employee: Employee.find_by(id: actor_id)
  )
  return { error: "Could not save the search: #{saved_search.errors.full_messages.to_sentence}" }.to_json unless saved_search.save

  saved_search_success_payload(saved_search).merge(
    message: "Saved '#{saved_search.name}' — the CRM link runs it."
  ).to_json
rescue StandardError => e
  tool_error(e)
end

.describe_customer_search(fields: nil) ⇒ String

Returns the discoverable search vocabulary and any requested live values.

Parameters:

  • fields (Array<String>, nil) (defaults to: nil)

    bounded enumeration fields to load

Returns:

  • (String)

    JSON tool response



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'app/services/assistant/customer_search_tool_builder.rb', line 166

def describe_customer_search(fields: nil)
  manifest = Assistant::CommentManifest.details('view_customers') || {}
  payload = {
    custom_criteria: CUSTOM_CRITERIA_GUIDE,
    distance_criteria: DISTANCE_GUIDE,
    enumeration_fields: ENUMERATION_SOURCES.keys,
    enumerations: enumerations(fields:),
    predicate_guide: PREDICATE_GUIDE,
    columns: manifest[:columns],
    tips: manifest[:tips]
  }
  Assistant::ChatToolBuilder.truncate_result(payload.to_json)
rescue StandardError => e
  tool_error(e)
end

.enumerations(fields: []) ⇒ Hash{String => Hash}

Resolves requested bounded fields to their live, capped values.
Values are cached per field briefly so asking for one field neither loads
the other nineteen tables nor repeats an expensive options query every turn.

Parameters:

  • fields (Array<String, Symbol>) (defaults to: [])

    bounded field names to resolve

Returns:

  • (Hash{String => Hash})

    values and truncation metadata by field

Raises:



130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'app/services/assistant/customer_search_tool_builder.rb', line 130

def enumerations(fields: [])
  requested = Array(fields).map(&:to_s).uniq
  unknown = requested - ENUMERATION_SOURCES.keys
  raise ArgumentError, "Unknown enumeration field(s): #{unknown.to_sentence}" if unknown.any?

  requested.to_h do |field|
    values = Rails.cache.fetch("assistant/customer_search/enumerations/v1/#{field}",
                               expires_in: ENUMERATION_CACHE_TTL) do
      ENUMERATION_SOURCES.fetch(field).call
    end
    [field, { values: values.first(ENUMERATION_CAP), truncated: values.size > ENUMERATION_CAP }]
  end
end

.list_saved_customer_searches(search: nil, actor_id: nil) ⇒ String

Lists only the caller's saved favorites plus built-in global templates.

Parameters:

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

    optional name filter

  • actor_id (Integer, nil) (defaults to: nil)

    caller employee id

Returns:

  • (String)

    JSON tool response



187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'app/services/assistant/customer_search_tool_builder.rb', line 187

def list_saved_customer_searches(search: nil, actor_id: nil)
  employee = Employee.find_by(id: actor_id)
  scope = employee ? CustomerSearch.favorites.where(employee:) : CustomerSearch.none
  scope = scope.order(:name)
  scope = scope.where('searches.name ILIKE ?', "%#{ActiveRecord::Base.sanitize_sql_like(search)}%") if search.present?

  saved = scope.limit(50).map { |saved_search| saved_search_payload(saved_search) }
  global = CustomerSearch.query_favorites_templates.map do |template|
    { name: template.title, query_params: template.query_params, global: true }
  end
  { saved:, global_favorites: global }.to_json
rescue StandardError => e
  tool_error(e)
end

.run_customer_search(customer_search_params:, limit: 25) ⇒ String

Runs a validated customer search and returns one bounded result page.

Parameters:

  • customer_search_params (Hash)

    search criteria

  • limit (Integer) (defaults to: 25)

    maximum result rows

Returns:

  • (String)

    JSON tool response



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'app/services/assistant/customer_search_tool_builder.rb', line 207

def run_customer_search(customer_search_params:, limit: 25)
  return criteria_shape_error unless customer_search_params.is_a?(Hash) && customer_search_params.present?

  error = validate_criteria(customer_search_params)
  return error if error

  search = CustomerSearch.new(query_params: customer_search_params,
                              selected_columns: CustomerSearch.default_selected_columns)
  results = search.perform(1, nil, nil, false, limit.to_i.clamp(1, 100), false, false)
  rows = results.map { |customer| customer_search_row(customer) }
  {
    total_count: search.pagy_count,
    returned: rows.size,
    customers: rows,
    crm_url: search_url(customer_search_params),
    note: 'The CRM link runs these criteria in the advanced-search UI — review the full grid or export there.'
  }.to_json
rescue StandardError => e
  tool_error(e)
end

.search_url(customer_search_params) ⇒ Object

A CRM link that runs these criteria in the advanced-search UI (a human
reviews there; export is the UI's mass-export flow).



157
158
159
160
# File 'app/services/assistant/customer_search_tool_builder.rb', line 157

def search_url(customer_search_params)
  "#{CRM_URL}/searches/search_and_show?type=customer_search&" \
    "#{Rack::Utils.build_nested_query(query_params: customer_search_params)}"
end

.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>

Build the customer-search discovery/run tools.

Parameters:

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

    expects :user_id (Employee id — the owner of
    any saved search the write tools create or modify).

Returns:

  • (Array<RubyLLM::Tool>)


113
114
115
116
117
118
119
120
121
# File 'app/services/assistant/customer_search_tool_builder.rb', line 113

def tools(audit_context: {})
  [
    build_describe_customer_search_tool,
    build_list_saved_customer_searches_tool(audit_context),
    build_run_customer_search_tool,
    build_create_customer_search_tool(audit_context),
    build_update_customer_search_tool(audit_context)
  ]
end

.update_customer_search(attributes, actor_id:) ⇒ String

Updates a caller-owned saved search after validating replacement criteria.

Parameters:

  • attributes (Hash)

    tool keyword arguments

  • actor_id (Integer, nil)

    caller employee id

Returns:

  • (String)

    JSON tool response



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'app/services/assistant/customer_search_tool_builder.rb', line 260

def update_customer_search(attributes, actor_id:)
  saved_search = Employee.find_by(id: actor_id)&.searches&.find_by(
    id: attributes[:search_id], type: 'CustomerSearch'
  )
  unless saved_search
    return { error: "Saved search ##{attributes[:search_id]} not found among your searches — " \
                    'you can only modify your own.' }.to_json
  end

  error = customer_search_update_error(attributes)
  return error if error

  saved_search.name = attributes[:name] if attributes[:name].present?
  saved_search.query_params = attributes[:customer_search_params] unless attributes[:customer_search_params].nil?
  saved_search.pinned = attributes[:pinned].to_b unless attributes[:pinned].nil?
  return { error: "Could not update the search: #{saved_search.errors.full_messages.to_sentence}" }.to_json unless saved_search.save

  saved_search_success_payload(saved_search).merge(pinned: saved_search.pinned).to_json
rescue StandardError => e
  tool_error(e)
end

.validate_criteria(customer_search_params) ⇒ Object

Validate criteria exactly as the audience tools do — the strict ransack
probe with the custom/distance keys stripped. Returns an error JSON
string on failure, nil when valid.



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

def validate_criteria(customer_search_params)
  probe_params = customer_search_params.except(*CustomerSearch::CUSTOM_CRITERIA_KEYS, *Search::DISTANCE_SEARCH_KEYS)
  ViewCustomer.ransack(probe_params, ignore_unknown_conditions: false)
  nil
rescue Ransack::InvalidSearchError => e
  { error: "Invalid customer search criteria: #{e.message}" }.to_json
end