# frozen_string_literal: true # DualTranscript - merges two per-speaker transcripts into one correctly attributed conversation. # # When the phone records the call legs as separate channels, each channel is transcribed on its own and # therefore contains exactly one person. Attribution stops being something a diariser infers from voice # characteristics and becomes a fact about which file the words came from - which is the entire point of # capturing the legs separately. # # Both channels are the same recording, so their timestamps share an origin and interleaving them # reconstructs the conversation in order. # # The output keeps the shape everything downstream already parses - "[HH:MM:SS] [Speaker N]: text" - # so the summariser, the ledger extractor and the console need no changes. Only the labels get better: # "Speaker 1" becomes "Operator" and "Speaker 2" becomes "Caller", and now they are actually true. # # grep anchors: dual-transcript-parse, dual-transcript-merge. require "time" module Helpdesk module DualTranscript LINE = /\A\s*\[(\d{1,2}):(\d{2}):(\d{2})\]\s*(?:\[[^\]]*\]\s*:?)?\s*(.*)\z/ OPERATOR = "Operator" CALLER = "Caller" module_function # -> [{ at: seconds, text: "..." }, ...] # Lines without a timestamp are attached to the previous entry: the service wraps long turns, and # dropping the continuation would silently lose half a sentence. grep anchor: dual-transcript-parse. def parse(text) out = [] text.to_s.each_line do |line| s = line.strip next if s.empty? if (m = s.match(LINE)) at = (m[1].to_i * 3600) + (m[2].to_i * 60) + m[3].to_i out << { at: at, text: m[4].to_s.strip } elsif out.any? out.last[:text] = [out.last[:text], s].reject(&:empty?).join(" ") else out << { at: 0, text: s } # untimed preamble, keep it rather than lose it end end out.reject { |e| e[:text].empty? } end # Merge two single-speaker transcripts into one. grep anchor: dual-transcript-merge. # Ties go to the operator, so a turn that begins in the same second as the caller's reads as the # question before the answer, which is nearly always the real order on a helpdesk call. def merge(left:, right:, left_label: OPERATOR, right_label: CALLER) entries = parse(left).map { |e| e.merge(who: left_label, rank: 0) } + parse(right).map { |e| e.merge(who: right_label, rank: 1) } entries.sort_by.with_index { |e, i| [e[:at], e[:rank], i] } .map { |e| "[#{hms(e[:at])}] [#{e[:who]}]: #{e[:text]}" } .join("\n\n") end def hms(seconds) s = seconds.to_i format("%02d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60) end # Did the two channels actually capture different people? If one leg was silent, or the HAL handed # back the same mixed audio on both, the merge is worthless and the caller should keep the ordinary # single-file transcript instead of a doubled one. Cheap sanity check on the text, since we cannot # inspect the audio from here. def plausible?(left, right) l = parse(left) r = parse(right) return false if l.empty? || r.empty? # Identical channels mean the split gained nothing (or both legs got the same stream). lt = l.map { |e| e[:text] }.join(" ").strip rt = r.map { |e| e[:text] }.join(" ").strip return false if lt == rt true end end end