diff --git a/shared/utils/sttMatch.ts b/shared/utils/sttMatch.ts index 012efff..7f24924 100644 --- a/shared/utils/sttMatch.ts +++ b/shared/utils/sttMatch.ts @@ -168,49 +168,64 @@ export function fuzzyContains(haystack: string, needle: string, extraTolerance = 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. */ +/** Split a callsign-style candidate ("lufthansa 359", "dlh 359", "baw27", + * "dlh4") into its alphabetic prefix and trailing digit run. Returns null + * when the candidate doesn't have that shape — the caller then refuses to + * fuzzy-match it if it carries digits (an unanchored flight number must + * never ride on string-wide tolerance). */ function splitCallsignParts(candidate: string): { alpha: string; digits: string } | null { // Accept either "alpha digits" with a space or "alphaDigits" glued together. + // Single-digit flight numbers ("DLH4") are valid callsigns and need the same + // strict digit anchoring as longer ones. 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 } + if (alpha.length >= 3 && digits.length >= 1) 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 } + if (alpha.length >= 3 && digits.length >= 1) 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: +/** Callsign-tolerant match: split the candidate into airline prefix + flight + * number, require the flight number VERBATIM and only fuzz the airline name. + * 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 + * while refusing digit confusions ("Lufthansa 350" is a DIFFERENT flight and + * must never fuzzy-match "Lufthansa 359"). Fuzzy tolerance therefore never + * spans the digits: a candidate that carries a flight number either anchors + * it exactly in the haystack or doesn't match at all. */ 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) + // Fold spoken digit words ("three five niner" → "359") so word-form + // alternatives face the same strict digit anchoring as written ones — + // otherwise they contain no digit characters and would dodge it entirely. + const folded = normalizeForMatch(denormalizeSpokenAtc(candidate)) + const effective = /\d/.test(folded) ? folded : candidate + + // No digits anywhere: pure-alpha candidates may use the generous + // whole-string fuzzy — there is no flight number to confuse. + if (!/\d/.test(effective)) return fuzzyContains(haystack, candidate, 3) + + const parts = splitCallsignParts(effective) 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 + // The flight number is the identity of the flight — one wrong digit means a + // different aircraft. Require it verbatim, with non-digit boundaries so + // "359" cannot hit inside "3590". No fuzzy tolerance on digits, ever. + if (!new RegExp(`(^|\\D)${digits}(\\D|$)`).test(haystack)) return false // Alpha part may be misspelled by Whisper or split across whitespace; we // allow ~25% character distance plus the base allowance. diff --git a/tests/shared/sttMatch.test.ts b/tests/shared/sttMatch.test.ts index 844b62f..e5aef19 100644 --- a/tests/shared/sttMatch.test.ts +++ b/tests/shared/sttMatch.test.ts @@ -276,6 +276,64 @@ describe('matchTranscriptionToFields — callsign tolerance', () => { }) }) +describe('matchTranscriptionToFields — callsign digit strictness', () => { + // A single wrong digit is a DIFFERENT flight. The generous fuzzy tolerance + // may only ever forgive the airline name, never the flight number. + const lufthansa = { + key: 'callsign', + expected: 'DLH359', + alternatives: ['DLH359', 'DLH 359', 'Lufthansa 359', 'Lufthansa three five niner'], + isCallsign: true, + } as const + + it('does not match a flight number that differs in the last digit (spoken form)', () => { + // Whisper heard "three five zero" — that is DLH350, not DLH359. + const result = matchTranscriptionToFields('Lufthansa three five zero', [lufthansa]) + assert.equal(result.matches['callsign'], undefined) + }) + + it('does not match a flight number that differs in the last digit (written form)', () => { + const result = matchTranscriptionToFields('Lufthansa 350', [lufthansa]) + assert.equal(result.matches['callsign'], undefined) + }) + + it('does not match transposed flight-number digits (271 vs spoken 217)', () => { + const result = matchTranscriptionToFields('Speedbird two one seven', [ + { + key: 'callsign', + expected: 'BAW271', + alternatives: ['BAW271', 'BAW 271', 'Speedbird 271', 'Speedbird two seven one'], + isCallsign: true, + }, + ]) + assert.equal(result.matches['callsign'], undefined) + }) + + it('matches a single-digit flight number despite an airline-name typo', () => { + const result = matchTranscriptionToFields('Lufthana four', [ + { + key: 'callsign', + expected: 'DLH4', + alternatives: ['DLH4', 'DLH 4', 'Lufthansa 4', 'Lufthansa four'], + isCallsign: true, + }, + ]) + assert.equal(result.matches['callsign'], 'DLH4') + }) + + it('does not match a single-digit flight number with the wrong digit', () => { + const result = matchTranscriptionToFields('Lufthansa five', [ + { + key: 'callsign', + expected: 'DLH4', + alternatives: ['DLH4', 'DLH 4', 'Lufthansa 4', 'Lufthansa four'], + isCallsign: true, + }, + ]) + assert.equal(result.matches['callsign'], undefined) + }) +}) + describe('matchTranscriptionToFields — realistic radio-check', () => { it('handles a full spoken radio check readback', () => { const result = matchTranscriptionToFields(