Module: Assistant::SalesManagement::PipelineTools

Defined in:
app/services/assistant/sales_management/pipeline_tools.rb

Overview

Pipeline + workload + performance + recent-call tools. Each one is
rep-centric: lookups are anchored on an employee (or all employees in
a role) and return a snapshot of the deals, activities, or calls
they're working with.

Class Method Summary collapse

Class Method Details

.build_pipeline_summary_toolObject

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



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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 289

def build_pipeline_summary_tool
  klass = Class.new(RubyLLM::Tool) do
    description 'Get a summary of the sales pipeline — opportunities grouped by stage, rep, or time period. ' \
                'Shows open deals, values, close dates, and recent state changes. ' \
                'Use this to understand what a rep is working on, pipeline health, and deal progress.'

    params type: 'object',
           properties: {
             rep_id: {
               type: 'integer',
               description: 'Employee ID of a specific sales rep (filters to their opportunities)'
             },
             rep_name: {
               type: 'string',
               description: 'Sales rep name (partial match) — alternative to rep_id'
             },
             state: {
               type: 'string',
               description: 'Filter by opportunity state (e.g. "quoting", "follow_up", "promised", "won", "lost")'
             },
             open_only: {
               type: 'boolean',
               description: 'Only show open opportunities (default: true)'
             },
             since: {
               type: 'string',
               description: 'Only opportunities created after this date (YYYY-MM-DD)'
             },
             limit: {
               type: 'integer',
               description: 'Maximum opportunities to return (default: 25, max: 50)'
             }
           }

    define_method(:name) { 'get_pipeline_summary' }
    define_method(:execute) do |rep_id: nil, rep_name: nil, state: nil, open_only: true, since: nil, limit: 25, **_|
      Assistant::SalesManagement::PipelineTools.pipeline_summary(
        rep_id: rep_id, rep_name: rep_name, state: state,
        open_only: open_only, since: since, limit: limit
      )
    end
  end
  klass.new
end

.build_recent_calls_toolObject



388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 388

def build_recent_calls_tool
  klass = Class.new(RubyLLM::Tool) do
    description 'Get recent call records for a sales rep or team, with duration, direction, ' \
                'outcome, and key topics from transcripts. Use for understanding call activity ' \
                'and following up on action items from calls.'

    params type: 'object',
           properties: {
             rep_id: { type: 'integer', description: 'Employee ID to get calls for' },
             rep_name: { type: 'string', description: 'Rep name (partial match) — alternative to rep_id' },
             days: { type: 'integer', description: 'Look-back period in days (default: 7)' },
             limit: { type: 'integer', description: 'Maximum calls to return (default: 20, max: 50)' }
           }

    define_method(:name) { 'get_recent_calls' }
    define_method(:execute) do |rep_id: nil, rep_name: nil, days: 7, limit: 20, **_|
      Assistant::SalesManagement::PipelineTools.recent_calls(
        rep_id: rep_id, rep_name: rep_name, days: days, limit: limit
      )
    end
  end
  klass.new
end

.build_rep_performance_toolObject



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

def build_rep_performance_tool
  klass = Class.new(RubyLLM::Tool) do
    description 'Get a performance snapshot for a sales rep: recent quotes, orders, ' \
                'won/lost opportunities, and activity completion rates. ' \
                'Useful for sales manager reviews and planning conversations.'

    params type: 'object',
           properties: {
             rep_id: { type: 'integer', description: 'Employee ID of the sales rep' },
             rep_name: { type: 'string', description: 'Rep name (partial match) — alternative to rep_id' },
             period_days: { type: 'integer', description: 'Look-back period in days (default: 30)' }
           },
           required: []

    define_method(:name) { 'get_rep_performance' }
    define_method(:execute) do |rep_id: nil, rep_name: nil, period_days: 30, **_|
      Assistant::SalesManagement::PipelineTools.rep_performance(
        rep_id: rep_id, rep_name: rep_name, period_days: period_days
      )
    end
  end
  klass.new
