Class: CallRecord
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- CallRecord
- Includes:
- Models::Auditable, Models::Embeddable, PgSearch::Model
- Defined in:
- app/models/call_record.rb
Overview
Phone call records from the Switchvox phone system.
Stores call metadata, transcriptions, and AI analysis results.
rubocop:disable Metrics/ClassLength
== Schema Information
Table name: call_records
Database name: primary
id :integer not null, primary key
action_items :jsonb
agent_performance_score :integer
agent_speaker_label :string
ai_summary :text
audio_channels :integer
call_direction :enum
call_outcome :enum
call_phases :jsonb
customer_satisfaction :string
destination_name :string(255)
destination_number :string(255)
duration_secs :integer default(0)
imported :boolean
key_topics :jsonb
lemur_analyzed_at :datetime
note :string(255)
notes :text
origin_name :string(255)
origin_number :string(255)
recording_source :string
reference1 :string(255)
reference2 :string(255)
structured_transcript_json :jsonb
summarized_at :datetime
transcribed_at :datetime
transcript :text
transcription_state :enum default("pending")
tsvector_search_tsearch :tsvector
tsvector_transcript_tsearch :tsvector
twilio_call_details :jsonb
twilio_call_sid :string
twilio_recording_sid :string
unread :boolean default(FALSE), not null
unrestricted :boolean default(FALSE), not null
created_at :datetime not null
assemblyai_transcript_id :string
destination_party_id :integer
origin_party_id :integer
switchvox_from_account_id :integer
switchvox_recorded_call_id :integer
switchvox_to_account_id :integer
Indexes
idx_call_records_id_where_dp_id (id) WHERE (destination_party_id IS NULL)
idx_call_records_id_where_op_id (id) WHERE (origin_party_id IS NULL)
idx_origin_id_dest_id_created_at (origin_party_id,destination_party_id,created_at)
index_call_records_on_assemblyai_transcript_id (assemblyai_transcript_id) UNIQUE
index_call_records_on_created_at (created_at)
index_call_records_on_destination_party_id (destination_party_id)
index_call_records_on_switchvox_recorded_call_id (switchvox_recorded_call_id) UNIQUE
index_call_records_on_transcribed_at (transcribed_at)
index_call_records_on_transcription_state (transcription_state)
index_call_records_on_tsvector_search_tsearch (tsvector_search_tsearch) USING gin
index_call_records_on_tsvector_transcript_tsearch (tsvector_transcript_tsearch) USING gin
index_call_records_on_twilio_recording_sid (twilio_recording_sid) UNIQUE
index_call_records_on_unread (unread) WHERE (unread = true)
Constant Summary collapse
- VOICEMAIL_EXTENSIONS =
Voicemail extensions.
%w[402].freeze
- MIN_TRANSCRIPTION_DURATION =
Minimum transcription duration.
30- MIN_TRANSCRIPTION_DURATION_VOICEMAIL =
Minimum transcription duration voicemail.
5- SATISFACTION_LEVELS =
Customer satisfaction levels (stored as string, set by LeMUR analysis)
%w[very_satisfied satisfied neutral frustrated angry].freeze
- SATISFACTION_DISPLAY =
Satisfaction display.
{ 'very_satisfied' => '😊 Very Satisfied', 'satisfied' => '🙂 Satisfied', 'neutral' => '😐 Neutral', 'frustrated' => '😤 Frustrated', 'angry' => '😠 Angry' }.freeze
- COMPANY_MAIN_NUMBERS =
Company main line numbers that should NOT be matched to parties
These are toll-free/main numbers that route to the IVR/queue, not individual employees %w[ +18008755285 +18664361444 +18475502400 ].freeze
Constants included from Models::Embeddable
Models::Embeddable::MAX_CONTENT_LENGTH
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#customer_satisfaction ⇒ Object
readonly
Validates customer satisfaction.
Has one collapse
-
#call_log_event ⇒ CallLogEvent?
The associated call log event.
-
#upload ⇒ Upload?
The associated upload.
Has many collapse
-
#activities ⇒ ActiveRecord::Relation<Activity>
The associated activities.
-
#call_record_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::CallRecordEmbedding>
Direct association to partitioned embeddings table (more efficient than polymorphic).
Methods included from Models::Embeddable
Belongs to collapse
-
#destination_party ⇒ Party?
The destination party this record belongs to.
-
#origin_party ⇒ Party?
The origin party this record belongs to.
Methods included from Models::Auditable
Class Method Summary collapse
-
.analyzed ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are analyzed.
-
.bpo ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are bpo.
-
.by_recording_source ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are by recording source.
-
.company_main_number?(number) ⇒ Boolean
Check if number is a company main line (should not be matched to a party).
-
.destination_filter ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are destination filter.
-
.distinct_key_topics(limit: 50) ⇒ Object
Get distinct key topics for filter dropdown.
-
.embeddable_content_types ⇒ Object
Define content types for embedding.
-
.find_party(number) ⇒ Object
Find party.
-
.for_destination_employees ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for destination employees.
-
.for_destination_employees_or_unlinked ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for destination employees or unlinked.
-
.for_party ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for party.
-
.for_unlinked_extensions ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for unlinked extensions.
-
.internal_call?(number) ⇒ Boolean
Whether the record internal call.
-
.mcp_searchable? ⇒ Boolean
Privacy: Call records are NOT exposed via MCP (sensitive customer data).
-
.parse_phone_number(number) ⇒ Object
Parse phone number.
-
.party_records ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are party records.
-
.ransackable_associations(_auth_object = nil) ⇒ Array<String>
Ransack-allowlisted associations.
-
.ransackable_attributes(_auth_object = nil) ⇒ Array<String>
Ransack-allowlisted attributes.
-
.ransackable_scopes(_auth_object = nil) ⇒ Object
Ransack configuration for filtering.
-
.read ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are read.
-
.recent ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are recent.
-
.transcription_eligible ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are transcription eligible.
-
.transcription_too_short ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are transcription too short.
-
.unread ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are unread.
-
.voicemails ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are voicemails.
-
.with_action_items ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are with action items.
-
.with_key_topic ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are with key topic.
-
.with_transcript ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are with transcript.
Instance Method Summary collapse
-
#action_items? ⇒ Boolean
Check if there are action items.
-
#action_items_task_summary ⇒ Object
Single-line task text for prompts / embeddings.
-
#agent_improvements ⇒ Object
Get agent's improvement areas from analysis.
-
#agent_strengths ⇒ Object
Get agent's strengths from analysis.
-
#bpo? ⇒ Boolean
Cheap per-record check for badges: the import flag, or (legacy unflagged records) the terminating leg to the BPO cascade number.
-
#call_log ⇒ Object
private.
-
#content_for_embedding(_content_type = :primary) ⇒ Object
Generate content for embedding - includes call metadata, transcript, and summary.
-
#duration_friendly ⇒ Object
Duration friendly.
-
#enqueue_crm_navbar_voicemail_refresh ⇒ Object
Only enqueue when the change can affect the navbar badge — i.e.
-
#high_priority_action_items ⇒ Object
Get high priority action items.
-
#lemur_analyzed? ⇒ Boolean
Check if LeMUR analysis has been run.
-
#mark_as_read! ⇒ Object
Mark as read, raising on failure.
-
#mark_as_unread! ⇒ Object
Mark as unread, raising on failure.
-
#match_destination_party(force: false) ⇒ Object
Method to match destination party based on switchvox acount id if specified or lookup on destination number.
-
#match_origin_party(force: false) ⇒ Object
Method to match origin party based on switchvox acount id if specified or lookup on origin number.
-
#match_parties(force: false) ⇒ Object
Match parties.
-
#performance_score_display ⇒ Object
Get performance score display with color.
-
#satisfaction_badge_class ⇒ Object
Get satisfaction CSS class for styling.
-
#satisfaction_display ⇒ Object
Get formatted satisfaction with emoji.
-
#to_s ⇒ String
String representation of the record.
-
#transcription_min_duration ⇒ Object
Transcription min duration.
-
#trim_numbers ⇒ Object
Trim numbers.
-
#voicemail? ⇒ Boolean
Whether the record voicemail.
-
#voicemail_processing_enabled? ⇒ Boolean
Whether automatic voicemail follow-up should run for this call — the matched-caller follow-up Activity and the transcript-driven contact enrichment in CallRecordProcessing::TranscriptionService.
Methods included from Models::Embeddable
#embeddable_locales, #embedding_content_hash, #embedding_current?, #embedding_eligible?, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search, with_shape_lock
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
Methods inherited from ApplicationRecord
ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#customer_satisfaction ⇒ Object (readonly)
Validates customer satisfaction.
Validations:
- Inclusion ({ in: SATISFACTION_LEVELS, allow_nil: true })
114 |
# File 'app/models/call_record.rb', line 114 validates :customer_satisfaction, inclusion: { in: SATISFACTION_LEVELS, allow_nil: true } |
Class Method Details
.analyzed ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are analyzed. Active Record Scope
311 312 313 |
# File 'app/models/call_record.rb', line 311 scope :analyzed, ->(value = true) { value.to_s == 'true' ? where.not(lemur_analyzed_at: nil) : where(lemur_analyzed_at: nil) } |
.bpo ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are bpo. Active Record Scope
302 |
# File 'app/models/call_record.rb', line 302 scope :bpo, -> { where(recording_source: 'twilio').where("twilio_call_details @> ?", { bpo: true }.to_json) } |
.by_recording_source ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are by recording source. Active Record Scope
287 288 289 290 291 292 293 294 295 296 297 |
# File 'app/models/call_record.rb', line 287 scope :by_recording_source, ->(source) { if source == 'switchvox' where(recording_source: [nil, 'switchvox']) elsif source == 'bpo' bpo elsif source.present? where(recording_source: source) else all end } |
.company_main_number?(number) ⇒ Boolean
Check if number is a company main line (should not be matched to a party)
351 352 353 354 355 |
# File 'app/models/call_record.rb', line 351 def self.company_main_number?(number) return false if number.blank? COMPANY_MAIN_NUMBERS.include?(number) end |
.destination_filter ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are destination filter. Active Record Scope
239 240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'app/models/call_record.rb', line 239 scope :destination_filter, ->(*args) { ids = args.flatten.map(&:to_s).compact_blank include_null = ids.delete('null').present? integer_ids = ids.map(&:to_i).select(&:positive?) if include_null && integer_ids.any? where(destination_party_id: integer_ids).or(where(destination_party_id: nil)) elsif include_null where(destination_party_id: nil) elsif integer_ids.any? where(destination_party_id: integer_ids) end } |
.distinct_key_topics(limit: 50) ⇒ Object
Get distinct key topics for filter dropdown
326 327 328 329 330 331 332 333 |
# File 'app/models/call_record.rb', line 326 def self.distinct_key_topics(limit: 50) where.not(key_topics: nil) .pluck(Arel.sql('jsonb_array_elements_text(key_topics)')) .tally .sort_by { |_, count| -count } .first(limit) .map(&:first) end |
.embeddable_content_types ⇒ Object
Define content types for embedding
609 610 611 |
# File 'app/models/call_record.rb', line 609 def self. [:primary] end |
.find_party(number) ⇒ Object
Find party.
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 |
# File 'app/models/call_record.rb', line 359 def self.find_party(number) return unless number return if company_main_number?(number) # Don't match main company lines parties = Party .joins(:contact_points) .active .select('parties.id,parties.full_name') .order('parties.updated_at DESC') .limit(1) parties = if internal_call?(number) parties.where(type: 'Employee') .where('(contact_points.detail = :number or contact_points.extension = :number)', number: number) else parties.where(type: %w[Customer Contact]) .where(ContactPoint[:detail].eq(number)) end party_type, party_id, party_full_name = parties.pick(:type, :id, :full_name) return unless party_id { party_type: party_type, party_id: party_id, party_full_name: party_full_name } end |
.for_destination_employees ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for destination employees. Active Record Scope
180 |
# File 'app/models/call_record.rb', line 180 scope :for_destination_employees, ->(employee_ids) { where(destination_party_id: employee_ids) } |
.for_destination_employees_or_unlinked ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for destination employees or unlinked. Active Record Scope
194 195 196 |
# File 'app/models/call_record.rb', line 194 scope :for_destination_employees_or_unlinked, ->(employee_ids) { where(destination_party_id: employee_ids).or(where(destination_party_id: nil)) } |
.for_party ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for party. Active Record Scope
184 185 186 187 188 189 190 191 192 |
# File 'app/models/call_record.rb', line 184 scope :for_party, ->(party_or_ids) { # Guard against nil/blank input — without this, Rails turns the OR # branches into `IS NULL` predicates, matching every unlinked call # record (calls where the origin or destination party is unknown). ids = Array.wrap(party_or_ids).compact.uniq next none if ids.empty? where.any_of({ origin_party_id: ids }, { destination_party_id: ids }) } |
.for_unlinked_extensions ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are for unlinked extensions. Active Record Scope
193 |
# File 'app/models/call_record.rb', line 193 scope :for_unlinked_extensions, -> { where(destination_party_id: nil) } |
.internal_call?(number) ⇒ Boolean
Returns whether the record internal call.
345 346 347 |
# File 'app/models/call_record.rb', line 345 def self.internal_call?(number) number && (number.length == 3 || number.match(/^\+184755024/).present?) end |
.mcp_searchable? ⇒ Boolean
Privacy: Call records are NOT exposed via MCP (sensitive customer data)
614 615 616 |
# File 'app/models/call_record.rb', line 614 def self.mcp_searchable? false end |
.parse_phone_number(number) ⇒ Object
Parse phone number.
442 443 444 445 446 447 |
# File 'app/models/call_record.rb', line 442 def self.parse_phone_number(number) return if number.blank? n = number.match(/\d+/).to_s.match(/^9?1?(\d{10})$/).try(:[], 1) || number PhoneNumber.parse_and_format(n) end |
.party_records ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are party records. Active Record Scope
223 224 225 |
# File 'app/models/call_record.rb', line 223 scope :party_records, ->(party_id) { for_party([party_id] + Contact.where(customer_id: party_id).ids) } |
.ransackable_associations(_auth_object = nil) ⇒ Array<String>
Returns Ransack-allowlisted associations.
273 274 275 |
# File 'app/models/call_record.rb', line 273 def self.ransackable_associations(_auth_object = nil) %w[origin_party destination_party upload] end |
.ransackable_attributes(_auth_object = nil) ⇒ Array<String>
Returns Ransack-allowlisted attributes.
261 262 263 264 265 266 267 268 269 |
# File 'app/models/call_record.rb', line 261 def self.ransackable_attributes(_auth_object = nil) %w[ id created_at origin_party_id destination_party_id origin_number destination_number origin_name destination_name duration_secs unrestricted unread transcription_state call_direction call_outcome customer_satisfaction agent_performance_score transcribed_at summarized_at lemur_analyzed_at recording_source audio_channels ] end |
.ransackable_scopes(_auth_object = nil) ⇒ Object
Ransack configuration for filtering
255 256 257 |
# File 'app/models/call_record.rb', line 255 def self.ransackable_scopes(_auth_object = nil) %i[search transcript_search with_key_topic analyzed with_action_items by_recording_source destination_filter] end |
.read ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are read. Active Record Scope
198 |
# File 'app/models/call_record.rb', line 198 scope :read, -> { where(unread: false) } |
.recent ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are recent. Active Record Scope
178 |
# File 'app/models/call_record.rb', line 178 scope :recent, ->(days = 90) { where(call_records: { created_at: days.days.ago.. }) } |
.transcription_eligible ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are transcription eligible. Active Record Scope
142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'app/models/call_record.rb', line 142 scope :transcription_eligible, -> { joins(:upload) .where(transcription_state: %i[pending error]) .where( 'call_records.duration_secs >= :standard OR ' \ '((call_records.call_outcome = :vm_outcome OR call_records.destination_number IN (:vm_exts)) ' \ 'AND call_records.duration_secs >= :vm_min)', standard: MIN_TRANSCRIPTION_DURATION, vm_outcome: call_outcomes[:voicemail], vm_exts: VOICEMAIL_EXTENSIONS, vm_min: MIN_TRANSCRIPTION_DURATION_VOICEMAIL ) } |
.transcription_too_short ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are transcription too short. Active Record Scope
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'app/models/call_record.rb', line 162 scope :transcription_too_short, -> { # COALESCE(..., FALSE) keeps a NULL destination_number/call_outcome from # poisoning the NOT (three-valued logic) and dropping the row — the inverse of # the OR-shaped eligible test, which tolerates that NULL. joins(:upload) .where(transcription_state: :pending) .where( 'call_records.duration_secs < :standard AND ' \ 'NOT COALESCE((call_records.call_outcome = :vm_outcome OR call_records.destination_number IN (:vm_exts)) ' \ 'AND call_records.duration_secs >= :vm_min, FALSE)', standard: MIN_TRANSCRIPTION_DURATION, vm_outcome: call_outcomes[:voicemail], vm_exts: VOICEMAIL_EXTENSIONS, vm_min: MIN_TRANSCRIPTION_DURATION_VOICEMAIL ) } |
.unread ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are unread. Active Record Scope
197 |
# File 'app/models/call_record.rb', line 197 scope :unread, -> { where(unread: true) } |
.voicemails ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are voicemails. Active Record Scope
179 |
# File 'app/models/call_record.rb', line 179 scope :voicemails, -> { where(call_outcome: :voicemail) } |
.with_action_items ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are with action items. Active Record Scope
316 317 318 319 320 321 322 |
# File 'app/models/call_record.rb', line 316 scope :with_action_items, ->(value = true) { if value.to_s == 'true' where('action_items IS NOT NULL AND jsonb_array_length(action_items) > 0') else where('action_items IS NULL OR jsonb_array_length(action_items) = 0') end } |
.with_key_topic ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are with key topic. Active Record Scope
278 |
# File 'app/models/call_record.rb', line 278 scope :with_key_topic, ->(topic) { where('key_topics @> ?', [topic].to_json) } |
.with_transcript ⇒ ActiveRecord::Relation<CallRecord>
A relation of CallRecords that are with transcript. Active Record Scope
141 |
# File 'app/models/call_record.rb', line 141 scope :with_transcript, -> { where.not(transcript: nil).where.not(transcript: '') } |
Instance Method Details
#action_items? ⇒ Boolean
Check if there are action items
480 481 482 |
# File 'app/models/call_record.rb', line 480 def action_items? action_items.present? end |
#action_items_task_summary ⇒ Object
Single-line task text for prompts / embeddings. Handles JSON where task is a String,
an Array of strings, or other nested shapes (avoids calling String#truncate on an Array).
493 494 495 496 497 498 499 500 501 502 503 504 505 506 |
# File 'app/models/call_record.rb', line 493 def action_items_task_summary return +'' if action_items.blank? Array(action_items).flat_map do |item| next [] unless item.is_a?(Hash) task_val = if item.key?('task') item['task'] elsif item.key?(:task) item[:task] end extract_action_item_task_strings(task_val) end.compact_blank.join('; ') end |
#activities ⇒ ActiveRecord::Relation<Activity>
Returns the associated activities.
121 |
# File 'app/models/call_record.rb', line 121 has_many :activities, dependent: :nullify, inverse_of: :call_record |
#agent_improvements ⇒ Object
Get agent's improvement areas from analysis
514 515 516 |
# File 'app/models/call_record.rb', line 514 def agent_improvements structured_transcript_json&.dig('agent_performance', 'improvements') || [] end |
#agent_strengths ⇒ Object
Get agent's strengths from analysis
509 510 511 |
# File 'app/models/call_record.rb', line 509 def agent_strengths structured_transcript_json&.dig('agent_performance', 'strengths') || [] end |
#bpo? ⇒ Boolean
Cheap per-record check for badges: the import flag, or (legacy unflagged
records) the terminating leg to the BPO cascade number.
306 307 308 |
# File 'app/models/call_record.rb', line 306 def bpo? recording_source == 'twilio' && (twilio_bpo? || Phone::Bpo.bpo_number?(destination_number)) end |
#call_log ⇒ Object
private
540 541 542 |
# File 'app/models/call_record.rb', line 540 def call_log call_log_event.try(:call_log) end |
#call_log_event ⇒ CallLogEvent?
Returns the associated call log event.
119 |
# File 'app/models/call_record.rb', line 119 has_one :call_log_event, dependent: :nullify |
#call_record_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::CallRecordEmbedding>
Direct association to partitioned embeddings table (more efficient than polymorphic)
124 125 126 127 128 |
# File 'app/models/call_record.rb', line 124 has_many :call_record_embeddings, class_name: 'ContentEmbedding::CallRecordEmbedding', foreign_key: :embeddable_id, inverse_of: :embeddable, dependent: false |
#content_for_embedding(_content_type = :primary) ⇒ Object
Generate content for embedding - includes call metadata, transcript, and summary
620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 |
# File 'app/models/call_record.rb', line 620 def (_content_type = :primary) parts = [] # Call metadata parts << "Phone call from #{origin_name.presence || origin_number} to #{destination_name.presence || destination_number}" parts << "Duration: #{duration_friendly}" parts << "Date: #{created_at.strftime('%B %d, %Y at %I:%M %p')}" parts << "Direction: #{call_direction}" if call_direction.present? parts << "Outcome: #{call_outcome}" if call_outcome.present? && call_outcome != 'unknown' parts << "Customer satisfaction: #{customer_satisfaction}" if customer_satisfaction.present? # Customer context if available if origin_party.is_a?(Customer) parts << "Customer: #{origin_party.full_name}" elsif destination_party.is_a?(Customer) parts << "Customer: #{destination_party.full_name}" end # Key topics (from LeMUR analysis) parts << "Topics discussed: #{key_topics.join(', ')}" if key_topics.present? # AI Summary parts << "\nSummary: #{ai_summary}" if ai_summary.present? # Action items (for searchability) summary = action_items_task_summary parts << "\nAction items: #{summary}" if summary.present? # Transcript (main searchable content - truncate for embedding efficiency) if transcript.present? # Limit transcript to ~8000 chars to keep embedding focused on key content parts << "\nTranscript:\n#{transcript.truncate(8000, omission: '...[transcript continues]')}" end parts.compact.join("\n") end |
#destination_party ⇒ Party?
Returns the destination party this record belongs to.
Validations:
- Presence ({ if: -> { destination_party_id.present? } })
133 |
# File 'app/models/call_record.rb', line 133 belongs_to :destination_party, class_name: 'Party', inverse_of: :destination_call_records, optional: true |
#duration_friendly ⇒ Object
Duration friendly.
450 451 452 |
# File 'app/models/call_record.rb', line 450 def duration_friendly Heatwave::Duration.humanize(duration_secs || 0) end |
#enqueue_crm_navbar_voicemail_refresh ⇒ Object
Only enqueue when the change can affect the navbar badge — i.e. a
voicemail's unread state flipped, or a brand-new unread voicemail
arrived. Non-voicemail calls (sales, support, etc.) are noisy but not
badged.
413 414 415 416 417 418 419 420 421 422 423 |
# File 'app/models/call_record.rb', line 413 def return unless voicemail? if previously_new_record? return unless unread? elsif !saved_change_to_unread? return end CrmNavbarFanoutWorker.perform_async('voicemail') end |
#high_priority_action_items ⇒ Object
Get high priority action items
485 486 487 488 489 |
# File 'app/models/call_record.rb', line 485 def high_priority_action_items return [] if action_items.blank? action_items.select { |item| item['priority'] == 'high' } end |
#lemur_analyzed? ⇒ Boolean
Check if LeMUR analysis has been run
459 460 461 |
# File 'app/models/call_record.rb', line 459 def lemur_analyzed? lemur_analyzed_at.present? end |
#mark_as_read! ⇒ Object
Mark as read, raising on failure.
426 427 428 |
# File 'app/models/call_record.rb', line 426 def mark_as_read! update_column(:unread, false) if unread? end |
#mark_as_unread! ⇒ Object
Mark as unread, raising on failure.
431 432 433 |
# File 'app/models/call_record.rb', line 431 def mark_as_unread! update_column(:unread, true) unless unread? end |
#match_destination_party(force: false) ⇒ Object
Method to match destination party based on switchvox acount id if specified or lookup on destination number
591 592 593 594 595 596 597 598 599 600 601 602 603 |
# File 'app/models/call_record.rb', line 591 def match_destination_party(force: false) return :already_matched if destination_party_id && !force # already matched if switchvox_to_account_id.present? && (eps = EmployeePhoneStatus.find_by(switchvox_account_id: switchvox_to_account_id)) self.destination_name = eps.employee.full_name self.destination_party = eps.employee elsif destination_number && (match = self.class.find_party(destination_number)) self.destination_name = match[:party_full_name] self.destination_party_id = match[:party_id] end logger&.info "Looking for destination party for call record #{id} for #{destination_number}, found #{destination_party_id || 'None'} #{destination_party.try(:full_name)}" destination_party_id end |
#match_origin_party(force: false) ⇒ Object
Method to match origin party based on switchvox acount id if specified or lookup on origin number
575 576 577 578 579 580 581 582 583 584 585 586 587 |
# File 'app/models/call_record.rb', line 575 def match_origin_party(force: false) return :already_matched if origin_party_id && !force # already matched if switchvox_from_account_id.present? && (eps = EmployeePhoneStatus.find_by(switchvox_account_id: switchvox_from_account_id)) self.origin_name = eps.employee.full_name if origin_name.blank? self.origin_party = eps.employee elsif origin_number && (match = self.class.find_party(origin_number)) self.origin_name = match[:party_full_name] if origin_name.blank? self.origin_party_id = match[:party_id] end logger&.info "Looking for originating party for call record #{id} for #{origin_number}, found #{origin_party_id || 'None'} #{origin_party.try(:full_name)}" origin_party_id end |
#match_parties(force: false) ⇒ Object
Match parties.
568 569 570 571 |
# File 'app/models/call_record.rb', line 568 def match_parties(force: false) match_origin_party(force: force) match_destination_party(force: force) end |
#origin_party ⇒ Party?
Returns the origin party this record belongs to.
Validations:
- Presence ({ if: -> { origin_party_id.present? } })
131 |
# File 'app/models/call_record.rb', line 131 belongs_to :origin_party, class_name: 'Party', inverse_of: :origin_call_records, optional: true |
#performance_score_display ⇒ Object
Get performance score display with color
519 520 521 522 523 524 525 526 527 528 529 530 531 532 |
# File 'app/models/call_record.rb', line 519 def performance_score_display return nil if agent_performance_score.blank? score = agent_performance_score if score >= 80 { score: score, class: 'text-success', label: 'Excellent' } elsif score >= 60 { score: score, class: 'text-primary', label: 'Good' } elsif score >= 40 { score: score, class: 'text-warning', label: 'Needs Improvement' } else { score: score, class: 'text-danger', label: 'Poor' } end end |
#satisfaction_badge_class ⇒ Object
Get satisfaction CSS class for styling
469 470 471 472 473 474 475 476 477 |
# File 'app/models/call_record.rb', line 469 def satisfaction_badge_class case customer_satisfaction when 'very_satisfied', 'satisfied' then 'bg-success' when 'neutral' then 'bg-secondary' when 'frustrated', 'angry' then 'bg-danger' else 'bg-light text-dark' end end |
#satisfaction_display ⇒ Object
Get formatted satisfaction with emoji
464 465 466 |
# File 'app/models/call_record.rb', line 464 def satisfaction_display SATISFACTION_DISPLAY[customer_satisfaction] end |
#to_s ⇒ String
Returns string representation of the record.
535 536 537 |
# File 'app/models/call_record.rb', line 535 def to_s "CallRecord: #{id}, From #{origin_number} to #{destination_number} at #{created_at}" end |
#transcription_min_duration ⇒ Object
Transcription min duration.
436 437 438 |
# File 'app/models/call_record.rb', line 436 def transcription_min_duration voicemail? ? MIN_TRANSCRIPTION_DURATION_VOICEMAIL : MIN_TRANSCRIPTION_DURATION end |
#trim_numbers ⇒ Object
Trim numbers.
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 |
# File 'app/models/call_record.rb', line 545 def trim_numbers # North American Match 10 digits, strip 1 strip 9 if (n = self.class.parse_phone_number(origin_number)) self.origin_number = n end if (n = self.class.parse_phone_number(destination_number)) self.destination_number = n end # Drop blank/zero Switchvox account ids. Each side is checked against ITSELF: # the `to` line used to test `from` (typo), which nulled the agent's # to_account_id on every inbound call (external caller => blank from_account_id) # — ~29% of recordings — breaking account-id-based matching and agent # attribution for those calls. self.switchvox_from_account_id = nil if switchvox_from_account_id.to_i.zero? self.switchvox_to_account_id = nil if switchvox_to_account_id.to_i.zero? true end |
#upload ⇒ Upload?
Returns the associated upload.
117 |
# File 'app/models/call_record.rb', line 117 has_one :upload, as: :resource, dependent: :destroy |
#voicemail? ⇒ Boolean
Returns whether the record voicemail.
388 389 390 |
# File 'app/models/call_record.rb', line 388 def voicemail? call_outcome_voicemail? || destination_number.to_s.in?(VOICEMAIL_EXTENSIONS) end |
#voicemail_processing_enabled? ⇒ Boolean
Whether automatic voicemail follow-up should run for this call — the
matched-caller follow-up Activity and the transcript-driven contact
enrichment in CallRecordProcessing::TranscriptionService. Honors the
destination employee's +process_voicemails+ opt-out (default on); defaults
to enabled when the destination is not a preference-bearing employee.
The manual "Create Customer" button is intentionally not gated by this —
an employee acting on a voicemail by hand always logs the activity.
402 403 404 405 406 407 |
# File 'app/models/call_record.rb', line 402 def voicemail_processing_enabled? employee = destination_party return true unless employee.is_a?(Employee) employee.process_voicemails? != false end |