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