mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-12 20:55:41 +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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split a callsign-style candidate ("lufthansa 359", "dlh 359", "baw27") into
|
/** Split a callsign-style candidate ("lufthansa 359", "dlh 359", "baw27",
|
||||||
* its alphabetic prefix and trailing digit run. If either side is missing we
|
* "dlh4") into its alphabetic prefix and trailing digit run. Returns null
|
||||||
* return null and fall back to whole-string matching. */
|
* 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 {
|
function splitCallsignParts(candidate: string): { alpha: string; digits: string } | null {
|
||||||
// Accept either "alpha digits" with a space or "alphaDigits" glued together.
|
// 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)
|
const spaced = candidate.match(/^([a-z][a-z ]*?)\s+(\d{1,5})[a-z]?$/i)
|
||||||
if (spaced) {
|
if (spaced) {
|
||||||
const alpha = spaced[1]!.trim()
|
const alpha = spaced[1]!.trim()
|
||||||
const digits = spaced[2]!
|
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)
|
const glued = candidate.match(/^([a-z]{2,})(\d{1,5})[a-z]?$/i)
|
||||||
if (glued) {
|
if (glued) {
|
||||||
const alpha = glued[1]!
|
const alpha = glued[1]!
|
||||||
const digits = glued[2]!
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Callsign-tolerant match: try the candidate whole, then split into the
|
/** Callsign-tolerant match: split the candidate into airline prefix + flight
|
||||||
* airline prefix + flight number and require BOTH parts to appear (with fuzz)
|
* number, require the flight number VERBATIM and only fuzz the airline name.
|
||||||
* in the haystack. This rescues common Whisper errors like:
|
* This rescues common Whisper errors like:
|
||||||
* - "Loftansa three five niner" → matches "Lufthansa 359"
|
* - "Loftansa three five niner" → matches "Lufthansa 359"
|
||||||
* - "Speed bird 27" → matches "Speedbird 27"
|
* - "Speed bird 27" → matches "Speedbird 27"
|
||||||
* - "Lufthana 359" → matches "Lufthansa 359" (typo)
|
* - "Lufthana 359" → matches "Lufthansa 359" (typo)
|
||||||
* - "easy 25" → matches "EZY25" via alts
|
* - "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 {
|
function callsignMatches(haystack: string, candidate: string): boolean {
|
||||||
if (!candidate || !haystack) return false
|
if (!candidate || !haystack) return false
|
||||||
if (candidate.length >= 3 && haystack.includes(candidate)) return true
|
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
|
if (!parts) return false
|
||||||
const { alpha, digits } = parts
|
const { alpha, digits } = parts
|
||||||
|
|
||||||
// Digit part is the strong anchor — it must be present, allowing a single
|
// The flight number is the identity of the flight — one wrong digit means a
|
||||||
// typo on longer flight numbers.
|
// different aircraft. Require it verbatim, with non-digit boundaries so
|
||||||
const digitsOk = haystack.includes(digits)
|
// "359" cannot hit inside "3590". No fuzzy tolerance on digits, ever.
|
||||||
|| (digits.length >= 3 && fuzzyContains(haystack, digits, 1))
|
if (!new RegExp(`(^|\\D)${digits}(\\D|$)`).test(haystack)) return false
|
||||||
if (!digitsOk) return false
|
|
||||||
|
|
||||||
// Alpha part may be misspelled by Whisper or split across whitespace; we
|
// Alpha part may be misspelled by Whisper or split across whitespace; we
|
||||||
// allow ~25% character distance plus the base allowance.
|
// 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', () => {
|
describe('matchTranscriptionToFields — realistic radio-check', () => {
|
||||||
it('handles a full spoken radio check readback', () => {
|
it('handles a full spoken radio check readback', () => {
|
||||||
const result = matchTranscriptionToFields(
|
const result = matchTranscriptionToFields(
|
||||||
|
|||||||
Reference in New Issue
Block a user