Class: CallRecordProcessing::TranscriptionService

Inherits:
Object
  • Object
show all
Defined in:
app/services/call_record_processing/transcription_service.rb

Overview

Service for transcribing call recordings using AssemblyAI with speaker diarization.
Audio files (.wav, .mp3, .aac, .oga) are transcribed and formatted with speaker labels.

Examples:

Basic usage

service = CallRecordProcessing::TranscriptionService.new(call_record)
result = service.transcribe

Force retranscription

service = CallRecordProcessing::TranscriptionService.new(call_record, force: true)
result = service.transcribe

Constant Summary collapse

MIN_DURATION_SECONDS =

Minimum duration seconds.

30
MIN_DURATION_SECONDS_VOICEMAIL =

Minimum duration seconds voicemail.

5
VOICEMAIL_PLACEHOLDER_PREFIX =

Voicemail placeholder prefix.

'Voicemail from'
CALLER_IDENTITY_PROMPT =

Caller identity prompt.

<<~PROMPT

  For voicemail caller identity, extract only details the caller explicitly says or spells out.
  Reassemble spelled email addresses when the transcript provides enough letters.
  Return null for identity fields that are not mentioned.

  Add this field to the top-level JSON response:
    "caller_identity": {
      "person_name": "Full caller name if stated, otherwise null",
      "company_name": "Company name if stated, otherwise null",
      "caller_type": "homeowner|business|unknown",
      "email": "Email address if stated or spelled, otherwise null",
      "phone_numbers": ["Additional phone numbers mentioned, excluding the caller ID number"],
      "job_title": "Caller job title if stated, otherwise null"
    }
PROMPT
MAX_WAIT_TIME =

Maximum wait time for transcription (most calls are short)

600

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(call_record, options = {}) ⇒ TranscriptionService

Returns a new instance of TranscriptionService.

Parameters:

  • call_record (CallRecord)

    the call record to transcribe

  • options (Hash) (defaults to: {})

    transcription options

Options Hash (options):

  • force (Boolean)

    re-transcribe even if a transcript already exists (default: false)



49
50
51
52
# File 'app/services/call_record_processing/transcription_service.rb', line 49

def initialize(call_record, options = {})
  @call_record = call_record
  @options = options.symbolize_keys
end

Instance Attribute Details

#call_recordObject (readonly)

10 minutes



44
45
46
# File 'app/services/call_record_processing/transcription_service.rb', line 44

def call_record
  @call_record
end

#optionsObject (readonly)

10 minutes



44
45
46
# File 'app/services/call_record_processing/transcription_service.rb', line 44

def options
  @options
end

Instance Method Details

#process_completed_transcript(result) ⇒ Object

Process a completed transcript (called by webhook worker or sync mode)
Formats the transcript, saves it, runs LeMUR analysis, and generates embeddings

Parameters:

  • result (Hash)

    The completed transcript from AssemblyAI



104
105
106
107
108
109
110
111
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
139
140
141
142
# File 'app/services/call_record_processing/transcription_service.rb', line 104

def process_completed_transcript(result)
  # Format the transcript with speaker diarization
  # This sets @agent_speaker_label or @use_role_labels for proper speaker identification
  formatted = format_speaker_diarization(result)

  # Determine the agent speaker label to save
  # - For name-based: The agent's actual name (legacy Slam-1 speech_understanding)
  # - For role-based: 'Agent' (literal label from API)
  # - For legacy/Universal-3 Pro A/B diarization: detected label ('A' or 'B')
  # - For multichannel: nil (uses channel mapping instead)
  detected_agent_label = if @use_name_labels
                           agent_name # Save the actual agent name
                         elsif @use_role_labels
                           'Agent'
                         elsif @agent_speaker_label.present?
                           @agent_speaker_label
                         end

  # Save transcription results first
  call_record.update!(
    transcript: formatted[:text],
    structured_transcript_json: result,
    transcription_state: :completed,
    transcribed_at: Time.current,
    assemblyai_transcript_id: result['id'],
    call_direction: detect_call_direction,
    agent_speaker_label: detected_agent_label
  )

  Rails.logger.info "[CallRecordTranscription] Completed transcription for CallRecord #{call_record.id}"

  # Run call analysis (summary, action items, etc.) if transcription succeeded
  run_call_analysis(result['id']) if result['id'].present?

  return unless call_record.voicemail?

  append_transcription_to_voicemail_activity
  notify_voicemail_recipient
