A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
80 lines
3.4 KiB
Ruby
80 lines
3.4 KiB
Ruby
# 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
|