Module: CallRecordsHelper

Defined in:
app/helpers/call_records_helper.rb

Overview

== Schema Information

Table name: call_records

id :integer not null, primary key
created_at :datetime not null
origin_party_id :integer
origin_name :string(255)
origin_number :string(255)
destination_party_id :integer
destination_name :string(255)
destination_number :string(255)
duration_secs :integer default(0)
imported :boolean
reference1 :string(255)
reference2 :string(255)
note :string(255)
tsvector_search_tsearch :tsvector
switchvox_from_account_id :integer
switchvox_to_account_id :integer
switchvox_recorded_call_id :integer
View helpers for the CRM call records UI: origin/destination party links,
transcript speaker labels, avatars and initials, AssemblyAI sentiment badges,
and AI status badges.

See Also:

Instance Method Summary collapse

Instance Method Details

#call_outcome_badge_class(outcome) ⇒ String

Get badge class for call outcome

Parameters:

  • outcome (String, Symbol, nil)

    the call outcome (sale, support, inquiry, voicemail)

Returns:

  • (String)

    Bootstrap background class for the outcome badge



379
380
381
382
383
384
385
386
387
# File 'app/helpers/call_records_helper.rb', line 379

def call_outcome_badge_class(outcome)
  case outcome.to_s
  when 'sale' then 'bg-success'
  when 'support' then 'bg-info'
  when 'inquiry' then 'bg-primary'
  when 'voicemail' then 'bg-secondary'
  else 'bg-light text-dark'
  end
end

#call_record_ai_status_badges(call_record) ⇒ String

Render AI status badges for call record index

Parameters:

Returns:

  • (String)

    HTML badges for transcription, AI summary, action items, and embeddings



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
418
# File 'app/helpers/call_records_helper.rb', line 393

def call_record_ai_status_badges(call_record)
  badges = []

  # Transcription status
  if call_record.transcription_state_completed?
    badges << tag.span(fa_icon('file-lines'), class: 'badge bg-success', title: 'Transcribed')
  elsif call_record.transcription_state_processing?
    badges << tag.span(fa_icon('spinner', class: 'fa-spin'), class: 'badge bg-warning', title: 'Processing')
  elsif call_record.transcription_state_error?
    badges << tag.span(fa_icon('triangle-exclamation'), class: 'badge bg-danger', title: 'Error')
  end

  # AI Summary status
  badges << tag.span(fa_icon('robot'), class: 'badge bg-success', title: 'AI Summary Available') if call_record.ai_summary.present?

  # Action items indicator
  if call_record.action_items?
    count = call_record.action_items.size
    badges << tag.span(fa_icon('list-check', text: count.to_s), class: 'badge bg-warning text-dark', title: "#{count} Action Item(s)")
  end

  # Embedding status - uses preloaded partitioned association (any? checks loaded collection)
  badges << tag.span(fa_icon('brain'), class: 'badge bg-info', title: 'Embedded for Search') if call_record.call_record_embeddings.any?

  safe_join(badges, ' ')
end

#call_record_index_action(cr) ⇒ String

Action button for a call record row on the index: an "Open" link when the
user can read the record, an "Access Restricted" badge otherwise.

Parameters:

Returns:

  • (String)

    HTML for the action button



128
129
130
131
132
133
134
# File 'app/helpers/call_records_helper.rb', line 128

def call_record_index_action(cr)
  if can?(:read, cr)
    link_to((:span, fa_icon('phone-volume', text: "Open", title: "Open call record #{cr.id}"), class: "btn btn-#{cr.unrestricted ? 'success' : 'warning'}"), call_record_path(cr.id, return_path: request.fullpath))
  else
    (:span, fa_icon('ban', text: 'Access Restricted'), class: 'btn btn-danger')
  end
end

#call_record_is_agent?(call_record, speaker_label) ⇒ Boolean

