Module: Assistant::SalesManagement::BriefTools

Includes:
Serializers
Defined in:
app/services/assistant/sales_management/brief_tools.rb

Overview

Bundled-lookup tools that resolve a single CRM entity (an opportunity
or a customer/contact) and return everything sales reps consistently
ask about — activities, quotes, orders, call records, related parties
— in one tool call. Replaces the historical 6–10 piecemeal SQL queries
that routinely burned through the per-turn tool budget before the
model could draft a follow-up email.

Constant Summary collapse

CUSTOMER_REFERENCE_REGEX =

Match either bare numerics or the CN… party prefix only — ON…,
SQ…, etc. would silently resolve a Party with that id and return a
valid-but-wrong customer brief.

/\A(?:CN)?(\d+)\z/i

Class Method Summary collapse

Methods included from Serializers

serialize_activity, serialize_call_record, serialize_opportunity_summary, serialize_order, serialize_quote

Class Method Details

.build_customer_brief_toolObject



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
314
315
316
# File 'app/services/assistant/sales_management/brief_tools.rb', line 276

def build_customer_brief_tool
  klass = Class.new(RubyLLM::Tool) do
    description 'Look up a customer/contact party by CRM party number (CNxxxxx) or id and ' \
                'return a bundled brief: party details, recent activities (configurable, ' \
                'default 20, max 50; default window is the last 14 days, max 90), open ' \
                'opportunities, the most recent 20 quotes and 20 orders (when the party is ' \
                'a customer), the most recent 10 linked call records, and open support cases. ' \
                'Use this BEFORE writing SQL when the user asks about a specific customer — ' \
                'it is one call instead of many and avoids guessing column names on ' \
                'view_activities. Each section reports its own count.'

    params type: 'object',
           properties: {
             reference: {
               type: 'string',
               description: 'CRM party number, e.g. "CN25803159" (with or without the CN prefix)'
             },
             id: {
               type: 'integer',
               description: 'Numeric party id (alternative to reference)'
             },
             activity_limit: {
               type: 'integer',
               description: 'Most recent N activities to include (default: 20, max: 50)'
             },
             activity_window_days: {
               type: 'integer',
               description: 'Look-back window in days for activities (default: 14, max: 90)'
             }
           }

    define_method(:name) { 'get_customer_brief' }
    define_method(:execute) do |reference: nil, id: nil, activity_limit: 20, activity_window_days: 14, **_|
      Assistant::SalesManagement::BriefTools.customer_brief(
        reference: reference, id: id,
        activity_limit: activity_limit, activity_window_days: activity_window_days
      )
    end
  end
  klass.new
end

.build_opportunity_brief_toolObject

─── Tool factories ─────────────────────────────────────────────



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
# File 'app/services/assistant/sales_management/brief_tools.rb', line 239

def build_opportunity_brief_tool
  klass = Class.new(RubyLLM::Tool) do
    description 'Look up an opportunity by reference number (ONxxxxx) or id and return ' \
                'a bundled brief: opportunity details, customer/contact, sales rep, ' \
                'the most recent activities (configurable, default 15, max 40), the most ' \
                'recent 20 quotes and 20 orders, and the most recent 10 linked call records. ' \
                'Use this BEFORE writing SQL when the user asks about a specific opportunity — ' \
                'it is one call instead of six and avoids guessing column names. ' \
                'Each section reports its own count; on long-running opportunities older items ' \
                'beyond these caps require a follow-up SQL query if needed.'

    params type: 'object',
           properties: {
             reference: {
               type: 'string',
               description: 'CRM reference number, e.g. "ON5734284" (with or without the ON prefix)'
             },
             id: {
               type: 'integer',
               description: 'Numeric opportunity id (alternative to reference)'
             },
             activity_limit: {
               type: 'integer',
               description: 'Most recent N activities to include (default: 15, max: 40)'
             }
           }

    define_method(:name) { 'get_opportunity_brief' }
    define_method(:execute) do |reference: nil, id: nil, activity_limit: 15, **_|
      Assistant::SalesManagement::BriefTools.opportunity_brief(
        reference: reference, id: id, activity_limit: activity_limit
      )
    end
  end
  klass.new
end

.customer_brief(reference: nil, id: nil, activity_limit: 20, activity_window_days: 14) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'app/services/assistant/sales_management/brief_tools.rb', line 118

def customer_brief(reference: nil, id: nil, activity_limit: 20, activity_window_days: 14)
  activity_limit = activity_limit.to_i.clamp(1, 50)
  activity_window_days = activity_window_days.to_i.clamp(1, 90)

  party = resolve_party(reference: reference, id: id)
  return party if party.is_a?(String) # error JSON

  Helpers.truncate_json(customer_brief_payload(
    party,
    activity_limit: activity_limit,
    activity_window_days: activity_window_days
  ).to_json)
rescue StandardError => e
  { error: e.message }.to_json
end

.customer_brief_counts(**counts) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'app/services/assistant/sales_management/brief_tools.rb', line 222

