Prd/components/transcription-worker/bench/ab.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

331 lines
15 KiB
Ruby

# frozen_string_literal: true
# Transcription A/B harness - runs the same recordings through different Whisper settings and scores
# them, so a change can be proven rather than assumed.
#
# Why it exists: the transcripts have real Czech errors ("badíček" for "balíček", "myštěný" for
# "zmeškaný"), and there are several plausible fixes - a different model, different speaker hints, less
# lossy audio from the phone. Without measurement they are all just opinions, and some of them make
# things worse. The existing tests (test_remote_transcriber.rb, test_pipeline.rb) are offline fakes that
# check plumbing, never accuracy.
#
# Usage:
# ruby components/transcription-worker/bench/ab.rb [corpus_dir] # default: bench/corpus
#
# Corpus layout - the reference is what you SAID, typed out by hand:
# corpus/call-01.m4a
# corpus/call-01.txt <- optional reference. Without it you still get transcripts to read,
# but no score, because there is nothing to be right or wrong against.
#
# Needs the same mTLS env as production (WHISPER_URL, WHISPER_MTLS_P12, WHISPER_MTLS_PASS).
# Results are cached under bench/out/, because the service is CPU-only and a re-run is minutes, not
# seconds. Delete a cached file to force that one job again.
#
# grep anchors: bench-variants, bench-normalise, bench-wer.
require "json"
require "fileutils"
require_relative "../remote_transcriber"
module Helpdesk
module Bench
# The settings worth comparing. Everything here is a real parameter the hosted service accepts -
# it takes exactly five fields (file, model, language, min_speakers, max_speakers) and no others,
# so there is no prompt or decoding knob to vary. grep anchor: bench-variants.
VARIANTS = [
{ name: "baseline", model: "large-v2", min_speakers: 2, max_speakers: 3 }, # what production does today
{ name: "v2-2spk", model: "large-v2", min_speakers: 2, max_speakers: 2 }, # stop it inventing a third voice
{ name: "v3", model: "large-v3", min_speakers: 2, max_speakers: 3 }, # the service calls v2 better for Czech - check
{ name: "v3-2spk", model: "large-v3", min_speakers: 2, max_speakers: 2 },
].freeze
AUDIO_EXT = %w[.m4a .mp3 .wav .ogg .flac .mp4 .opus .webm .aac .audio].freeze
module_function
# ---- scoring, all pure so it is testable without touching the network -------------------------
# Strip the service's "[00:00:01] [Speaker 1]: " prefixes down to the words that were said.
# grep anchor: bench-normalise.
def strip_markup(text)
text.to_s.lines.map { |l| l.sub(/\A\s*\[\d{2}:\d{2}:\d{2}\]\s*/, "").sub(/\A\s*\[[^\]]+\]:\s*/, "") }
.join(" ")
end
# Compare like with like: case and punctuation are not transcription errors. Diacritics ARE kept -
# in Czech they change the word, so folding them would hide real mistakes.
# NOTE the en/en-dash characters in the punctuation class below are deliberate and are NOT a style
# violation of the repo's no-dashes rule: that rule governs prose we write, while this strips
# punctuation out of Czech text Whisper produces, which does contain both. Do not "fix" them.
PUNCT = /[.,!?;:"„“”…()\-–—]/.freeze
def normalise(text)
strip_markup(text).downcase
.gsub(PUNCT, " ")
.gsub(/\s+/, " ")
.strip
end
def words(text) = normalise(text).split(" ")
# Word error rate: edit distance over WORDS divided by the reference length. 0.0 is perfect, and it
# can exceed 1.0 when the output is longer than the reference (Whisper padding a mumble with
# invented words is a real failure mode, so that is not clamped). grep anchor: bench-wer.
def wer(reference, hypothesis)
r = words(reference)
h = words(hypothesis)
return { wer: nil, ref_words: 0 } if r.empty?
d = levenshtein(r, h)
{ wer: d.fdiv(r.length), edits: d, ref_words: r.length, hyp_words: h.length }
end
# Align two word sequences and label every position. Full matrix with a backtrace, because knowing
# WHERE they diverge is what makes the output usable - a bare distance tells you a number, an
# alignment tells you which words to look at. grep anchor: bench-align.
# -> [[:ok|:sub|:del|:ins, ref_word_or_nil, hyp_word_or_nil], ...]
def align(r, h)
m = Array.new(r.length + 1) { Array.new(h.length + 1, 0) }
(0..r.length).each { |i| m[i][0] = i }
(0..h.length).each { |j| m[0][j] = j }
r.each_with_index do |rw, i|
h.each_with_index do |hw, j|
m[i + 1][j + 1] = [m[i][j + 1] + 1, m[i + 1][j] + 1, m[i][j] + (rw == hw ? 0 : 1)].min
end
end
ops = []
i = r.length; j = h.length
while i.positive? || j.positive?
if i.positive? && j.positive? && m[i][j] + (r[i - 1] == h[j - 1] ? 0 : 1) == m[i][j]
ops << [r[i - 1] == h[j - 1] ? :ok : :sub, r[i - 1], h[j - 1]]; i -= 1; j -= 1
elsif i.positive? && m[i - 1][j] + 1 == m[i][j]
ops << [:del, r[i - 1], nil]; i -= 1
elsif j.positive? && m[i][j - 1] + 1 == m[i][j]
ops << [:ins, nil, h[j - 1]]; j -= 1
else
ops << [r[i - 1] == h[j - 1] ? :ok : :sub, r[i - 1], h[j - 1]]; i -= 1; j -= 1
end
end
ops.reverse
end
# Group runs of disagreement into chunks, each with the reference words and what replaced them.
# Adjacent edits belong together: "na shledanou" -> "naschledanou" is ONE disagreement, and scoring
# it as two makes a formatting quirk look twice as bad as a misheard word.
def chunks(reference, hypothesis)
out = []
cur = nil
align(words(reference), words(hypothesis)).each do |op, rw, hw|
if op == :ok
out << cur if cur
cur = nil
else
cur ||= { ref: [], hyp: [] }
cur[:ref] << rw if rw
cur[:hyp] << hw if hw
end
end
out << cur if cur
out.flat_map { |c| split_chunk(c[:ref], c[:hyp]) }
end
# A run of consecutive edits can mix kinds - "děkuju mockrát na shledanou" -> "děkuji moc krát
# naschledanou" is one run but three separate phenomena, and calling the whole thing a
# misrecognition overstates it. Peel off any leading piece that has a benign explanation, then
# recurse on the rest; whatever cannot be explained stays as one :real chunk.
def split_chunk(ref, hyp)
whole = [{ ref: ref, hyp: hyp, kind: classify_chunk(ref, hyp) }]
return whole if ref.length + hyp.length <= 2 || whole[0][:kind] != :real
(1..ref.length).each do |i|
(1..hyp.length).each do |j|
next if i == ref.length && j == hyp.length # no progress
k = classify_chunk(ref[0, i], hyp[0, j])
next if k == :real
rest_r = ref[i..] || []
rest_h = hyp[j..] || []
rest = (rest_r.empty? && rest_h.empty?) ? [] : split_chunk(rest_r, rest_h)
return [{ ref: ref[0, i], hyp: hyp[0, j], kind: k }] + rest
end
end
whole
end
# What KIND of disagreement is this? Only :real should count against the audio. Deciding this
# automatically is the difference between a score you can trust and one you have to re-argue every
# time. grep anchor: bench-classify.
NUM_WORDS = %w[nula jedna jedné jeden jedno dva dvě tři čtyři pět šest sedm osm devět deset
jedenáct dvanáct třináct čtrnáct patnáct šestnáct sedmnáct osmnáct devatenáct
dvacet třicet čtyřicet padesát šedesát sedmdesát osmdesát devadesát sto stě set
tisíc lomeno].freeze
def classify_chunk(ref, hyp)
r = ref.join; h = hyp.join
return :boundary if r == h # same letters, different spacing
return :diacritic if strip_diacritics(r) == strip_diacritics(h)
return :variant if spoken_variant?(r, h)
return :number if (ref + hyp).any? { |w| NUM_WORDS.include?(w) || w.match?(/\d/) }
:real
end
# Czech first-person verbs have a spoken form in -u and a written one in -i ("děkuju" / "děkuji",
# "potřebuju" / "potřebuji"). Whisper writes the standard form whichever was said, so counting that
# as a transcription error would penalise it for being right. Same stem, endings differing only
# u <-> i, is the whole rule.
def spoken_variant?(a, b)
return false if a.length < 3 || b.length < 3
return false unless a[0..-2] == b[0..-2]
%w[u i].include?(a[-1]) && %w[u i].include?(b[-1]) && a[-1] != b[-1]
end
def strip_diacritics(s)
s.tr("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ", "acdeeinorstuuyzACDEEINORSTUUYZ")
end
# Word error rate ignoring differences that are not misrecognitions. Boundary and diacritic-only
# chunks are dropped; everything else counts. This is the number worth comparing between runs.
def effective_wer(reference, hypothesis)
r = words(reference)
return { wer: nil } if r.empty?
cs = chunks(reference, hypothesis)
counted = cs.reject { |c| %i[boundary diacritic variant].include?(c[:kind]) }
edits = counted.sum { |c| [c[:ref].length, c[:hyp].length].max }
{ wer: edits.fdiv(r.length), edits: edits, ref_words: r.length,
by_kind: cs.group_by { |c| c[:kind] }.transform_values(&:length) }
end
# Character error rate with spaces removed - immune to where the model put word boundaries, and a
# useful sanity check on the word-level numbers.
def cer(reference, hypothesis)
r = normalise(reference).delete(" ").chars
return nil if r.empty?
levenshtein(r, normalise(hypothesis).delete(" ").chars).fdiv(r.length)
end
# Standard Levenshtein over arrays, two rows rather than a full matrix.
def levenshtein(a, b)
return b.length if a.empty?
return a.length if b.empty?
prev = (0..b.length).to_a
a.each_with_index do |ax, i|
cur = [i + 1]
b.each_with_index do |bx, j|
cur << [prev[j + 1] + 1, cur[j] + 1, prev[j] + (ax == bx ? 0 : 1)].min
end
prev = cur
end
prev[b.length]
end
# Pull one speaker's words out of a merged dual-channel transcript.
def speaker_side(transcript, label)
transcript.to_s.lines.select { |l| l.include?("[#{label}]:") }
.map { |l| l.sub(/.*\]:/, "") }.join(" ")
end
# Score a dual-channel transcript PER SPEAKER. Scoring a merged transcript against a linear script
# measures the wrong thing: the merge is ordered by timestamp and the script by reading order, so
# whenever turns group differently the alignment collapses and reports a huge error rate for text
# that is actually correct. On a real call that inflated 10% to 44%. Comparing each speaker's words
# against only their own lines removes the ordering entirely.
# -> { "Operator" => {wer:, cer:, ...}, "Caller" => {...} }
# grep anchor: bench-per-channel.
def score_channels(transcript, refs)
refs.each_with_object({}) do |(label, ref), out|
hyp = speaker_side(transcript, label)
out[label] = effective_wer(ref, hyp).merge(cer: cer(ref, hyp), hyp_words: words(hyp).length)
end
end
# How many distinct voices the diariser decided there were. A two-party phone call should be 2;
# 3 means it split someone, 1 means it merged them.
def speaker_count(text) = text.to_s.scan(/\[Speaker\s+(\d+)\]/i).flatten.uniq.length
# The words the reference has that the hypothesis does not, and vice versa - this is what tells you
# WHICH words are being mangled, which is the whole point of looking at a corpus.
def word_diff(reference, hypothesis, limit: 12)
r = words(reference).tally
h = words(hypothesis).tally
missing = r.reject { |w, n| h[w].to_i >= n }.keys.first(limit)
spurious = h.reject { |w, n| r[w].to_i >= n }.keys.first(limit)
{ missing: missing, spurious: spurious }
end
# ---- running ----------------------------------------------------------------------------------
def corpus(dir)
Dir.glob(File.join(dir, "*")).select { |f| AUDIO_EXT.include?(File.extname(f).downcase) }.sort
end
def reference_for(audio)
ref = audio.sub(/#{Regexp.escape(File.extname(audio))}\z/, ".txt")
File.exist?(ref) ? File.read(ref) : nil
end
def run(dir)
files = corpus(dir)
if files.empty?
warn "no audio in #{dir}. Put recordings there, plus a matching .txt of what was actually said."
return 1
end
out = File.join(__dir__, "out")
FileUtils.mkdir_p(out)
transcriber = RemoteTranscriber.new
rows = []
files.each do |audio|
ref = reference_for(audio)
base = File.basename(audio, ".*")
puts "\n#{base}#{ref ? '' : ' (no reference: transcripts only, no score)'}"
VARIANTS.each do |v|
cached = File.join(out, "#{base}.#{v[:name]}.txt")
if File.exist?(cached)
text = File.read(cached)
note = "cached"
else
t0 = Time.now
res = transcriber.transcribe(audio, model: v[:model],
min_speakers: v[:min_speakers], max_speakers: v[:max_speakers])
unless res[:state] == "transcribed"
puts " %-10s FAILED (%s)" % [v[:name], res[:reason] || res[:state]]
next
end
text = res[:transcript]
File.write(cached, text)
note = "#{(Time.now - t0).round}s"
end
eff = ref ? effective_wer(ref, text) : nil
rows << { file: base, variant: v[:name], wer: eff && eff[:wer], speakers: speaker_count(text) }
puts " %-10s speakers=%d %s %s" % [
v[:name], speaker_count(text),
eff && eff[:wer] ? "WER=#{(eff[:wer] * 100).round(1)}% CER=#{(cer(ref, text) * 100).round(1)}%"
: "WER=n/a",
note
]
if ref && eff[:wer]
# Only the misrecognitions are worth reading; boundary/diacritic/variant chunks are noise
# the score already discounts, and printing them buries the ones that matter.
bad = chunks(ref, text).reject { |c| %i[boundary diacritic variant].include?(c[:kind]) }
bad.first(8).each do |c|
puts " [%-8s] %s -> %s" % [c[:kind], c[:ref].join(" "), c[:hyp].join(" ")]
end
puts " (+#{bad.length - 8} more)" if bad.length > 8
skipped = eff[:by_kind].select { |k, _| %i[boundary diacritic variant].include?(k) }
puts " discounted: #{skipped.map { |k, n| "#{n} #{k}" }.join(', ')}" if skipped.any?
end
end
end
scored = rows.select { |r| r[:wer] }
unless scored.empty?
puts "\n=== mean WER across #{scored.map { |r| r[:file] }.uniq.length} recording(s), lower is better ==="
scored.group_by { |r| r[:variant] }.each do |name, rs|
mean = rs.sum { |r| r[:wer] } / rs.length
puts " %-10s %.1f%%" % [name, mean * 100]
end
puts "\nA difference under a point or two on a handful of calls is noise, not an improvement."
end
puts "\ntranscripts cached in #{out} (delete one to re-run that job)"
0
end
end
end
exit(Helpdesk::Bench.run(ARGV[0] || File.join(__dir__, "corpus"))) if $PROGRAM_NAME == __FILE__