From 54e5029bc2b0ba282b7143cda156d8cda89fcdc7 Mon Sep 17 00:00:00 2001 From: leubeem Date: Mon, 29 Jun 2026 16:02:07 +0200 Subject: [PATCH] feat(pm): say-again, sign-off, readback-correct, drop stale transmissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- app/pages/pm.vue | 56 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 99eb150..b0ea726 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -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 (1–5). */ const READABILITY_PHRASE: Record = { 5: 'read you 5, loud and clear',