end

#run_call_analysis(transcript_id = nil) ⇒ Object

Run call analysis on the transcript using the LLM (summary, action items, etc.)
This replaces the separate CallRecordSummaryWorker.
Made public so it can be called independently for re-analysis.



147
148
149
150
151
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
179
180
181
182
183
# File 'app/services/call_record_processing/transcription_service.rb', line 147

def run_call_analysis(transcript_id = nil)
  transcript_id ||= call_record.assemblyai_transcript_id
  return if transcript_id.blank?

  Rails.logger.info "[CallRecordTranscription] Running call analysis for transcript: #{transcript_id}"

  begin
    analysis = run_analysis_with_agent(transcript_id)

    # Update call record with analysis results
    call_record.update!(
      ai_summary: analysis['summary'],
      call_outcome: map_call_outcome(analysis['call_outcome']),
      customer_satisfaction: analysis['customer_satisfaction'],
      action_items: analysis['action_items'],
      call_phases: analysis['call_phases'],
      key_topics: analysis['key_topics'],
      agent_performance_score: analysis.dig('agent_performance', 'score'),
      summarized_at: Time.current,
      lemur_analyzed_at: Time.current
    )

    Rails.logger.info "[CallRecordTranscription] Call analysis completed for CallRecord #{call_record.id}"

    # Honor the destination employee's process_voicemails opt-out — when
    # disabled we don't spin off Contacts or mutate the matched party.
    enrich_voicemail_customer(analysis['caller_identity']) if call_record.voicemail? && call_record.voicemail_processing_enabled?

    # Generate embedding with the enriched content (summary, action items, etc.)
    EmbeddingWorker.perform_async('CallRecord', call_record.id)
    Rails.logger.info "[CallRecordTranscription] Queued embedding generation for CallRecord #{call_record.id}"
  rescue StandardError => e
    # Don't fail the whole transcription if analysis fails
    Rails.logger.error "[CallRecordTranscription] Call analysis failed for CallRecord #{call_record.id}: #{e.message}"
    Rails.logger.error e.backtrace.first(5).join("\n")
  end
end

#transcribe(use_webhook: true) ⇒ Hash

Main transcription workflow - now uses webhooks by default
Submit transcription to AssemblyAI and exit immediately.
When transcription completes, AssemblyAI calls our webhook which triggers
AssemblyaiCompletionWorker to process the result.

Parameters:

  • use_webhook (Boolean) (defaults to: true)

    Use async webhook mode (default: true)

Returns:

  • (Hash)

    Result with :status and transcript_id or error



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'app/services/call_record_processing/transcription_service.rb', line 61

def transcribe(use_webhook: true)
  return skip_result(:already_transcribed) if already_transcribed? && !force?
  return skip_result(:too_short) if too_short?
  return skip_result(:no_audio) unless has_audio?

  begin
    mark_processing

    audio_url = get_audio_url
    unless audio_url
      # With `has_audio?` now checking for a fetchable file, this is only
      # reachable via get_audio_url's rescue — a transient storage failure.
      # Mark `error` (as the rescue below does) so the nightly reconciler's
      # pending/error scope re-queues it, rather than leaving it stuck in
      # `processing` where nothing reconciles it (AppSignal #1982).
      call_record.update!(transcription_state: :error)
      return error_result(:no_audio_url)
    end

    Rails.logger.info "[CallRecordTranscription] Starting transcription for CallRecord #{call_record.id}, audio: #{audio_url}"

    if use_webhook
      # Async mode: Submit and exit immediately, webhook will handle completion
      transcript_id = submit_transcription_with_webhook(audio_url)
      Rails.logger.info "[CallRecordTranscription] Submitted async transcription for CallRecord #{call_record.id}: #{transcript_id}"
      { status: :submitted, transcript_id: transcript_id, mode: :webhook }
    else
      # Sync mode: Poll for completion (legacy, slower but useful for testing)
      result = submit_and_poll_transcription(audio_url)
      process_completed_transcript(result)
      { status: :success, transcript_id: result['id'], word_count: call_record.transcript&.split&.size || 0 }
    end
  rescue StandardError => e
    Rails.logger.error "[CallRecordTranscription] Failed for CallRecord #{call_record.id}: #{e.message}"
    call_record.update!(transcription_state: :error)
    error_result(:transcription_failed, e.message)
  end
end