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
-
#call_outcome_badge_class(outcome) ⇒ Object
Get badge class for call outcome.
-
#call_record_ai_status_badges(call_record) ⇒ Object
Render AI status badges for call record index.
- #call_record_index_action(cr) ⇒ Object
-
#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) ⇒ Object
Get profile image URL for a speaker, returns nil if no image available.
-
#call_record_speaker_initials(call_record, speaker_label) ⇒ Object
Get initials for avatar fallback.
-
#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).
-
#call_record_speaker_party(call_record, speaker_label) ⇒ Object
Get the speaker's party object for avatar display.
- #call_records_link(party, options = {}) ⇒ Object
-
#call_sentiment_summary(call_record) ⇒ Object
Get aggregated sentiment for the entire call.
- #destination_link(cr) ⇒ Object
-
#find_utterance_sentiment(call_record, utterance) ⇒ Object
Find sentiment for an utterance by matching time range AssemblyAI returns sentiment_analysis_results separately from utterances.
-
#format_ms_timestamp(milliseconds) ⇒ Object
Format milliseconds to MM:SS timestamp for transcript display.
-
#linked_activity_for_call_record(call_record) ⇒ Object
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) ⇒ Object
When origin_party is a Customer, find the matching Contact by phone number.
- #origin_link(cr) ⇒ Object
- #safe_phone_number(number) ⇒ Object
-
#sentiment_badge(sentiment) ⇒ Object
Render sentiment badge with icon.
-
#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).
-
#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).
Instance Method Details
#call_outcome_badge_class(outcome) ⇒ Object
Get badge class for call outcome
294 295 296 297 298 299 300 301 302 |
# File 'app/helpers/call_records_helper.rb', line 294 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
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 |
# File 'app/helpers/call_records_helper.rb', line 305 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) ⇒ Object
92 93 94 95 96 97 98 |
# File 'app/helpers/call_records_helper.rb', line 92 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"
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 |
# File 'app/helpers/call_records_helper.rb', line 152 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
207 208 209 210 211 212 213 214 215 216 217 |
# File 'app/helpers/call_records_helper.rb', line 207 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
220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'app/helpers/call_records_helper.rb', line 220 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)
139 140 141 142 |
# File 'app/helpers/call_records_helper.rb', line 139 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
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'app/helpers/call_records_helper.rb', line 188 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 = {}) ⇒ Object
80 81 82 83 84 85 86 87 88 89 90 |
# File 'app/helpers/call_records_helper.rb', line 80 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) ⇒ Object
Get aggregated sentiment for the entire call
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'app/helpers/call_records_helper.rb', line 255 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) ⇒ Object
46 47 48 |
# File 'app/helpers/call_records_helper.rb', line 46 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
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'app/helpers/call_records_helper.rb', line 235 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) ⇒ Object
Format milliseconds to MM:SS timestamp for transcript display
101 102 103 104 105 106 107 108 109 |
# File 'app/helpers/call_records_helper.rb', line 101 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) ⇒ Object
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.
28 29 30 31 32 33 34 35 36 |
# File 'app/helpers/call_records_helper.rb', line 28 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)
181 182 183 184 185 |
# File 'app/helpers/call_records_helper.rb', line 181 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) ⇒ Object
When origin_party is a Customer, find the matching Contact by phone number.
67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'app/helpers/call_records_helper.rb', line 67 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) ⇒ Object
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'app/helpers/call_records_helper.rb', line 50 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-muted') ]) else link_to(cr.origin_name || party.full_name, polymorphic_path(party)) end end |
#safe_phone_number(number) ⇒ Object
38 39 40 41 42 43 44 |
# File 'app/helpers/call_records_helper.rb', line 38 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
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'app/helpers/call_records_helper.rb', line 274 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)
114 115 116 117 118 119 120 121 122 |
# File 'app/helpers/call_records_helper.rb', line 114 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)
127 128 129 130 131 132 133 134 135 |
# File 'app/helpers/call_records_helper.rb', line 127 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 |