# frozen_string_literal: true # Pipeline — composes the chain: audio → Transcriber → transcript → Summariser → result. A plain class # (no job runner) so the whole thing runs and is tested offline. server.rb calls it off the request # thread on a recording upload; test/test_pipeline.rb drives it with stubs. # # Design rule: the TRANSCRIPT is the valuable, durable artifact. If summarisation fails, the pipeline # still succeeds (stage="transcribed") — the summary is advisory and can be retried/backfilled later. require_relative "summariser" require_relative "lib/helpdesk/glossary" require_relative "lib/helpdesk/wav_split" require_relative "lib/helpdesk/dual_transcript" require "tmpdir" require "fileutils" require_relative File.join("..", "transcription-worker", "transcriber") module Helpdesk class Pipeline # Speaker hints for a SINGLE-speaker channel. Not a claim about how many people are on it - see # transcribe_best. Higher values segment more finely; these were measured, not guessed. SEGMENT_MIN_SPEAKERS = 4 SEGMENT_MAX_SPEAKERS = 8 def initialize(transcriber: Transcriber.new, summariser: Summariser.new, wiki_retriever: nil) @transcriber = transcriber @summariser = summariser @wiki = wiki_retriever # optional RAG: retrieves relevant wiki chunks per transcript end # Config-driven build: use the HOSTED Whisper (RemoteTranscriber) when WHISPER_URL is set, # else the local WhisperX subprocess; load the wiki RAG index if present. Both transcribers # satisfy the same transcribe(...) contract. def self.build(summariser: Summariser.new) url = ENV["WHISPER_URL"].to_s transcriber = if url.empty? Transcriber.new else require_relative File.join("..", "transcription-worker", "remote_transcriber") RemoteTranscriber.new(base_url: url) end new(transcriber: transcriber, summariser: summariser, wiki_retriever: load_wiki) end # Load the wiki RAG index if it exists + we have a key to embed the query. Nil = no grounding. def self.load_wiki path = ENV["WIKI_INDEX_PATH"] || File.join(__dir__, "wiki", "wiki_index.json") key = ENV["OPENROUTER_API_KEY"].to_s return nil if key.empty? || !File.exist?(path) require_relative "wiki/embedder" require_relative "wiki/wiki_index" WikiIndex.new(embedder: Embedder.new(api_key: key)).load(path) rescue => e warn "[helpdesk] wiki index load failed: #{e.class}: #{e.message}" nil end # → { ok:, stage:, transcript?:, summary?:, action_items?:, cost?:, error?:, transcription:, summary_meta?, # context_digest?:, caller_facts?:, ledger_deltas?: } # stage ∈ transcription(fail) | no_speech | transcribed(summary failed) | summarised def process(audio, operator: nil, campaign: nil, ledger: [], model: "large-v2", timeout: nil) t = transcribe_best(audio, model: model, timeout: timeout) out = { transcription: { state: t[:state], retryable: t[:retryable], exit_code: t[:exit_code] } } out[:channels] = t[:channels] if t[:channels] unless %w[transcribed no_speech].include?(t[:state]) return out.merge(ok: false, stage: "transcription", error: t[:reason] || "transcription #{t[:state]}") end transcript = t[:transcript].to_s # Repair domain words the transcriber cannot know. Whisper has never seen "yamt" and the hosted # service takes no vocabulary hint, so it writes the sound it heard ("jamt"); better audio cannot # fix that because nothing in the signal spells the word. Only distinctive terms are listed, and # every substitution is logged - a transcript is a record of what a caller was told, so it must # never be edited silently. See lib/helpdesk/glossary.rb (grep anchor: glossary-correct). transcript, glossary_fixes = Glossary.correct(transcript) unless glossary_fixes.empty? out[:glossary_fixes] = glossary_fixes warn "[helpdesk] glossary: #{glossary_fixes.map { |c| "#{c[:from]}->#{c[:to]}" }.join(', ')}" end out[:transcript] = transcript return out.merge(ok: true, stage: "no_speech", summary: nil, action_items: []) if transcript.strip.empty? s = @summariser.summarise(transcript, operator: operator, campaign: campaign, wiki_context: wiki_context_for(transcript), ledger: ledger) out[:summary_meta] = { ok: s[:ok], model: s[:model], cost: s[:cost], needs_review: s[:needs_review], fell_back: s[:fell_back] } if s[:ok] out.merge(ok: true, stage: "summarised", summary: s[:summary], action_items: s[:action_items], suggested_position: s[:suggested_position], cost: s[:cost], context_digest: s[:context_digest], caller_facts: s[:caller_facts], ledger_deltas: s[:ledger_deltas] || []) else # keep the transcript; leave the summary for a retry/backfill (advisory, non-blocking) out.merge(ok: true, stage: "transcribed", summary: nil, action_items: [], summary_error: s[:error] || "needs_review") end end # When the phone recorded the two call legs as separate channels, transcribe each one on its own and # merge them; otherwise transcribe the file as it stands. Splitting is not an optimisation, it is the # only way the separation survives: Whisper resamples to mono before decoding, so handing it a stereo # file puts both speakers back into one channel and discards what the phone captured. # # Every failure path falls back to transcribing the original file, because a correctly attributed # transcript is a bonus and a transcript is not. grep anchor: dual-channel-transcribe. def transcribe_best(audio, model:, timeout:) return transcribe_single(audio, model: model, timeout: timeout) unless WavSplit.splittable?(audio) dir = Dir.mktmpdir("helpdesk-legs") left = File.join(dir, "operator.wav") right = File.join(dir, "caller.wav") begin WavSplit.split(audio, left_path: left, right_path: right) # Diarisation is used here as a SEGMENTER, not a classifier. Its speaker labels are discarded # (we already know who owns each channel), but its turn boundaries are the only timestamps the # service emits - so pinning a channel to one speaker, which is the truth, collapses it into a # single block with one timestamp and leaves nothing to interleave on. Measured on a real call: # 1 speaker gave 1 segment, 2 gave 4, and 4-8 gave 6, with the transcribed WORDS byte-identical # at every setting. So asking for more speakers than exist costs nothing and buys the resolution # the merge needs. A leg that fails falls back to the mixed file below. l = @transcriber.transcribe(left, model: model, timeout: timeout, min_speakers: SEGMENT_MIN_SPEAKERS, max_speakers: SEGMENT_MAX_SPEAKERS) r = @transcriber.transcribe(right, model: model, timeout: timeout, min_speakers: SEGMENT_MIN_SPEAKERS, max_speakers: SEGMENT_MAX_SPEAKERS) unless l[:state] == "transcribed" && r[:state] == "transcribed" warn "[helpdesk] dual-channel: a leg failed (left=#{l[:state]} right=#{r[:state]}), " \ "falling back to the mixed file" return transcribe_single(audio, model: model, timeout: timeout) end unless DualTranscript.plausible?(l[:transcript], r[:transcript]) warn "[helpdesk] dual-channel: the channels do not look like two different speakers, " \ "falling back to the mixed file" return transcribe_single(audio, model: model, timeout: timeout) end { state: "transcribed", retryable: false, channels: 2, transcript: DualTranscript.merge(left: l[:transcript], right: r[:transcript]) } rescue => e warn "[helpdesk] dual-channel split failed (#{e.class}: #{e.message}), using the mixed file" transcribe_single(audio, model: model, timeout: timeout) ensure FileUtils.remove_entry(dir, true) end end def transcribe_single(audio, model:, timeout:) @transcriber.transcribe(audio, model: model, timeout: timeout) end # Retrieve the most relevant wiki chunks for this transcript as a context string (nil if no index). # k=15 is the A/B sweet spot: enough recall to surface concrete specs, without the dilution that # made k=40 answer more vaguely. The grounding is traceable — each action item cites its source page. def wiki_context_for(transcript, k: 15) return nil unless @wiki chunks = @wiki.retrieve(transcript, k: k) return nil if chunks.empty? chunks.map { |c| "- [#{c[:title]}] #{c[:text][0, 600]}" }.join("\n") rescue => e warn "[helpdesk] wiki retrieve failed: #{e.class}: #{e.message}" nil end end end # CLI: [OPENROUTER_API_KEY=…] ruby pipeline.rb