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

Instance Method Summary collapse

Instance Method Details

#call_outcome_badge_class(outcome) ⇒ Object

Get badge class for call outcome



256
257
258
259
260
261
262
263
264
# File 'app/helpers/call_records_helper.rb', line 256

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) ⇒ Object

Render AI status badges for call record index



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'app/helpers/call_records_helper.rb', line 267

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
  if call_record.ai_summary.present?
    badges << tag.span(fa_icon('robot'), class: 'badge bg-success', title: 'AI Summary Available')
  end

  # 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)
  if call_record.call_record_embeddings.any?
    badges << tag.span(fa_icon('brain'), class: 'badge bg-info', title: 'Embedded for Search')
  end

  safe_join(badges, ' ')
end

#call_record_index_action(cr) ⇒ Object



52
53
54
55
56
57
58
# File 'app/helpers/call_records_helper.rb', line 52

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"

Returns:

  • (Boolean)


112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'app/helpers/call_records_helper.rb', line 112

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) ⇒ Object

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



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

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) ⇒ Object

Get initials for avatar fallback



182
183
184
185
186
187
188
189
190
191
192
193
# File 'app/helpers/call_records_helper.rb', line 182

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) ⇒ Object

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



99
100
101
102
# File 'app/helpers/call_records_helper.rb', line 99

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) ⇒ Object

Get the speaker's party object for avatar display



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'app/helpers/call_records_helper.rb', line 148

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
  else
    # Caller party
    if call_record.origin_party.is_a?(Employee)
      call_record.destination_party
    else
      call_record.origin_party
    end
  end
end


41
42
43
44
45
46
47
48
49
50
# File 'app/helpers/call_records_helper.rb', line 41

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? and 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) ⇒ Object

Get aggregated sentiment for the entire call



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'app/helpers/call_records_helper.rb', line 217

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


33
34
35
# File 'app/helpers/call_records_helper.rb', line 33

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) ⇒ Object

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



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'app/helpers/call_records_helper.rb', line 197

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.map { |s| s['sentiment'] }
  sentiments.max_by { |s| sentiments.count(s) }
end

#format_ms_timestamp(milliseconds) ⇒ Object

Format milliseconds to MM:SS timestamp for transcript display



61
62
63
64
65
66
67
68
69
# File 'app/helpers/call_records_helper.rb', line 61

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

#name_prefix_match?(speaker_lower, name_lower) ⇒ Boolean

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

Returns:

  • (Boolean)


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

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


37
38
39
# File 'app/helpers/call_records_helper.rb', line 37

def origin_link(cr)
  (cr.origin_party ? link_to(cr.origin_name || cr.origin_party.full_name , polymorphic_path(cr.origin_party)) : cr.origin_name)
end

#safe_phone_number(number) ⇒ Object



25
26
27
28
29
30
31
# File 'app/helpers/call_records_helper.rb', line 25

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

  number_to_phone(number)
rescue ArgumentError
  number
end

#sentiment_badge(sentiment) ⇒ Object

Render sentiment badge with icon



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'app/helpers/call_records_helper.rb', line 236

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) ⇒ Object

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)



74
75
76
77
78
79
80
81
82
# File 'app/helpers/call_records_helper.rb', line 74

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) ⇒ Object

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)



87
88
89
90
91
92
93
94
95
# File 'app/helpers/call_records_helper.rb', line 87

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