Determine if the speaker label corresponds to the agent
Uses the stored agent_speaker_label detected during transcription
Handles multiple modes:

  • Multichannel: speaker_label is "1A", "2B", etc. (channel + speaker)
    Channel 1 = Customer, Channel 2 = Company/Agent
  • Name-based: speaker_label is actual name (e.g., "Laurie Hoffman")
  • Role-based: speaker_label is "Agent" or "Customer"
  • Legacy: speaker_label is "A" or "B"

Parameters:

  • call_record (CallRecord)

    the call record

  • speaker_label (String, nil)

    the AssemblyAI speaker label

Returns:

  • (Boolean)

    true when the label corresponds to the agent side of the call



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/helpers/call_records_helper.rb', line 206

def call_record_is_agent?(call_record, speaker_label)
  return false if speaker_label.blank?

  # For multichannel labels (e.g., "1A", "2B", "1B", "2A" or just "1", "2")
  # First character is the channel: 1 = Customer, 2 = Company/Agent
  # Note: The [A-Z]? makes the letter optional to match both "1A" and "1"
  if speaker_label.to_s.match?(/^[12][A-Z]?$/i)
    # Channel 2 = Company side (IVR + Agents)
    # Channel 1 = Customer side
    return speaker_label.to_s.start_with?('2')
  end

  # For Slam-1 role-based labels, check for 'Agent' directly
  return speaker_label == 'Agent' if speaker_label.in?(%w[Agent Customer])

  # For legacy A/B labels
  return speaker_label == (call_record.agent_speaker_label.presence || 'A') if speaker_label.in?(%w[A B])

  # For name-based labels (speech_understanding returned actual names)
  # AssemblyAI may return just first name (e.g., "Lyn") while we store full name ("Lyn Davis-Asquith")
  # Use prefix matching against the expected agent name from call record metadata
  # This is more reliable than agent_speaker_label which may have been incorrectly set by heuristics
  speaker_lower = speaker_label.to_s.downcase.strip
  expected_agent_name = transcript_agent_name(call_record).to_s.downcase

  name_prefix_match?(speaker_lower, expected_agent_name)
end

#call_record_speaker_avatar_url(call_record, speaker_label, size: 40) ⇒ String?

Get profile image URL for a speaker, returns nil if no image available

Parameters:

  • call_record (CallRecord)

    the call record

  • speaker_label (String, nil)

    the AssemblyAI speaker label

  • size (Integer) (defaults to: 40)

    thumbnail width/height in pixels

Returns:

  • (String, nil)

    thumbnail URL, or nil when the speaker has no profile image



274
275
276
277
278
279
280
281
282
283
284
# File 'app/helpers/call_records_helper.rb', line 274

def call_record_speaker_avatar_url(call_record, speaker_label, size: 40)
  party = call_record_speaker_party(call_record, speaker_label)
  return nil unless party

  # Get profile image (consolidated storage for all party types)
  profile_image = party.profile_image if party.respond_to?(:profile_image)

  return nil unless profile_image

  profile_image.image_url(width: size, height: size, thumbnail: true)
end

#call_record_speaker_initials(call_record, speaker_label) ⇒ String

Get initials for avatar fallback

Parameters:

  • call_record (CallRecord)

    the call record

  • speaker_label (String, nil)

    the AssemblyAI speaker label

Returns:

  • (String)

    upcased initials ('U' when the speaker name is blank)



291
292
293
294
295
296
297
298
299
300
301
302
# File 'app/helpers/call_records_helper.rb', line 291

def call_record_speaker_initials(call_record, speaker_label)
  name = call_record_speaker_name(call_record, speaker_label)
  return 'U' if name.blank?

  # Extract initials from name (first letter of first and last word)
  words = name.split
  if words.size >= 2
    "#{words.first[0]}#{words.last[0]}".upcase
  else
    name[0..1].upcase
  end
end

#call_record_speaker_name(call_record, speaker_label) ⇒ String

Get the speaker's name based on AssemblyAI speaker label
Speaker A is typically the first to speak (agent on inbound, agent on outbound)

