feat(pm): say-again, sign-off, readback-correct, drop stale transmissions

- "say again" / "repeat" → ATC re-speaks its last transmission locally (#8/#40)
- bare sign-off ("thank you", "tschüss", "good day" with no readback/digits)
  gets a polite "Good day" instead of being evaluated (#32)
- positive "Readback correct" confirmation prefixed when every required
  readback field matched, not only on the IFR clearance (#10)
- a newer pilot transmission supersedes an older one still awaiting its
  backend reply; the stale reply is dropped (#16)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leubeem
2026-06-29 16:02:07 +02:00
parent e21414e45a
commit 54e5029bc2

View File

@@ -3379,6 +3379,10 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
return
}
// Mark this as the latest transmission; a newer one started before the backend
// replies supersedes it (the stale reply is dropped below).
const myGen = ++transmitGeneration
const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot'
setLastTransmission(`${prefix}: ${transcript}`)
@@ -3402,6 +3406,31 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
return
}
// --- Say again ---
// Pilot asks ATC to repeat. Handled locally: re-speak the last controller
// transmission without advancing the flow.
if (SAY_AGAIN_RE.test(transcript) && lastControllerSay.value) {
const reply = `I say again. ${lastControllerSay.value}`
scheduleControllerSpeech(reply)
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
return
}
// --- Sign-off ---
// A bare goodbye / thank-you (no readback or request alongside): acknowledge
// politely instead of evaluating it as a transmission. Requires a farewell
// with no digits, no readback/acknowledgement words, and only a few words, so
// it never swallows a real call like "wilco, thanks" or a frequency readback.
const wordCount = transcript.split(/\s+/).filter(Boolean).length
const hasActionWord = /\b(wilco|roger|cleared|clear|runway|contact|ready|request|squawk|descend|climb|taxi|line\s?up|holding|hold short|approved|affirm|going|switching|push|startup)\b/i.test(transcript)
if (FAREWELL_RE.test(transcript) && !/\d/.test(transcript) && !hasActionWord && wordCount <= 6) {
const cs = (vars as any).value?.callsign ?? ''
const reply = cs ? `Good day, ${cs}.` : 'Good day.'
scheduleControllerSpeech(reply)
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
return
}
// --- Frequency check ---
// Reject the transmission if the pilot is on the wrong frequency. A position
// can publish several valid frequencies (e.g. two Tower freqs); accept any of
@@ -3448,6 +3477,13 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
try {
const response = await radioBackend.transmit(backendSessionId.value, transcript)
// Drop a stale reply if the pilot already transmitted again while this call
// was in flight — only the newest transmission's result is applied (#16).
if (myGen !== transmitGeneration) {
pmLog.info('Discarded stale transmission reply (superseded by a newer transmission)')
return
}
pmLog.group(`✓ RESPONSE ${currentState.value?.id ?? '?'}${response.next_state_id}`, () => {
pmLog.info('next_state :', response.next_state_id)
pmLog.info('auto_advanced :', response.auto_advanced_states ?? [])
@@ -3509,7 +3545,16 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
// TTS: use the pre-rendered string from the backend (correct variable values).
// Fall back to rendering the template locally if rendered is absent.
const sayText = response.controller_say_rendered || response.controller_say_template
let sayText = response.controller_say_rendered || response.controller_say_template
// Positive readback confirmation (#10): when every required readback field
// was matched, prefix "Readback correct" — unless the controller line is
// already a confirmation/correction — so it isn't only the IFR clearance
// that acknowledges a good readback.
const rb = response.readback_report ?? []
const readbackPassed = rb.length > 0 && rb.every((r: any) => r.matched)
if (sayText && readbackPassed && !/^\s*(readback correct|correct[,.\s]|negative|i say again)/i.test(sayText)) {
sayText = `Readback correct. ${sayText}`
}
if (sayText) {
pmLog.info('TTS →', sayText)
lastControllerSay.value = sayText
@@ -4550,6 +4595,15 @@ const playAtisBroadcast = async () => {
const RADIO_CHECK_RE = /\b(radio|signal)\s*check\b|how do you read/i
// Pilot asking ATC to repeat its last transmission.
const SAY_AGAIN_RE = /\b(say again|come again|repeat( that| your last)?)\b|please\s+say\s+again|pls\s+say\s+again/i
// Bare sign-off / pleasantry (no substantive content alongside).
const FAREWELL_RE = /\b(good\s?bye|bye[\s-]?bye|tsch(ü|ue)ss|servus|good day|cheerio|auf\s?wieder|thank you|thanks|danke)\b/i
// Monotonic counter so a newer pilot transmission supersedes an older one still
// awaiting its backend response — only the latest result is applied (#16).
let transmitGeneration = 0
/** Spoken readability per signal-strength level (15). */
const READABILITY_PHRASE: Record<number, string> = {
5: 'read you 5, loud and clear',