def customer_brief_counts(**counts)
  is_customer = !counts[:customer].nil?
  {
    activities: { returned: counts[:recent_activities].size, total_in_window: counts[:total_recent_activities],
                  cap: counts[:activity_limit], window_days: counts[:activity_window_days] },
    opportunities: { returned: counts[:opportunities].size, party_is_customer: is_customer },
    quotes: { returned: counts[:quotes].size, cap: counts[:quote_cap], party_is_customer: is_customer },
    orders: { returned: counts[:orders].size, cap: counts[:order_cap], party_is_customer: is_customer },
    call_records: { returned: counts[:call_records].size, total: counts[:total_call_records],
                    cap: counts[:call_cap] },
    open_support_cases: { returned: counts[:support_cases].size, total: counts[:total_open_support_cases],
                          cap: counts[:support_cap] }
  }
end

.customer_brief_party_header(party) ⇒ Object



199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'app/services/assistant/sales_management/brief_tools.rb', line 199

def customer_brief_party_header(party)
  {
    id: party.id,
    full_name: party.full_name,
    type: party.type,
    report_grouping: party.try(:report_grouping),
    primary_sales_rep: party.try(:primary_sales_rep)&.full_name,
    local_sales_rep: party.try(:local_sales_rep)&.full_name,
    email: party.try(:email),
    phone: party.try(:phone),
    crm_url: "#{CRM_URL}/parties/#{party.id}"
  }
end

.customer_brief_payload(party, activity_limit:, activity_window_days:) ⇒ Object



151
152
153
154
155
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
# File 'app/services/assistant/sales_management/brief_tools.rb', line 151

def customer_brief_payload(party, activity_limit:, activity_window_days:)
  window_start = activity_window_days.days.ago
  quote_cap = 20
  order_cap = 20
  call_cap = 10
  support_cap = 10

  recent_activities = party.activities
                           .where(activities: { created_at: window_start.. })
                           .includes(:activity_type, :assigned_resource, :resource_opportunity)
                           .recency_order
                           .limit(activity_limit)
  total_recent_activities = party.activities.where(activities: { created_at: window_start.. }).count

  customer = party.is_a?(Customer) ? party : nil
  opportunities = customer ? customer.opportunities.open_opportunities.most_recent_first.limit(20) : Opportunity.none
  # Explicit ordering — the description promises "most recent",
  # `customer.quotes` / `customer.orders` defaults aren't guaranteed
  # to apply when chained with `.limit`.
  quotes = customer ? customer.quotes.order(created_at: :desc).limit(quote_cap) : Quote.none
  orders = customer ? customer.orders.order(created_at: :desc).limit(order_cap) : Order.none

  call_records = CallRecord.for_party(party.id).order(created_at: :desc).limit(call_cap)
  total_call_records = CallRecord.for_party(party.id).count

  open_support_cases = party.support_cases.where.not(state: %w[closed resolved])
  support_cases = open_support_cases.order(created_at: :desc).limit(support_cap)

  {
    party: customer_brief_party_header(party),
    activities: recent_activities.map { |a| Serializers.serialize_activity(a, include_opportunity: true) },
    opportunities: opportunities.map { |o| Serializers.serialize_opportunity_summary(o) },
    quotes: quotes.map { |q| Serializers.serialize_quote(q) },
    orders: orders.map { |o| Serializers.serialize_order(o) },
    call_records: call_records.map { |cr| Serializers.serialize_call_record(cr) },
    support_cases: support_cases.map { |sc| serialize_support_case(sc) },
    counts: customer_brief_counts(
      recent_activities: recent_activities, total_recent_activities: total_recent_activities,
      opportunities: opportunities, quotes: quotes, orders: orders,
      call_records: call_records, total_call_records: total_call_records,
      support_cases: support_cases, total_open_support_cases: open_support_cases.count,
      activity_limit: activity_limit, activity_window_days: activity_window_days,
      quote_cap: quote_cap, order_cap: order_cap, call_cap: call_cap, support_cap: support_cap,
      customer: customer
    )
  }
end

.opportunity_brief(reference: nil, id: nil, activity_limit: 15) ⇒ Object

─── Opportunity Brief ──────────────────────────────────────────



22
23
24
25
26
27
28
29
30
# File 'app/services/assistant/sales_management/brief_tools.rb', line 22

def opportunity_brief(reference: nil, id: nil, activity_limit: 15)
  activity_limit = activity_limit.to_i.clamp(1, 40)
  opp = resolve_opportunity(reference: reference, id: id)
  return opp if opp.is_a?(String) # error JSON

  Helpers.truncate_json(opportunity_brief_payload(opp, activity_limit: activity_limit).to_json)
rescue StandardError => e
  { error: e.message }.to_json
end

.opportunity_brief_header(opp) ⇒ Object



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'app/services/assistant/sales_management/brief_tools.rb', line 77

