fix(stt): anchor callsign digits verbatim, fuzzy only the airline name

The whole-string fuzzy fallback in callsignMatches() ran before the
digit-anchored check and let single-digit typos slip through (e.g.
"Lufthansa 350" matched expected "DLH359" at Levenshtein distance 1).
Spoken-digit-word candidates ("three five niner") bypassed the digit
anchor entirely since they contain no digit characters pre-fold.

Fold candidates through denormalizeSpokenAtc before checking for
digits, then require the flight number verbatim (word-boundary regex,
zero tolerance) while keeping the existing ~25% fuzzy tolerance on the
airline-name portion. Single-digit flight numbers (DLH4) now get the
same anchoring as longer ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-14 17:47:11 +02:00
parent 469912a9c2
commit 44b1f28bc4
2 changed files with 89 additions and 16 deletions

View File

@@ -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.

View File

@@ -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(