mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
feat(sim-control): add fail-closed intent parser for frequency-driven sim setup
Roadmap item "frequency-sim-control": pilot speaks a self-service
sim setup command on frequency ("set me up for an approach from 5000
ft to EDDF 07R", "change my altitude to 8000"). Rule-based, not an
LLM call per transmission — a misparsed command changes the user's
real sim state, and it must never be confused with real ATC
phraseology routed through sttMatch.ts.
parseSimControl() gates on explicit self-service anchors first
("set me up", "put me", "change/set my <param>"); anything without
one of those anchors returns no_intent, so regular readbacks and
clearances can never be misrouted. Matched intents still refuse on
any ambiguous slot (missing unit, out-of-range, invalid runway) with
a typed reason instead of guessing.
Parameter vocabulary follows NormalizedTelemetry (altitude_ft,
ias_kts, heading_deg) since the command travels toward the bridge/sim,
not the decision engine's DecisionNodeAutoTrigger vocabulary.
Design doc covers the still-missing server→bridge write channel
(piggyback on the existing telemetry POST response) — not
implemented yet, this commit is the parser only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
167
shared/utils/simControl.ts
Normal file
167
shared/utils/simControl.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// Frequency-driven simulator control (roadmap key `frequency-sim-control`).
|
||||
//
|
||||
// Parses free-form SIM SETUP commands the pilot speaks on frequency ("set me
|
||||
// up for an approach from 5000 ft to EDDF 07R", "change my altitude to 8000")
|
||||
// into structured commands for the bridge. These are meta commands from the
|
||||
// user to their OWN simulator instance — deliberately a different grammar from
|
||||
// ICAO readback phraseology (which runs through sttMatch.ts) so an ordinary
|
||||
// ATC readback can never be mistaken for a sim command.
|
||||
//
|
||||
// Fail-closed by design: anything ambiguous returns { matched: false } with a
|
||||
// reason instead of a best-effort guess — a misparsed command changes the
|
||||
// user's real sim state. Parameter names follow the NormalizedTelemetry
|
||||
// convention (useRadioBackend.ts): the command travels toward the bridge/sim,
|
||||
// so it speaks the sim-side vocabulary (altitude_ft, ias_kts, heading_deg).
|
||||
|
||||
import { denormalizeSpokenAtc, normalizeForMatch } from './sttMatch'
|
||||
|
||||
export type SimControlCommand =
|
||||
| { type: 'set_altitude'; altitude_ft: number }
|
||||
| { type: 'set_heading'; heading_deg: number }
|
||||
| { type: 'set_speed'; ias_kts: number }
|
||||
| {
|
||||
type: 'setup_approach'
|
||||
/** Uppercase 4-letter ICAO ("EDDF"). Existence is the caller's check. */
|
||||
airport_icao: string
|
||||
/** "07R", "26L", "23" — zero-padded number 01–36 + optional L/R/C. */
|
||||
runway: string
|
||||
altitude_ft?: number
|
||||
final_distance_nm?: number
|
||||
}
|
||||
|
||||
export type SimControlNoMatchReason =
|
||||
| 'no_intent'
|
||||
| 'missing_value'
|
||||
| 'missing_unit'
|
||||
| 'out_of_range'
|
||||
| 'invalid_runway'
|
||||
| 'missing_runway'
|
||||
| 'missing_airport'
|
||||
|
||||
export type SimControlParseResult =
|
||||
| { matched: true; command: SimControlCommand; text: string }
|
||||
| { matched: false; reason: SimControlNoMatchReason; text: string }
|
||||
|
||||
/** Hard value ranges; anything outside is a refusal, never a clamp. */
|
||||
export const SIM_CONTROL_LIMITS = {
|
||||
altitude_ft: { min: 0, max: 45000 },
|
||||
heading_deg: { min: 1, max: 360 },
|
||||
ias_kts: { min: 60, max: 400 },
|
||||
final_distance_nm: { min: 1, max: 30 },
|
||||
} as const
|
||||
|
||||
// 4-letter English words that can follow "to/at/for" in an approach phrase
|
||||
// and must never be mistaken for an ICAO code.
|
||||
const ICAO_STOPWORDS = new Set([
|
||||
'left', 'right', 'feet', 'final', 'mile', 'nautical', 'from', 'with', 'land',
|
||||
])
|
||||
|
||||
function noMatch(reason: SimControlNoMatchReason, text: string): SimControlParseResult {
|
||||
return { matched: false, reason, text }
|
||||
}
|
||||
|
||||
function inRange(value: number, limit: { min: number; max: number }): boolean {
|
||||
return value >= limit.min && value <= limit.max
|
||||
}
|
||||
|
||||
/** "flight level 100" / "fl100" → feet, or null when no FL phrase is present. */
|
||||
function flightLevelFeet(fragment: string): number | null {
|
||||
const fl = fragment.match(/\b(?:flight level|fl) ?(\d{1,3})\b/)
|
||||
return fl ? Number(fl[1]) * 100 : null
|
||||
}
|
||||
|
||||
function parseApproach(text: string): SimControlParseResult {
|
||||
// Runway: keyword form ("runway 26 l" → "runway 26l" after denormalize) or a
|
||||
// bare suffixed token ("25r"). A bare number WITHOUT suffix is only accepted
|
||||
// behind the "runway" keyword — a lone "25" in the sentence is too ambiguous.
|
||||
const keyword = text.match(/\brunway (\d{1,2}) ?([lrc])?\b/)
|
||||
const bareToken = text.match(/(?:^|\s)(\d{2})([lrc])(?:\s|$)/)
|
||||
const rwNumRaw = keyword?.[1] ?? bareToken?.[1]
|
||||
const rwSuffix = (keyword ? keyword[2] : bareToken?.[2]) ?? ''
|
||||
if (!rwNumRaw) return noMatch('missing_runway', text)
|
||||
const rwNum = Number(rwNumRaw)
|
||||
if (rwNum < 1 || rwNum > 36) return noMatch('invalid_runway', text)
|
||||
const runway = `${rwNumRaw.padStart(2, '0')}${rwSuffix.toUpperCase()}`
|
||||
|
||||
const airport = text.match(/\b(?:to|at|for|into) ([a-z]{4})\b/)
|
||||
if (!airport || ICAO_STOPWORDS.has(airport[1]!)) return noMatch('missing_airport', text)
|
||||
const airport_icao = airport[1]!.toUpperCase()
|
||||
|
||||
// Altitude is optional, but WHEN a "from <number>" is present it must carry
|
||||
// an explicit unit (feet/ft) or be a flight level — a bare "from 5000" is
|
||||
// ambiguous and refused rather than guessed.
|
||||
let altitude_ft: number | undefined
|
||||
const fromFl = text.match(/\bfrom (?:flight level|fl) ?(\d{1,3})\b/)
|
||||
const fromFt = text.match(/\bfrom (\d{3,5}) ?(?:feet|ft)\b/)
|
||||
if (fromFl) altitude_ft = Number(fromFl[1]) * 100
|
||||
else if (fromFt) altitude_ft = Number(fromFt[1])
|
||||
else if (/\bfrom \d/.test(text)) return noMatch('missing_unit', text)
|
||||
if (altitude_ft !== undefined && !inRange(altitude_ft, SIM_CONTROL_LIMITS.altitude_ft)) {
|
||||
return noMatch('out_of_range', text)
|
||||
}
|
||||
|
||||
let final_distance_nm: number | undefined
|
||||
const dist = text.match(/\b(\d{1,2}) ?(?:miles?|nm|nautical miles?) final\b/)
|
||||
if (dist) {
|
||||
final_distance_nm = Number(dist[1])
|
||||
if (!inRange(final_distance_nm, SIM_CONTROL_LIMITS.final_distance_nm)) {
|
||||
return noMatch('out_of_range', text)
|
||||
}
|
||||
}
|
||||
|
||||
const command: SimControlCommand = { type: 'setup_approach', airport_icao, runway }
|
||||
if (altitude_ft !== undefined) command.altitude_ft = altitude_ft
|
||||
if (final_distance_nm !== undefined) command.final_distance_nm = final_distance_nm
|
||||
return { matched: true, command, text }
|
||||
}
|
||||
|
||||
function parseParameter(
|
||||
parameter: 'altitude' | 'heading' | 'speed',
|
||||
rest: string,
|
||||
text: string,
|
||||
): SimControlParseResult {
|
||||
if (parameter === 'altitude') {
|
||||
// "altitude" names the unit context, so a bare number is unambiguous here
|
||||
// (this is the literal roadmap wording "change my altitude to 8000").
|
||||
const fl = flightLevelFeet(rest)
|
||||
const num = fl ?? Number(rest.match(/\b(\d{1,6})\b/)?.[1] ?? NaN)
|
||||
if (!Number.isFinite(num)) return noMatch('missing_value', text)
|
||||
if (!inRange(num, SIM_CONTROL_LIMITS.altitude_ft)) return noMatch('out_of_range', text)
|
||||
return { matched: true, command: { type: 'set_altitude', altitude_ft: num }, text }
|
||||
}
|
||||
if (parameter === 'heading') {
|
||||
const num = Number(rest.match(/\b(\d{1,3})\b/)?.[1] ?? NaN)
|
||||
if (!Number.isFinite(num)) return noMatch('missing_value', text)
|
||||
if (!inRange(num, SIM_CONTROL_LIMITS.heading_deg)) return noMatch('out_of_range', text)
|
||||
return { matched: true, command: { type: 'set_heading', heading_deg: num }, text }
|
||||
}
|
||||
const num = Number(rest.match(/\b(\d{2,3})\b/)?.[1] ?? NaN)
|
||||
if (!Number.isFinite(num)) return noMatch('missing_value', text)
|
||||
if (!inRange(num, SIM_CONTROL_LIMITS.ias_kts)) return noMatch('out_of_range', text)
|
||||
return { matched: true, command: { type: 'set_speed', ias_kts: num }, text }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a transmission into a sim-control command, or refuse.
|
||||
*
|
||||
* The intent gate is intentionally narrow: only explicit self-service anchors
|
||||
* ("set me up …", "put me …", "reposition …" for approaches; "change/set my
|
||||
* <parameter> …" for single values) enter parsing at all. Regular phraseology
|
||||
* ("descend to 5000 feet", "request descent …", "cleared to land …") carries
|
||||
* none of these anchors and always returns `no_intent` — that property is the
|
||||
* safety contract of this module and is covered by tests.
|
||||
*/
|
||||
export function parseSimControl(input: string): SimControlParseResult {
|
||||
const text = normalizeForMatch(denormalizeSpokenAtc(input))
|
||||
if (!text) return noMatch('no_intent', text)
|
||||
|
||||
const wantsApproach = /\b(?:set me up|put me|reposition(?: me)?)\b.*\b(?:approach|final)\b/.test(text)
|
||||
if (wantsApproach) return parseApproach(text)
|
||||
|
||||
const parameter = text.match(/\b(?:change|set) my (altitude|heading|speed)\b(.*)$/)
|
||||
if (parameter) {
|
||||
return parseParameter(parameter[1] as 'altitude' | 'heading' | 'speed', parameter[2] ?? '', text)
|
||||
}
|
||||
|
||||
return noMatch('no_intent', text)
|
||||
}
|
||||
148
tests/shared/simControl.test.ts
Normal file
148
tests/shared/simControl.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { parseSimControl } from '~~/shared/utils/simControl'
|
||||
|
||||
function expectCommand(input: string) {
|
||||
const result = parseSimControl(input)
|
||||
assert.equal(result.matched, true, `expected a command for "${input}", got ${JSON.stringify(result)}`)
|
||||
return (result as Extract<ReturnType<typeof parseSimControl>, { matched: true }>).command
|
||||
}
|
||||
|
||||
function expectNoMatch(input: string, reason?: string) {
|
||||
const result = parseSimControl(input)
|
||||
assert.equal(result.matched, false, `expected NO match for "${input}", got ${JSON.stringify(result)}`)
|
||||
if (reason) {
|
||||
assert.equal((result as Extract<ReturnType<typeof parseSimControl>, { matched: false }>).reason, reason)
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseSimControl — approach setup', () => {
|
||||
it('parses the canonical roadmap utterance (written form)', () => {
|
||||
const cmd = expectCommand('set me up for an approach from 5000 ft to EDDF 07R')
|
||||
assert.deepEqual(cmd, {
|
||||
type: 'setup_approach',
|
||||
airport_icao: 'EDDF',
|
||||
runway: '07R',
|
||||
altitude_ft: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses the same utterance in fully spoken Whisper form', () => {
|
||||
const cmd = expectCommand(
|
||||
'set me up for an approach from five thousand feet to echo delta delta foxtrot zero seven right',
|
||||
)
|
||||
assert.deepEqual(cmd, {
|
||||
type: 'setup_approach',
|
||||
airport_icao: 'EDDF',
|
||||
runway: '07R',
|
||||
altitude_ft: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses an approach without altitude ("runway 26 left" wording)', () => {
|
||||
const cmd = expectCommand('set me up for an approach to EDDM runway 26 left')
|
||||
assert.deepEqual(cmd, {
|
||||
type: 'setup_approach',
|
||||
airport_icao: 'EDDM',
|
||||
runway: '26L',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a final with a distance ("5 mile final")', () => {
|
||||
const cmd = expectCommand('put me on a 5 mile final for EDDF 25R')
|
||||
assert.deepEqual(cmd, {
|
||||
type: 'setup_approach',
|
||||
airport_icao: 'EDDF',
|
||||
runway: '25R',
|
||||
final_distance_nm: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a flight-level altitude in an approach setup', () => {
|
||||
const cmd = expectCommand('set me up for an approach from flight level 100 to EDDL runway 23')
|
||||
assert.deepEqual(cmd, {
|
||||
type: 'setup_approach',
|
||||
airport_icao: 'EDDL',
|
||||
runway: '23',
|
||||
altitude_ft: 10000,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an approach altitude without a unit (ambiguous)', () => {
|
||||
expectNoMatch('set me up for an approach from 5000 to EDDF 07R', 'missing_unit')
|
||||
})
|
||||
|
||||
it('rejects an invalid runway number (40)', () => {
|
||||
expectNoMatch('set me up for an approach to EDDF runway 40', 'invalid_runway')
|
||||
})
|
||||
|
||||
it('rejects an approach without a runway', () => {
|
||||
expectNoMatch('set me up for an approach to EDDF', 'missing_runway')
|
||||
})
|
||||
|
||||
it('rejects an approach without an airport', () => {
|
||||
expectNoMatch('set me up for an approach runway 25', 'missing_airport')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSimControl — single-parameter changes', () => {
|
||||
it('parses the roadmap altitude wording (bare number is OK — "altitude" names the unit context)', () => {
|
||||
const cmd = expectCommand('change my altitude to 8000')
|
||||
assert.deepEqual(cmd, { type: 'set_altitude', altitude_ft: 8000 })
|
||||
})
|
||||
|
||||
it('parses an altitude given as flight level', () => {
|
||||
const cmd = expectCommand('set my altitude to flight level 100')
|
||||
assert.deepEqual(cmd, { type: 'set_altitude', altitude_ft: 10000 })
|
||||
})
|
||||
|
||||
it('parses a heading change', () => {
|
||||
const cmd = expectCommand('change my heading to 270')
|
||||
assert.deepEqual(cmd, { type: 'set_heading', heading_deg: 270 })
|
||||
})
|
||||
|
||||
it('parses a speed change with unit', () => {
|
||||
const cmd = expectCommand('set my speed to 210 knots')
|
||||
assert.deepEqual(cmd, { type: 'set_speed', ias_kts: 210 })
|
||||
})
|
||||
|
||||
it('parses spoken digits ("change my heading to two seven zero")', () => {
|
||||
const cmd = expectCommand('change my heading to two seven zero')
|
||||
assert.deepEqual(cmd, { type: 'set_heading', heading_deg: 270 })
|
||||
})
|
||||
|
||||
it('rejects a parameter change without a value', () => {
|
||||
expectNoMatch('change my altitude', 'missing_value')
|
||||
})
|
||||
|
||||
it('rejects an out-of-range altitude', () => {
|
||||
expectNoMatch('change my altitude to 80000 feet', 'out_of_range')
|
||||
})
|
||||
|
||||
it('rejects an out-of-range heading', () => {
|
||||
expectNoMatch('change my heading to 370', 'out_of_range')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSimControl — must NEVER match ATC dialogue', () => {
|
||||
it('does not match a plain ATC readback ("descend to 5000 feet")', () => {
|
||||
expectNoMatch('descend to 5000 feet', 'no_intent')
|
||||
})
|
||||
|
||||
it('does not match a pilot request ("request descent to flight level 100")', () => {
|
||||
expectNoMatch('request descent to flight level 100', 'no_intent')
|
||||
})
|
||||
|
||||
it('does not match a clearance readback with runway and callsign', () => {
|
||||
expectNoMatch('cleared to land runway 25R Lufthansa 359', 'no_intent')
|
||||
})
|
||||
|
||||
it('does not match a squawk instruction ("set squawk 7000")', () => {
|
||||
expectNoMatch('set squawk 7000', 'no_intent')
|
||||
})
|
||||
|
||||
it('does not match an unanchored altitude mention ("set me up at 8000")', () => {
|
||||
expectNoMatch('set me up at 8000')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user