def opportunity_brief_header(opp)
  {
    id: opp.id,
    reference_number: opp.reference_number,
    name: opp.name,
    state: opp.state,
    value: opp.value&.to_f,
    close_date: opp.close_date&.iso8601,
    planned_installation_date: opp.planned_installation_date&.iso8601,
    created_at: opp.created_at&.iso8601,
    customer: opp.customer&.full_name,
    customer_id: opp.customer_id,
    contact: opp.contact&.full_name,
    contact_id: opp.contact_id,
    primary_sales_rep: opp.primary_sales_rep&.full_name,
    secondary_sales_rep: opp.secondary_sales_rep&.full_name,
    local_sales_rep: opp.local_sales_rep&.full_name,
    technical_support_rep: opp.technical_support_rep&.full_name,
    source: opp.source&.full_name,
    opportunity_type: opp.opportunity_type,
    crm_url: "#{CRM_URL}/opportunities/#{opp.id}"
  }
end

.opportunity_brief_payload(opp, activity_limit:) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'app/services/assistant/sales_management/brief_tools.rb', line 48

def opportunity_brief_payload(opp, activity_limit:)
  quote_cap = 20
  order_cap = 20
  call_cap  = 10

  activities = opp.activities
                  .includes(:activity_type, :party, :assigned_resource)
                  .recency_order
                  .limit(activity_limit)
  quotes = opp.quotes.order(created_at: :desc).limit(quote_cap)
  orders = opp.orders.order(created_at: :desc).limit(order_cap)
  call_records = opportunity_call_records(opp, limit: call_cap)
  total_call_records = opportunity_call_record_ids(opp).size

  {
    opportunity: opportunity_brief_header(opp),
    activities: activities.map { |a| Serializers.serialize_activity(a) },
    quotes: quotes.map { |q| Serializers.serialize_quote(q, include_ship_to: true) },
    orders: orders.map { |o| Serializers.serialize_order(o) },
    call_records: call_records.map { |cr| Serializers.serialize_call_record(cr) },
    counts: {
      activities: { returned: activities.size, total: opp.activities.count, cap: activity_limit },
      quotes: { returned: quotes.size, total: opp.quotes.count, cap: quote_cap },
      orders: { returned: orders.size, total: opp.orders.count, cap: order_cap },
      call_records: { returned: call_records.size, total: total_call_records, cap: call_cap }
    }
  }
end

.opportunity_call_record_ids(opp) ⇒ Object



101
102
103
# File 'app/services/assistant/sales_management/brief_tools.rb', line 101

def opportunity_call_record_ids(opp)
  Activity.where(resource_type: 'CallRecord', opportunity_id: opp.id).distinct.pluck(:resource_id)
end

.opportunity_call_records(opp, limit:) ⇒ Object



105
106
107
108
109
# File 'app/services/assistant/sales_management/brief_tools.rb', line 105

def opportunity_call_records(opp, limit:)
  CallRecord.where(id: opportunity_call_record_ids(opp))
            .order(created_at: :desc)
            .limit(limit)
end

.resolve_opportunity(reference:, id:) ⇒ Object



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'app/services/assistant/sales_management/brief_tools.rb', line 32

def resolve_opportunity(reference:, id:)
  if id.present?
    Opportunity.find_by(id: id) ||
      { error: "No opportunity found for id=#{id.inspect}.",
        hint: 'Check the reference number for typos. The CRM URL contains both id and reference.' }.to_json
  elsif reference.present?
    ref = reference.to_s.strip.upcase
    ref = "ON#{ref}" if ref.match?(/\A\d+\z/)
    Opportunity.find_by(reference_number: ref) ||
      { error: "No opportunity found for reference=#{reference.inspect}.",
        hint: 'Check the reference number for typos. The CRM URL contains both id and reference.' }.to_json
  else
    { error: 'Provide reference (e.g. "ON5734284") or id.' }.to_json
  end
end

.resolve_party(reference:, id:) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'app/services/assistant/sales_management/brief_tools.rb', line 134

def resolve_party(reference:, id:)
  party = if id.present?
            Party.find_by(id: id)
          elsif reference.present?
            numeric = reference.to_s.strip.match(CUSTOMER_REFERENCE_REGEX)&.captures&.first
            numeric ? Party.find_by(id: numeric.to_i) : nil
          else
            return { error: 'Provide reference (e.g. "CN25803159") or id.' }.to_json
          end
  return party if party

  {
    error: "No party found for reference=#{reference.inspect} id=#{id.inspect}.",
    hint: 'CN identifiers strip to a numeric party id; check the CRM URL for the right id.'
  }.to_json
end

.serialize_support_case(support_case) ⇒ Object



213
214
215
216
217
218
219
220
# File 'app/services/assistant/sales_management/brief_tools.rb', line 213

def serialize_support_case(support_case)
  {
    id: support_case.id,
    state: support_case.state,
    subject: support_case.try(:subject),
    created_at: support_case.created_at&.iso8601
  }.compact
end

.toolsObject



16
17
18
# File 'app/services/assistant/sales_management/brief_tools.rb', line 16

def tools
  [build_opportunity_brief_tool, build_customer_brief_tool]
end