Prd/components/backend/test/test_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

135 lines
7.7 KiB
Ruby

# frozen_string_literal: true
# Offline tests for the transcript glossary (no gems, no network).
# This code REWRITES what a caller was told, so the tests that matter most are the ones proving it
# refuses to act: on ordinary words, on distant words, on text that was already correct, and on the
# transcript's own structure. A glossary that corrupts a good transcript is worse than none.
# Run: ruby components/backend/test/test_glossary.rb
require_relative "../lib/helpdesk/glossary"
$pass = 0; $fail = 0
def ok(d); $pass += 1; puts " ok #{d}"; end
def bad(d, got = nil); $fail += 1; puts " FAIL #{d}#{got.nil? ? '' : " (got: #{got.inspect})"}"; end
def eq(a, b, d); a == b ? ok(d) : bad(d, a); end
def truthy(x, d); x ? ok(d) : bad(d, x); end
def falsy(x, d); x ? bad(d, x) : ok(d); end
G = Helpdesk::Glossary
TERMS = %w[yamt Ježlovice repeater Vodafone].freeze
def fix(text, terms = TERMS) = G.correct(text, terms)
puts "the case this exists for: a homophone no model can spell"
out, ch = fix("Zkusil jste reset brány přes jamt?")
eq(out, "Zkusil jste reset brány přes yamt?", "jamt -> yamt")
eq(ch, [{ from: "jamt", to: "yamt" }], "and the substitution is reported, not silent")
puts "it reports what it did, because a transcript is evidence"
_, ch = fix("jamt a Jezlovice")
eq(ch.length, 2, "every change is listed")
truthy(ch.all? { |c| c[:from] && c[:to] }, "each with both the old and new word")
puts "diacritics are forgiven when matching, but the term's own spelling is written"
eq(fix("Přístup na Jezlovice")[0], "Přístup na Ježlovice", "missing accents still match")
puts "punctuation survives"
eq(fix("Přes jamt, prosím.")[0], "Přes yamt, prosím.", "trailing comma kept")
eq(fix("(jamt)")[0], "(yamt)", "brackets kept")
eq(fix("jamt.")[0], "yamt.", "full stop kept")
puts "IT MUST REFUSE: already-correct text is left exactly alone"
%w[yamt Ježlovice repeater].each do |w|
out, ch = fix("Tady je #{w} a nic víc")
eq(out, "Tady je #{w} a nic víc", "#{w} untouched when already right")
eq(ch, [], "and nothing reported for #{w}")
end
puts "the hazard that the matcher CANNOT solve, and which the curated list must prevent"
# Honest about the limit: "malíček" (little finger) is one edit from "balíček", differing at the first
# letter exactly like the "jamt"/"yamt" case this tool exists for. Nothing in the text distinguishes
# them, so if a common word were ever listed, correct Czech WOULD be rewritten. This test documents
# that rather than pretending otherwise - the protection is the list, enforced further down.
eq(fix("bolí mě malíček", %w[balíček])[0], "bolí mě balíček",
"a listed COMMON word does corrupt ordinary text - which is why common words are banned")
eq(fix("máme tady kabel", TERMS)[0], "máme tady kabel", "an unrelated word is untouched")
eq(fix("prosím pošlete to", TERMS)[0], "prosím pošlete to", "ordinary sentence untouched")
puts "IT MUST REFUSE: capitalisation, which is not a misrecognition"
# Caught on a real call: the glossary rewrote "Repeater" at the start of a sentence to the lower-case
# listed form, damaging correct text. Spelling is this tool's business; case is not.
eq(fix("Repeater je nainstalovaný")[0], "Repeater je nainstalovaný", "a sentence-initial term keeps its capital")
eq(fix("ježlovice")[0], "ježlovice", "and a lower-case proper noun is not force-capitalised either")
eq(fix("YAMT")[0], "YAMT", "shouting is left alone too")
# Accents are more than case, so they are still restored.
eq(fix("Jezlovice")[0], "Ježlovice", "but a missing accent is still fixed")
puts "IT MUST REFUSE: Czech inflection, which is grammar and not a mistake"
eq(fix("jsme na Vodafonu")[0], "jsme na Vodafonu", "the locative of Vodafone is left alone")
eq(fix("přes yamtu")[0], "přes yamtu", "an inflected yamt is not forced back to nominative")
eq(fix("do Ježlovic")[0], "do Ježlovic", "genitive plural of a site name survives")
truthy(G.suffix_only_difference?("vodafonu", "vodafone"), "ending-only difference is recognised as inflection")
falsy(G.suffix_only_difference?("jamt", "yamt"), "a first-letter difference is NOT inflection")
puts "IT MUST REFUSE: words too far away to be the same word"
eq(fix("javor")[0], "javor", "javor is not a mangled yamt")
eq(fix("nemam")[0], "nemam", "unrelated short word untouched")
eq(fix("Jihlava")[0], "Jihlava", "a different place name is not Ježlovice")
puts "IT MUST REFUSE: the transcript's own structure"
line = "[00:00:07] [Speaker 2]: Zkusili jsme jamt."
out, = fix(line)
eq(out, "[00:00:07] [Speaker 2]: Zkusili jsme yamt.", "words fixed, timestamp and speaker tag intact")
truthy(out.start_with?("[00:00:07] [Speaker 2]:"), "the prefix is byte-identical")
multi = "[00:00:01] [Speaker 1]: jamt\n[00:00:05] [Speaker 2]: Jezlovice\n"
eq(fix(multi)[0], "[00:00:01] [Speaker 1]: yamt\n[00:00:05] [Speaker 2]: Ježlovice\n",
"every line handled, newlines preserved")
puts "a term swallowed by the preposition before it"
# From a real call: "přes yamt" came back as "přezjamt" - fused AND voice-assimilated, so the token
# never resembles the term on its own and the plain matcher could not see it.
eq(fix("Zkusil jste reset brány přezjamt?")[0], "Zkusil jste reset brány přez yamt?",
"přezjamt is split back into preposition + term")
eq(fix("reset brány přesyamt")[0], "reset brány přes yamt", "unassimilated form too")
eq(fix("na Jezlovice")[0], "na Ježlovice", "a separate preposition is untouched, the term still fixed")
_, ch = fix("přezjamt")
eq(ch.first[:to], "přez yamt", "the change is reported as the split")
puts "IT MUST REFUSE to hack a term out of an ordinary word"
# The guard is that the part before the term has to be a real preposition, not merely short.
eq(fix("dynamt")[0], "dynamt", "dyn+amt is not a preposition, left alone")
eq(fix("abcjamt")[0], "abcjamt", "arbitrary letters before the term are not a split")
eq(fix("nejamtovitost")[0], "nejamtovitost", "the term must be at the END of the token")
eq(fix("krajamt")[0], "krajamt", "kra is not a preposition")
puts "multi-word terms match as a phrase"
mw = ["RF plánovač"]
eq(fix("souhlas od RF planovac", mw)[0], "souhlas od RF plánovač", "two-word term matched and corrected")
eq(fix("RF plánovač to zamítl", mw)[0], "RF plánovač to zamítl", "already correct phrase untouched")
puts "edge cases do not blow up"
eq(fix("")[0], "", "empty transcript")
eq(fix("nic", [])[0], "nic", "no terms configured -> unchanged")
eq(G.correct(nil, TERMS)[0], nil, "nil in, nil out")
eq(fix(" ")[0], " ", "whitespace only")
puts "loading the shipped list"
terms = G.load
truthy(terms.include?("yamt"), "yamt is in the repo glossary")
truthy(terms.include?("Ježlovice"), "Ježlovice is in the repo glossary")
truthy(terms.none? { |t| t.gsub(/\s+/, "").length < G::MIN_TERM_LEN }, "no term is short enough to be risky")
falsy(terms.any? { |t| t.start_with?("#") }, "comments are stripped")
eq(G.load("/nonexistent/glossary.txt"), [], "a missing file is not fatal")
# Regression: the terms are Czech, so reading them under the ambient locale meant a non-UTF-8 LANG
# produced an EMPTY glossary rather than an error anyone would notice.
truthy(G.load.all? { |t| t.encoding == Encoding::UTF_8 }, "terms are read as UTF-8 whatever the locale")
truthy(G.load.any? { |t| t.match?(/[ěščřžýáíéúůňťď]/i) }, "and the accented terms actually survive loading")
puts "the shipped list contains no ordinary-word landmines"
# If someone adds a common word later, this is the test that should start failing.
%w[balíček zmeškaný hovor adresa problém].each do |common|
falsy(G.load.any? { |t| G.send(:near_miss?, common, t) || G.fold(t) == G.fold(common) },
"#{common} is not within reach of any shipped term")
end
puts
puts "#{$pass} passed, #{$fail} failed"
exit($fail.zero? ? 0 : 1)