end

.build_rep_workload_toolObject



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

def build_rep_workload_tool
  klass = Class.new(RubyLLM::Tool) do
    description 'Get the activity workload for one or more sales reps. Shows open activities, ' \
                'overdue activities, today\'s activities, and workload capacity. ' \
                'Use this to understand how busy a rep is and plan their day.'

    params type: 'object',
           properties: {
             rep_id: { type: 'integer', description: 'Employee ID of a specific rep' },
             rep_name: { type: 'string', description: 'Rep name (partial match) — alternative to rep_id' },
             role: {
               type: 'string',
               description: 'Filter by role to get workload for all reps of a type (e.g. "sales_rep")'
             },
             date: {
               type: 'string',
               description: 'Date to check workload for (YYYY-MM-DD, default: today)'
             }
           }

    define_method(:name) { 'get_rep_workload' }
    define_method(:execute) do |rep_id: nil, rep_name: nil, role: nil, date: nil, **_|
      Assistant::SalesManagement::PipelineTools.rep_workload(
        rep_id: rep_id, rep_name: rep_name, role: role, date: date
      )
    end
  end
  klass.new
end

.pipeline_apply_rep_filter(scope, rep_id:, rep_name:) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 45

def pipeline_apply_rep_filter(scope, rep_id:, rep_name:)
  return [scope.assigned_to_rep(rep_id), nil] if rep_id.present?
  return [scope, nil] if rep_name.blank?

  rep_ids = Employee.active_employees.where('parties.full_name ILIKE ?', "%#{rep_name}%").ids
  if rep_ids.empty?
    [nil, { error: "No employee found matching '#{rep_name}'",
            suggestion: 'Try find_employee first to get the exact name or ID.' }.to_json]
  else
    [scope.assigned_to_rep(rep_ids), nil]
  end
end

.pipeline_grouped_by_state(opportunities) ⇒ Object



58
59
60
61
62
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 58

def pipeline_grouped_by_state(opportunities)
  opportunities.group_by(&:state).transform_values do |opps|
    { count: opps.size, total_value: opps.sum { |opp| opp.value.to_f }.round(2) }
  end
end

.pipeline_summary(rep_id: nil, rep_name: nil, state: nil, open_only: true, since: nil, limit: 25) ⇒ Object

─── pipeline_summary ───────────────────────────────────────────



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 23

def pipeline_summary(rep_id: nil, rep_name: nil, state: nil, open_only: true, since: nil, limit: 25)
  limit = limit.to_i.clamp(1, 50)
  scope = Opportunity.includes(:customer, :contact, :primary_sales_rep, :secondary_sales_rep,
                               :local_sales_rep, :quotes, :orders)
  scope, error = pipeline_apply_rep_filter(scope, rep_id: rep_id, rep_name: rep_name)
  return error if error

  scope = scope.open_opportunities if open_only
  scope = scope.where(state: state) if state.present?
  scope = scope.where(opportunities: { created_at: Date.parse(since).. }) if since.present?

  opportunities = scope.order(created_at: :desc).limit(limit).to_a
  Helpers.truncate_json({
    total_results: opportunities.size,
    pipeline_summary: pipeline_grouped_by_state(opportunities),
    total_pipeline_value: opportunities.sum { |opp| opp.value.to_f }.round(2),
    opportunities: opportunities.map { |opp| serialize_pipeline_opportunity(opp) }
  }.to_json)
rescue StandardError => e
  { error: e.message }.to_json
end

.recent_calls(rep_id: nil, rep_name: nil, days: 7, limit: 20) ⇒ Object

─── recent_calls ───────────────────────────────────────────────



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 247