Parameters:

  • call_record (CallRecord)

    the call record

  • speaker_label (String, nil)

    the AssemblyAI speaker label (e.g. "A", "1A", "Agent", or a name)

Returns:

  • (String)

    the resolved speaker display name

See Also:



189
190
191
192
# File 'app/helpers/call_records_helper.rb', line 189

def call_record_speaker_name(call_record, speaker_label)
  is_agent = call_record_is_agent?(call_record, speaker_label)
  is_agent ? transcript_agent_name(call_record) : transcript_caller_name(call_record)
end

#call_record_speaker_party(call_record, speaker_label) ⇒ Party?

Get the speaker's party object for avatar display

Parameters:

  • call_record (CallRecord)

    the call record

  • speaker_label (String, nil)

    the AssemblyAI speaker label

Returns:

  • (Party, nil)

    the origin or destination party matching the speaker



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/helpers/call_records_helper.rb', line 250

def call_record_speaker_party(call_record, speaker_label)
  is_agent = call_record_is_agent?(call_record, speaker_label)

  if is_agent
    # Agent party
    if call_record.origin_party.is_a?(Employee)
      call_record.origin_party
    else
      call_record.destination_party
    end
  elsif call_record.origin_party.is_a?(Employee)
    # Caller party
    call_record.destination_party
  else
    call_record.origin_party
  end
end

Link to a party's call records, with a badge showing the record count

Parameters:

  • party (Party)

    the party whose call records to link to

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

    HTML attributes for the generated link/anchor

Options Hash (options):

  • :class (String)

    CSS classes for the element

Returns:

  • (String, nil)

    HTML for the link, or nil when the party has no call records



111
112
113
114
115
116
117
118
119
120
121
# File 'app/helpers/call_records_helper.rb', line 111

def call_records_link(party, options = {})
  call_count = CallRecord.party_records(party.id).size
  return unless call_count > 0

  display = fa_icon('tape', text: "Call Records <span class='badge bg-secondary'>#{call_count}</span>".html_safe)
  if call_count.positive? && can?(:read, CallRecord)
    link_to display, polymorphic_path([party, :call_records], return_path: request.path), options
  else
     :a, display, options
  end
end

#call_sentiment_summary(call_record) ⇒ Hash?

Get aggregated sentiment for the entire call

Parameters:

  • call_record (CallRecord)

    the call record with a structured transcript

Returns:

  • (Hash, nil)

    counts and percentages keyed :positive, :neutral, :negative,
    :total, :positive_pct, :negative_pct, :overall; nil when no results exist



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'app/helpers/call_records_helper.rb', line 334

def call_sentiment_summary(call_record)
  sentiment_results = call_record.structured_transcript_json&.dig('sentiment_analysis_results') || []
  return nil if sentiment_results.empty?

  counts = sentiment_results.group_by { |s| s['sentiment'] }.transform_values(&:size)
  total = sentiment_results.size

  {
    positive: counts['POSITIVE'] || 0,
    neutral: counts['NEUTRAL'] || 0,
    negative: counts['NEGATIVE'] || 0,
    total: total,
    positive_pct: total.positive? ? ((counts['POSITIVE'] || 0).to_f / total * 100).round : 0,
    negative_pct: total.positive? ? ((counts['NEGATIVE'] || 0).to_f / total * 100).round : 0,
    overall: counts.max_by { |_, v| v }&.first
  }
end

Link to the destination party of a call, or the plain destination name when
no party is attached.

Parameters:

Returns:

  • (String)

    HTML link to the destination party, or the destination name



62
63
64
# File 'app/helpers/call_records_helper.rb', line 62

def destination_link(cr)
  (cr.destination_party ? link_to(cr.destination_name || cr.destination_party.full_name, polymorphic_path(cr.destination_party)) : cr.destination_name)
end

#find_utterance_sentiment(call_record, utterance) ⇒ String?

