Make callsign STT matching far more tolerant

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 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-05-26 21:26:54 +02:00
parent e87e6b79ac
commit c958f65b87
2 changed files with 128 additions and 3 deletions

View File

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

View File

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