def recent_calls(rep_id: nil, rep_name: nil, days: 7, limit: 20)
  days = days.to_i.clamp(1, 90)
  limit = limit.to_i.clamp(1, 50)
  since = days.days.ago

  emp, error = Helpers.resolve_employee(rep_id: rep_id, rep_name: rep_name)
  return error if error
  return { error: 'Employee not found. Use find_employee first.' }.to_json unless emp

  calls = CallRecord.for_party(emp.id)
                    .where(call_records: { created_at: since.. })
                    .order(created_at: :desc)
                    .limit(limit)
  results = calls.map { |c| serialize_recent_call(c) }
  Helpers.truncate_json({ employee: { id: emp.id, name: emp.full_name },
                          period: "Last #{days} days",
                          total_calls: results.size,
                          total_duration_minutes: (calls.sum { |call| call.duration_secs.to_i } / 60.0).round(1),
                          calls: results }.to_json)
rescue StandardError => e
  { error: e.message }.to_json
end

.rep_performance(rep_id: nil, rep_name: nil, period_days: 30) ⇒ Object

─── rep_performance ────────────────────────────────────────────



149
150
151
152
153
154
155
156
157
158
159
160
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 149

def rep_performance(rep_id: nil, rep_name: nil, period_days: 30)
  period_days = period_days.to_i.clamp(7, 365)
  since = period_days.days.ago

  emp, error = Helpers.resolve_employee(rep_id: rep_id, rep_name: rep_name)
  return error if error
  return { error: 'Employee not found. Use find_employee first to get the correct name or ID.' }.to_json unless emp

  Helpers.truncate_json(rep_performance_payload(emp, since: since, period_days: period_days).to_json)
rescue StandardError => e
  { error: e.message }.to_json
end

.rep_performance_payload(emp, since:, period_days:) ⇒ Object



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

def rep_performance_payload(emp, since:, period_days:)
  rep_opps = Opportunity.assigned_to_rep(emp.id)
  open_opps = rep_opps.open_opportunities
  won_recent = rep_opps.won.where(opportunities: { updated_at: since.. })
  lost_recent = rep_opps.lost.where(opportunities: { updated_at: since.. })

  recent_quotes = Quote.where(opportunity_id: rep_opps.select(:id))
                       .where(quotes: { created_at: since.. })
                       .order(created_at: :desc).limit(10)
  recent_orders = Order.assigned_to_rep(emp.id)
                       .where(orders: { created_at: since.. })
                       .order(created_at: :desc).limit(10)
  completed_activities = emp.activities.completed.where(activities: { completion_datetime: since.. })
  open_activities = emp.activities.open_activities
  recent_calls = CallRecord.for_party(emp.id).where(call_records: { created_at: since.. })

  {
    employee: { id: emp.id, name: emp.full_name, job_title: emp.job_title },
    period: "Last #{period_days} days (since #{since.to_date.iso8601})",
    pipeline: rep_performance_pipeline(open_opps, won_recent, lost_recent),
    quotes: { count: recent_quotes.count, items: recent_quotes.map { |q| serialize_rep_quote(q) } },
    orders: { count: recent_orders.count, items: recent_orders.map { |o| serialize_rep_order(o) } },
    activities: {
      completed_count: completed_activities.count,
      open_count: open_activities.count,
      overdue_count: open_activities.overdue_activities.count
    },
    calls: { total_calls: recent_calls.count,
             total_duration_minutes: (recent_calls.sum(:duration_secs).to_f / 60).round(1) }
  }
end

.rep_performance_pipeline(open_opps, won_recent, lost_recent) ⇒ Object



194
195
196
197
198
199
200
201
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 194

def rep_performance_pipeline(open_opps, won_recent, lost_recent)
  {
    open_opportunities: open_opps.count,
    open_pipeline_value: open_opps.sum(:value).to_f.round(2),
    won_count: won_recent.count, won_value: won_recent.sum(:value).to_f.round(2),
    lost_count: lost_recent.count, lost_value: lost_recent.sum(:value).to_f.round(2)
  }
end

