mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user