Prd/components/backend/lib/helpdesk/dual_transcript.rb
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
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.
2026-07-27 18:50:32 +02:00

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