.rep_workload(rep_id: nil, rep_name: nil, role: nil, date: nil) ⇒ Object

─── rep_workload ───────────────────────────────────────────────



80
81
82
83
84
85
86
87
88
89
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 80

def rep_workload(rep_id: nil, rep_name: nil, role: nil, date: nil)
  target_date = date.present? ? Date.parse(date) : Date.current
  employees = rep_workload_employees(rep_id: rep_id, rep_name: rep_name, role: role)
  return employees if employees.is_a?(String) # error JSON

  results = employees.map { |emp| rep_workload_entry(emp, target_date) }
  Helpers.truncate_json({ date: target_date.iso8601, total_reps: results.size, reps: results }.to_json)
rescue StandardError => e
  { error: e.message }.to_json
end

.rep_workload_employees(rep_id:, rep_name:, role:) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 91

def rep_workload_employees(rep_id:, rep_name:, role:)
  employees = if rep_id.present?
                Employee.where(id: rep_id).to_a
              elsif rep_name.present?
                Employee.active_employees.where('parties.full_name ILIKE ?', "%#{rep_name}%").to_a
              elsif role.present?
                Helpers.apply_role_scope(Employee.active_employees, role).sorted.to_a
              else
                return { error: 'Provide rep_id, rep_name, or role to look up workload.' }.to_json
              end
  return employees if employees.any?

  { error: 'No employees found matching the criteria.', suggestion: 'Try find_employee first.' }.to_json
end

.rep_workload_entry(emp, target_date) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 106

def rep_workload_entry(emp, target_date)
  open_activities = emp.activities.open_activities.non_notes
  overdue = open_activities.where(Activity.arel_table[:target_datetime].lteq(Time.current))
  today_activities = rep_workload_today_activities(open_activities, target_date)
  max_per_day = emp.maximum_activities_per_day(target_date)
  current_load = emp.activity_load_on_day(target_date)

  {
    id: emp.id, name: emp.full_name,
    working_today: emp.working_on_day?(target_date),
    status_today: emp.work_status_on_day(target_date).to_s,
    capacity: emp.capacity_ratio(target_date),
    open_activity_count: open_activities.count, overdue_count: overdue.count,
    today_count: today_activities.count,
    max_activities_per_day: max_per_day, current_load: current_load,
    load_percentage: max_per_day.positive? ? ((current_load.to_f / max_per_day) * 100).round(1) : 0,
    can_take_more: emp.can_take_activities_on_day?(target_date),
    today_activities: rep_workload_today_detail(today_activities)
  }
end

.rep_workload_today_activities(open_activities, target_date) ⇒ Object



127
128
129
130
131
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 127

def rep_workload_today_activities(open_activities, target_date)
  table = Activity.arel_table
  open_activities.where(table[:target_datetime].gteq(target_date.beginning_of_day))
                 .where(table[:target_datetime].lteq(target_date.end_of_day))
end

.rep_workload_today_detail(today_activities) ⇒ Object



133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 133

def rep_workload_today_detail(today_activities)
  today_activities.includes(:activity_type, :party, :opportunity).limit(20).map do |act|
    {
      id: act.id, type: act.activity_type&.task_type || 'Task',
      priority: act.activity_type&.priority,
      party: act.party&.full_name, due: act.target_datetime&.strftime('%I:%M %p'),
      overdue: act.overdue?,
      opportunity: act.opportunity&.reference_number,
      opportunity_id: act.opportunity_id,
      notes_excerpt: act.notes&.truncate(100)
    }.compact
  end
end

.serialize_pipeline_opportunity(opp) ⇒ Object



64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 64

