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.
Instance Method Summary collapse
-
#call_outcome_badge_class(outcome) ⇒ String
Get badge class for call outcome.
-
#call_record_ai_status_badges(call_record) ⇒ String
Render AI status badges for call record index.
-
#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.
-
#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.
-
#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.
-
#call_record_speaker_initials(call_record, speaker_label) ⇒ String
Get initials for avatar fallback.
-
#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).
-
#call_record_speaker_party(call_record, speaker_label) ⇒ Party?
Get the speaker's party object for avatar display.
-
#call_records_link(party, options = {}) ⇒ String?
Link to a party's call records, with a badge showing the record count.
-
#call_sentiment_summary(call_record) ⇒ Hash?
Get aggregated sentiment for the entire call.
-
#destination_link(cr) ⇒ String
Link to the destination party of a call, or the plain destination name when no party is attached.
-
#find_utterance_sentiment(call_record, utterance) ⇒ String?
Find sentiment for an utterance by matching time range AssemblyAI returns sentiment_analysis_results separately from utterances.
-
#format_ms_timestamp(milliseconds) ⇒ String
Format milliseconds to MM:SS timestamp for transcript display.
-
#linked_activity_for_call_record(call_record) ⇒ Activity?
CRM voicemail follow-up activity for this call.
-
#name_prefix_match?(speaker_lower, name_lower) ⇒ Boolean
Check if speaker matches name (exact or first-name prefix match).
-
#origin_contact_for(cr) ⇒ Contact?
When origin_party is a Customer, find the matching Contact by phone number.
-
#origin_link(cr) ⇒ String
Link to the origin party of a call.
-
#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.
-
#sentiment_badge(sentiment) ⇒ String?
Render sentiment badge with icon.
-
#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).
-
#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).
Instance Method Details
#call_outcome_badge_class(outcome) ⇒ String
Get badge class for call outcome
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
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..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.
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(content_tag(: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 content_tag(: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"
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
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
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)
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
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 |
#call_records_link(party, options = {}) ⇒ String?
Link to a party's call records, with a badge showing the record count
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, = {}) 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), else content_tag :a, display, end end |
#call_sentiment_summary(call_record) ⇒ Hash?
Get aggregated sentiment for the entire call
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 |
#destination_link(cr) ⇒ String
Link to the destination party of a call, or the plain destination name when
no party is attached.
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
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
140 141 142 143 144 145 146 147 148 |
# File 'app/helpers/call_records_helper.rb', line 140 def (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.
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)
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.
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 |
#origin_link(cr) ⇒ String
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.
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)), content_tag(: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.
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
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)
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)
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 |