Find sentiment for an utterance by matching time range
AssemblyAI returns sentiment_analysis_results separately from utterances

Parameters:

  • call_record (CallRecord)

    the call record with a structured transcript

  • utterance (Hash)

    AssemblyAI utterance with 'start'/'end' millisecond offsets

Returns:

  • (String, nil)

    the most common sentiment ("POSITIVE", "NEUTRAL", "NEGATIVE"), or nil



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'app/helpers/call_records_helper.rb', line 310

def find_utterance_sentiment(call_record, utterance)
  sentiment_results = call_record.structured_transcript_json&.dig('sentiment_analysis_results') || []
  return nil if sentiment_results.empty?

  utterance_start = utterance['start']
  utterance_end = utterance['end']

  # Find sentiment results that overlap with this utterance
  matching = sentiment_results.select do |s|
    s['start'] >= utterance_start && s['end'] <= utterance_end
  end

  return nil if matching.empty?

  # Return the most common sentiment for this utterance
  sentiments = matching.pluck('sentiment')
  sentiments.max_by { |s| sentiments.count(s) }
end

#format_ms_timestamp(milliseconds) ⇒ String

Format milliseconds to MM:SS timestamp for transcript display

Parameters:

  • milliseconds (Integer, nil)

    offset in milliseconds

Returns:

  • (String)

    zero-padded "MM:SS" timestamp ("00:00" when nil)



140
141
142
143
144
145
146
147
148
# File 'app/helpers/call_records_helper.rb', line 140

def format_ms_timestamp(milliseconds)
  return '00:00' if milliseconds.nil?

  total_seconds = milliseconds / 1000
  minutes = (total_seconds / 60).to_i
  seconds = (total_seconds % 60).to_i

  format('%02d:%02d', minutes, seconds)
end

#linked_activity_for_call_record(call_record) ⇒ Activity?

CRM voicemail follow-up activity for this call. Prefer +call_record_id+; fall back to
legacy polymorphic +resource+ or note HTML until all environments are migrated/backfilled.

Parameters:

  • call_record (CallRecord, nil)

    the call record to find a linked activity for

Returns:

  • (Activity, nil)

    the earliest linked activity, or nil when none exists



34
35
36
37
38
39
40
41
42
# File 'app/helpers/call_records_helper.rb', line 34

def linked_activity_for_call_record(call_record)
  return if call_record.blank?

  linked = call_record.activities.order(created_at: :asc).limit(1).first
  return linked if linked

  Activity.find_by(resource_type: 'CallRecord', resource_id: call_record.id) ||
    Activity.where('notes LIKE ?', "%/call_records/#{call_record.id}%").order(created_at: :asc).limit(1).first
end

#name_prefix_match?(speaker_lower, name_lower) ⇒ Boolean

Check if speaker matches name (exact or first-name prefix match)

Parameters:

  • speaker_lower (String, nil)

    downcased speaker label

  • name_lower (String, nil)

    downcased expected name

Returns:

  • (Boolean)

    true when the speaker equals or is a prefix of the name



239
240
241
242
243
# File 'app/helpers/call_records_helper.rb', line 239

def name_prefix_match?(speaker_lower, name_lower)
  return false if speaker_lower.blank? || name_lower.blank?

  speaker_lower == name_lower || name_lower.start_with?(speaker_lower)
end

#origin_contact_for(cr) ⇒ Contact?

When origin_party is a Customer, find the matching Contact by phone number.

Parameters:

Returns:

  • (Contact, nil)

    the matching contact, or nil when the origin is not a
    Customer or no contact has the origin phone number



93
94
95
96
97
98
99
100
101
102
103
104
# File 'app/helpers/call_records_helper.rb', line 93

def origin_contact_for(cr)
  party = cr.origin_party
  return unless party.is_a?(Customer) && cr.origin_number.present?

  normalized = PhoneNumber.parse_and_format(cr.origin_number)
  return if normalized.blank?

  Contact.joins(:contact_points)
         .where(customer_id: party.id)
         .where(contact_points: { category: ContactPoint::PHONE, detail: normalized })
         .first
