Prd/components/backend/lib/helpdesk/glossary.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

185 lines
9.3 KiB
Ruby

# frozen_string_literal: true
# Glossary - repairs domain words the transcriber cannot possibly get right.
#
# Whisper has never seen "yamt" and the hosted service accepts no vocabulary hint (it takes exactly five
# form fields, none of them a prompt), so it writes what it hears: "jamt", which is the same sound in
# Czech. No amount of audio quality fixes that, because nothing in the signal says how the word is spelt.
# A short list of terms we know are real does fix it, and does so deterministically.
#
# WHAT THIS IS NOT FOR. Only distinctive terms belong here - system names, site names, operators, product
# codes. NOT ordinary words. "balíček" is one edit from "malíček" (little finger), so listing it would
# eventually rewrite somebody's correct sentence into a wrong one. Common-word errors are a symptom of
# poor audio and are fixed at the recorder, not here. The loader enforces a minimum length and the
# matcher refuses anything but a near-miss, but the real safeguard is the list staying curated.
#
# Corrections are RETURNED as well as applied. A transcript is evidence of what a caller was told, so
# quietly editing it is not acceptable; the pipeline logs every substitution it makes.
#
# grep anchors: glossary-load, glossary-correct, glossary-match.
module Helpdesk
module Glossary
DEFAULT_PATH = File.join(__dir__, "glossary_terms.txt")
MIN_TERM_LEN = 4 # shorter terms are too easy to collide with ordinary words
# A near-miss only. One edit for a normal term, two once the word is long enough that two edits
# still leave it unmistakable. Anything looser starts "correcting" words that were already right.
def self.max_distance(term_len) = term_len >= 8 ? 2 : 1
module_function
# One term per line; "#" comments and blank lines ignored. A term may contain spaces
# ("RF plánovač") and is matched as a phrase. grep anchor: glossary-load.
def load(path = ENV["HELPDESK_GLOSSARY_PATH"] || DEFAULT_PATH)
return [] unless path && File.readable?(path)
# Explicit UTF-8, not the ambient locale. The terms are Czech, so under a non-UTF-8 LANG Ruby reads
# them as US-ASCII and the first match raises - which the rescue below turns into an EMPTY glossary,
# silently losing every term. The systemd unit does set LC_ALL, but anything run outside it (a cron
# job, a one-off script, the CLI) would not, and the failure looks like "the glossary just stopped".
File.readlines(path, chomp: true, encoding: "UTF-8")
.map { |l| l.sub(/#.*/, "").strip }
.reject { |l| l.empty? || l.gsub(/\s+/, "").length < MIN_TERM_LEN }
.uniq
rescue StandardError => e
warn "[helpdesk] glossary load failed (#{e.class}: #{e.message}) - continuing without it"
[]
end
# -> [corrected_text, [{ from:, to: }, ...]]
# Longest terms first so "RF plánovač" is considered before "plánovač" and a phrase match wins.
# grep anchor: glossary-correct.
def correct(text, terms = load)
return [text, []] if text.to_s.empty? || terms.empty?
ordered = terms.sort_by { |t| -t.split(/\s+/).length }
changes = []
out = text.to_s.lines.map { |line| correct_line(line, ordered, changes) }.join
[out, changes]
end
# The transcript arrives as "[00:00:01] [Speaker 1]: words". Only the words may be touched - rewriting
# a timestamp or a speaker tag would corrupt the structure everything downstream parses.
def correct_line(line, ordered, changes)
m = line.match(/\A(\s*(?:\[[^\]]*\]\s*)*:?\s*)(.*)\z/m)
prefix = m ? m[1] : ""
body = m ? m[2] : line
ordered.each { |term| body = apply_term(body, term, changes) }
prefix + body
end
# Slide a window the width of the term across the line and replace near-misses. Case and diacritics
# are ignored when comparing (the model often drops an accent) but the term's own spelling is what
# gets written back. grep anchor: glossary-match.
def apply_term(body, term, changes)
tw = term.split(/\s+/)
n = tw.length
tokens = body.split(/(\s+)/) # keep the whitespace so spacing survives
words = tokens.each_index.select { |i| i.even? } # even indices are words
return body if words.length < n
i = 0
while i <= words.length - n
idx = words[i, n]
phrase = idx.map { |k| tokens[k] }
core = phrase.map { |w| strip_edges(w) }
if n == 1 && core.first && (split = fused_split(core.first, term))
# A term swallowed by the word before it. Seen on a real call: "přes yamt" came back as
# "přezjamt" - fused AND assimilated (přes -> přez before a voiced consonant), so the token
# never resembles the term on its own. Restore both halves.
changes << { from: core.first, to: "#{split[0]} #{term}" }
lead, trail = edges(phrase.first, phrase.last)
tokens[idx.first] = lead + split[0] + " " + term + trail
i += 1
elsif core.all? { |c| !c.empty? } && near_miss?(core.join(" "), term)
original = core.join(" ")
unless original == term
changes << { from: original, to: term }
lead, trail = edges(phrase.first, phrase.last)
# Collapse the whole phrase into its first token: blank everything from just after it up to
# and including the last word, which clears the separators between them too. Doing this by
# word index alone would leave the spaces behind and pad the line.
((idx.first + 1)..idx.last).each { |k| tokens[k] = "" }
tokens[idx.first] = lead + term + trail
end
i += n
else
i += 1
end
end
tokens.join
end
# Punctuation clings to spoken words ("jamt," / "(jamt)"); compare the word itself, restore the rest.
def strip_edges(w) = w.gsub(/\A[^[:alnum:]]+|[^[:alnum:]]+\z/, "")
def edges(first, last)
[first[/\A[^[:alnum:]]*/].to_s, last[/[^[:alnum:]]*\z/].to_s]
end
# Czech prepositions that can swallow the following word in fast speech. Listed explicitly rather
# than accepting any short prefix, because "any prefix" would let the glossary hack a term out of
# the middle of ordinary words. Includes the voiced-assimilated forms the transcriber actually
# writes ("přes" heard as "přez", "s" as "z"), since that is what the audio sounds like.
FUSING_PREFIXES = %w[pres přes prez přez na do od pod nad pro pri při bez pres pri za pres
v ve z ze s se k ke o u].freeze
# Did a preposition swallow the term? -> [prefix, term] or nil. grep anchor: glossary-fused.
def fused_split(token, term)
t = fold(token)
b = fold(term)
return nil if t.length <= b.length || t.length - b.length > 5
# The tail must be the term (a near-miss is fine: "jamt" for "yamt"), and what precedes it must be
# a real preposition, not just any letters.
tail = t[-b.length..]
return nil unless tail == b || levenshtein(tail, b) <= 1
head = t[0...(t.length - b.length)]
return nil unless FUSING_PREFIXES.include?(head)
[token[0...(token.length - b.length)], term]
end
def near_miss?(candidate, term)
return false if candidate.to_s.empty? || term.to_s.empty?
return false if candidate == term # byte-identical, nothing to do
# Capitalisation is not a misrecognition. Rewriting "Repeater" at the start of a sentence to the
# lower-case listed form damages the text, and forcing a proper noun's case is not this tool's job
# either. Spelling only. Accents still get restored below, because "Jezlovice" and "Ježlovice"
# differ by more than case.
return false if candidate.downcase == term.downcase
a = fold(candidate)
b = fold(term)
return false if (a.length - b.length).abs > 2 # wildly different length is a different word
# Folded-equal means the ONLY difference was accents ("Jezlovice" for "Ježlovice"). Always worth
# restoring, and it has to be decided before the inflection guard below, which would otherwise
# see two identical strings as "differing only in the ending" and refuse.
return true if a == b
return false if suffix_only_difference?(a, b)
levenshtein(a, b) <= max_distance(b.length)
end
# Czech inflects heavily: "na Vodafonu" is the correct locative of "Vodafone", and "yamtu" is a
# perfectly good genitive. Those differ from the listed form only at the end, so a difference confined
# to the last couple of characters is grammar, not a misrecognition, and rewriting it would replace
# correct Czech with a wrong case. Misrecognitions of the kind this exists for ("jamt" for "yamt")
# differ at the start or in the middle instead.
def suffix_only_difference?(a, b)
n = [a.length, b.length].min
shared = 0
shared += 1 while shared < n && a[shared] == b[shared]
shared >= 3 && shared >= n - 2
end
def fold(s)
s.downcase.tr("áčďéěíňóřšťúůýž", "acdeeinorstuuyz")
end
def levenshtein(a, b)
ac = a.chars; bc = b.chars
prev = (0..bc.length).to_a
ac.each_with_index do |x, i|
cur = [i + 1]
bc.each_with_index { |y, j| cur << [prev[j + 1] + 1, cur[j] + 1, prev[j] + (x == y ? 0 : 1)].min }
prev = cur
end
prev[bc.length]
end
end
end