def serialize_pipeline_opportunity(opp)
  {
    id: opp.id, reference: opp.reference_number, name: opp.name, state: opp.state,
    value: opp.value&.to_f, customer: opp.customer&.full_name, contact: opp.contact&.full_name,
    primary_rep: opp.primary_sales_rep&.full_name,
    secondary_rep: opp.secondary_sales_rep&.full_name,
    local_rep: opp.local_sales_rep&.full_name,
    close_date: opp.close_date&.iso8601, created_at: opp.created_at&.iso8601,
    quote_count: opp.quotes.size, order_count: opp.orders.size,
    opportunity_type: opp.opportunity_type,
    crm_url: "#{CRM_URL}/opportunities/#{opp.id}"
  }.compact
end

.serialize_recent_call(call) ⇒ Object



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 270

def serialize_recent_call(call)
  base = {
    id: call.id,
    date: call.created_at&.strftime('%Y-%m-%d %I:%M %p'),
    direction: call.respond_to?(:direction) ? call.direction : nil,
    duration_seconds: call.duration_secs,
    duration_display: Helpers.format_duration(call.duration_secs),
    origin: call.origin_party&.full_name || call.origin_name,
    destination: call.destination_party&.full_name || call.destination_name
  }
  base[:outcome] = call.outcome if call.respond_to?(:outcome) && call.outcome.present?
  base[:key_topics] = call.key_topics if call.respond_to?(:key_topics) && call.key_topics.present?
  base[:action_items] = call.action_items if call.respond_to?(:action_items) && call.action_items.present?
  base[:summary] = call.summary&.truncate(300) if call.respond_to?(:summary)
  base.compact
end

.serialize_rep_order(order) ⇒ Hash{Symbol=>Object}

Note:

The bare id field is intentionally omitted. The model is
expected to cite the user-facing reference (e.g. "SO725148")
and link via crm_url. Exposing the primary key here regressed
twice in production daily-focus runs (Apr 15 conv 1352, May 7
conv 2447): with no total field present the model rendered the
id as either a dollar amount ("CO723898 ($1,354,181)" — id was
1354181) or a synthetic reference number ("SO1374291" — id was
1374291). See PR #734.

Compact LLM-facing payload for an order inside a get_rep_performance
response.

Parameters:

  • order (Order)

    the order being summarised

Returns:

  • (Hash{Symbol=>Object})

    keys: :reference [String],
    :state [String], :type [String, nil], :total [Float, nil],
    :created [String, nil] (ISO 8601), :crm_url [String]



238
239
240
241
242
243
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 238

def serialize_rep_order(order)
  { reference: order.reference_number, state: order.state,
    type: order.order_type, total: order.try(:total)&.to_f,
    created: order.created_at&.iso8601,
    crm_url: UrlHelper.instance.order_url(order, host: CRM_HOSTNAME) }
end

.serialize_rep_quote(quote) ⇒ Hash{Symbol=>Object}

Note:

The bare id field is intentionally omitted. The model is
expected to cite the user-facing reference (e.g. "SQ814062")
and link via crm_url. Exposing the primary key here regressed
in production: with no total field present the model grabbed
the id and rendered it as either a dollar amount or a synthetic
reference number. See PR #734.

Compact LLM-facing payload for a quote inside a get_rep_performance
response.

Parameters:

  • quote (Quote)

    the quote being summarised

Returns:

  • (Hash{Symbol=>Object})

    keys: :reference [String],
    :state [String], :type [String, nil], :total [Float, nil],
    :created [String, nil] (ISO 8601), :crm_url [String]



216
217
218
219
220
221
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 216

def serialize_rep_quote(quote)
  { reference: quote.reference_number, state: quote.state,
    type: quote.quote_type, total: quote.try(:total)&.to_f,
    created: quote.created_at&.iso8601,
    crm_url: UrlHelper.instance.quote_url(quote, host: CRM_HOSTNAME) }
end

.toolsObject



12
13
14
15
16
17
18
19
# File 'app/services/assistant/sales_management/pipeline_tools.rb', line 12

def tools
  [
    build_pipeline_summary_tool,
    build_rep_workload_tool,
    build_rep_performance_tool,
    build_recent_calls_tool
  ]
end