end

Link to the origin party of a call. When a matching Contact exists on a
Customer origin, links the contact with a secondary link to the customer.

Parameters:

Returns:

  • (String)

    HTML link(s) to the origin contact/party, or the origin name

See Also:



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'app/helpers/call_records_helper.rb', line 72

def origin_link(cr)
  party = cr.origin_party
  return cr.origin_name unless party

  contact = origin_contact_for(cr)
  if contact
    safe_join([
                link_to(contact.full_name, polymorphic_path(contact)),
                (:small, safe_join([' ', fa_icon('building', class: 'ms-1 me-1'),
                                               link_to(party.full_name, polymorphic_path(party))]), class: 'text-body-secondary')
              ])
  else
    link_to(cr.origin_name || party.full_name, polymorphic_path(party))
  end
end

#safe_phone_number(number) ⇒ String

Formats a phone number for display, falling back to the raw value when
Rails' +number_to_phone+ cannot parse it.

Parameters:

  • number (String, nil)

    the raw phone number

Returns:

  • (String)

    the formatted number, the raw value on parse failure, or '' when blank



49
50
51
52
53
54
55
# File 'app/helpers/call_records_helper.rb', line 49

def safe_phone_number(number)
  return '' if number.blank?

  number_to_phone(number)
rescue ArgumentError
  number
end

#sentiment_badge(sentiment) ⇒ String?

Render sentiment badge with icon

Parameters:

  • sentiment (String, nil)

    sentiment value ("POSITIVE", "NEGATIVE", "NEUTRAL")

Returns:

  • (String, nil)

    HTML badge, or nil when blank or unrecognized



356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'app/helpers/call_records_helper.rb', line 356

def sentiment_badge(sentiment)
  return nil if sentiment.blank?

  case sentiment.to_s.upcase
  when 'POSITIVE'
    tag.span(class: 'badge bg-success bg-opacity-75', style: 'font-size: 0.65rem;', title: 'Positive sentiment') do
      fa_icon('face-smile', style: :regular)
    end
  when 'NEGATIVE'
    tag.span(class: 'badge bg-danger bg-opacity-75', style: 'font-size: 0.65rem;', title: 'Negative sentiment') do
      fa_icon('face-frown', style: :regular)
    end
  when 'NEUTRAL'
    tag.span(class: 'badge bg-secondary bg-opacity-50', style: 'font-size: 0.65rem;', title: 'Neutral sentiment') do
      fa_icon('face-meh', style: :regular)
    end
  end
end

#transcript_agent_name(call_record) ⇒ String

Get the agent's display name for transcript
For inbound calls: agent is the destination (employee answering)
For outbound calls: agent is the origin (employee calling)

Parameters:

Returns:

  • (String)

    the agent's display name, or 'Agent' as fallback



156
157
158
159
160
161
162
163
164
# File 'app/helpers/call_records_helper.rb', line 156

def transcript_agent_name(call_record)
  if call_record.origin_party.is_a?(Employee)
    # Outbound: agent is origin
    call_record.origin_name.presence || 'Agent'
  else
    # Inbound: agent is destination
    call_record.destination_name.presence || 'Agent'
  end
end

#transcript_caller_name(call_record) ⇒ String

Get the caller's display name for transcript
For inbound calls: caller is the origin (external party calling in)
For outbound calls: caller is the destination (party being called)

Parameters:

Returns:

  • (String)

    the caller's display name, or 'Caller' as fallback



172
173
174
175
176
177
178
179
180
# File 'app/helpers/call_records_helper.rb', line 172

def transcript_caller_name(call_record)
  if call_record.origin_party.is_a?(Employee)
    # Outbound: caller is destination
    call_record.destination_name.presence || 'Caller'
  else
    # Inbound: caller is origin
    call_record.origin_name.presence || 'Caller'
  end
end