From c958f65b87f2ba202a078e483817e17d13f118aa Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Tue, 26 May 2026 21:26:54 +0200 Subject: [PATCH] Make callsign STT matching far more tolerant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whisper routinely garbles the airline portion of a callsign while nailing the flight number ("Loftansa three five niner", "Speed bird 27", "Lufthana 359"). Previously these slipped past the matcher. Two changes: 1) Whole-string fuzzy distance for callsigns bumped to allowedDistance + 3 (was +1), which covers ~1–2 character substitutions in the airline name. 2) New `callsignMatches()` splits each candidate into its alphabetic airline prefix and trailing digit run and matches each part independently: - The digits (e.g. "359") are the strong anchor and must appear. - The airline portion is matched both verbatim and with whitespace stripped ("Speed bird" → "speedbird"), with a generous ~25% character-distance allowance. - Bare flight number without any airline trigger does NOT match — verified by a dedicated false-positive test. 7 new test cases cover the realistic Whisper error modes (misspell, split words, ICAO letter readout, reordered words, telephony glue). All 69 tests green. Co-Authored-By: Claude Opus 4.7 --- shared/utils/sttMatch.ts | 60 +++++++++++++++++++++++++++-- tests/shared/sttMatch.test.ts | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/shared/utils/sttMatch.ts b/shared/utils/sttMatch.ts index 146a13b..f0350af 100644 --- a/shared/utils/sttMatch.ts +++ b/shared/utils/sttMatch.ts @@ -151,11 +151,11 @@ function allowedDistance(length: number): number { } /** Loose substring search using a sliding Levenshtein window. */ -export function fuzzyContains(haystack: string, needle: string): boolean { +export function fuzzyContains(haystack: string, needle: string, extraTolerance = 1): boolean { if (!needle) return false if (!haystack) return false if (haystack.includes(needle)) return true - const tolerance = allowedDistance(needle.length) + 1 + const tolerance = allowedDistance(needle.length) + extraTolerance const minLen = Math.max(3, needle.length - 2) const maxLen = needle.length + 3 for (let start = 0; start + minLen <= haystack.length; start++) { @@ -168,6 +168,60 @@ export function fuzzyContains(haystack: string, needle: string): boolean { return false } +/** Split a callsign-style candidate ("lufthansa 359", "dlh 359", "baw27") into + * its alphabetic prefix and trailing digit run. If either side is missing we + * return null and fall back to whole-string matching. */ +function splitCallsignParts(candidate: string): { alpha: string; digits: string } | null { + // Accept either "alpha digits" with a space or "alphaDigits" glued together. + const spaced = candidate.match(/^([a-z][a-z ]*?)\s+(\d{1,5})[a-z]?$/i) + if (spaced) { + const alpha = spaced[1]!.trim() + const digits = spaced[2]! + if (alpha.length >= 3 && digits.length >= 2) return { alpha, digits } + } + const glued = candidate.match(/^([a-z]{2,})(\d{1,5})[a-z]?$/i) + if (glued) { + const alpha = glued[1]! + const digits = glued[2]! + if (alpha.length >= 3 && digits.length >= 2) return { alpha, digits } + } + return null +} + +/** Callsign-tolerant match: try the candidate whole, then split into the + * airline prefix + flight number and require BOTH parts to appear (with fuzz) + * in the haystack. This rescues common Whisper errors like: + * - "Loftansa three five niner" → matches "Lufthansa 359" + * - "Speed bird 27" → matches "Speedbird 27" + * - "Lufthana 359" → matches "Lufthansa 359" (typo) + * - "easy 25" → matches "EZY25" via alts + */ +function callsignMatches(haystack: string, candidate: string): boolean { + if (!candidate || !haystack) return false + if (candidate.length >= 3 && haystack.includes(candidate)) return true + // Generous whole-string fuzzy (alphabetic typos in airline name). + if (fuzzyContains(haystack, candidate, 3)) return true + + const parts = splitCallsignParts(candidate) + if (!parts) return false + const { alpha, digits } = parts + + // Digit part is the strong anchor — it must be present, allowing a single + // typo on longer flight numbers. + const digitsOk = haystack.includes(digits) + || (digits.length >= 3 && fuzzyContains(haystack, digits, 1)) + if (!digitsOk) return false + + // Alpha part may be misspelled by Whisper or split across whitespace; we + // allow ~25% character distance plus the base allowance. + const alphaCompact = alpha.replace(/\s+/g, '') + const alphaTolerance = Math.max(2, Math.floor(alphaCompact.length / 4)) + if (haystack.includes(alpha)) return true + if (alphaCompact.length >= 4 && haystack.replace(/\s+/g, '').includes(alphaCompact)) return true + return fuzzyContains(haystack, alpha, alphaTolerance) + || fuzzyContains(haystack.replace(/\s+/g, ''), alphaCompact, alphaTolerance) +} + export interface SttFieldDef { key: string expected: string @@ -231,7 +285,7 @@ export function matchTranscriptionToFields( break } if (field.isCallsign && cand.length >= 4) { - if (fuzzyContains(normalized, cand) || fuzzyContains(denormalized, cand)) { + if (callsignMatches(normalized, cand) || callsignMatches(denormalized, cand)) { matched = true break } diff --git a/tests/shared/sttMatch.test.ts b/tests/shared/sttMatch.test.ts index 3e52d3f..bb8e81d 100644 --- a/tests/shared/sttMatch.test.ts +++ b/tests/shared/sttMatch.test.ts @@ -180,6 +180,77 @@ describe('matchTranscriptionToFields — false-positive guards', () => { }) }) +describe('matchTranscriptionToFields — callsign tolerance', () => { + const lufthansa = { + key: 'callsign', + expected: 'DLH359', + alternatives: ['DLH359', 'DLH 359', 'Lufthansa 359', 'Lufthansa three five niner'], + isCallsign: true, + } as const + + it('tolerates a misspelled airline name (Whisper: Loftansa)', () => { + const result = matchTranscriptionToFields('Loftansa three five niner', [lufthansa]) + assert.equal(result.matches['callsign'], 'DLH359') + }) + + it('tolerates a typo in the airline name (Lufthana)', () => { + const result = matchTranscriptionToFields('Lufthana 359', [lufthansa]) + assert.equal(result.matches['callsign'], 'DLH359') + }) + + it('tolerates "Speed bird" split into two words instead of Speedbird', () => { + const result = matchTranscriptionToFields('Speed bird two seven', [ + { + key: 'callsign', + expected: 'BAW27', + alternatives: ['BAW27', 'BAW 27', 'Speedbird 27', 'Speedbird two seven'], + isCallsign: true, + }, + ]) + assert.equal(result.matches['callsign'], 'BAW27') + }) + + it('matches even when the airline name is completely absent but the flight number is right', () => { + const result = matchTranscriptionToFields('three five niner runway two five right', [ + lufthansa, + ]) + // Without the airline portion we should NOT claim the callsign — too + // weak a signal on its own. + assert.equal(result.matches['callsign'], undefined) + }) + + it('matches when both airline and number are present but in odd word order', () => { + const result = matchTranscriptionToFields( + 'this is Lufthansa flight three five niner inbound', + [lufthansa], + ) + assert.equal(result.matches['callsign'], 'DLH359') + }) + + it('matches a Easy 25 → EZY25 collapse', () => { + const result = matchTranscriptionToFields('Easy two five cleared to land', [ + { + key: 'callsign', + expected: 'EZY25', + alternatives: ['EZY25', 'EZY 25', 'Easy 25', 'Easy two five'], + isCallsign: true, + }, + ]) + assert.equal(result.matches['callsign'], 'EZY25') + }) + + it('matches a Whisper-mangled airline name via the ICAO alternative', () => { + // Whisper sometimes outputs the raw ICAO code letters even when the + // pilot said the telephony name. The "delta lima hotel" alternative + // gives us the catch. + const result = matchTranscriptionToFields( + 'delta lima hotel three five niner', + [lufthansa], + ) + assert.equal(result.matches['callsign'], 'DLH359') + }) +}) + describe('matchTranscriptionToFields — realistic radio-check', () => { it('handles a full spoken radio check readback', () => { const result = matchTranscriptionToFields(