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.
-
#name_prefix_match?(speaker_lower, name_lower) ⇒ Boolean
Check if speaker matches name (exact or first-name prefix match).
- #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
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..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( 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"
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 |
#call_records_link(party, options = {}) ⇒ Object
41 42 43 44 45 46 47 48 49 50 |
# File 'app/helpers/call_records_helper.rb', line 41 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? and 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
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 |
#destination_link(cr) ⇒ Object
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 (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)
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 |
#origin_link(cr) ⇒ Object
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 |