From 6f060d0ddd926e5d9a319309719f7c4d58e04f20 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Fri, 13 Feb 2026 08:50:02 +0100 Subject: [PATCH] fix pm --- .yarn/install-state.gz | Bin 1059437 -> 1059440 bytes CLAUDE.md | 30 +++++ app/pages/pm.vue | 53 ++++++++- server/utils/openai.ts | 97 ++++++++++++++-- shared/utils/communicationsEngine.ts | 161 ++++++++++++++++++++++++++- 5 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 CLAUDE.md diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index c1ead4929a671358a08ad3dac0f387add7dd8d2f..7288008ae93755466736b337351d8e2e990475bc 100644 GIT binary patch delta 11859 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 N{}11gHDyL^F92HVcS`^O delta 11857 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 + + +
+

Suggested phrases

+
+ + {{ phrase.text }} + +
+
+

For emergencies when PTT fails or for testing

@@ -997,7 +1016,9 @@ const { moveTo: forceMove, normalizeATCText, renderATCMessage, - getStateDetails + getStateDetails, + collectAtcStatesUntilPilotTurn, + expectedPilotPhrases, } = engine const lastTransmission = ref('') @@ -1892,9 +1913,27 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = applyLLMDecision(decision, normalizedTrace ?? null) + // If decision explicitly has controller_say_tpl, speak it if (decision.controller_say_tpl && !decision.radio_check) { scheduleControllerSpeech(decision.controller_say_tpl) } + + // Auto-advance through ATC/system states and speak any say_tpl messages. + // This is the key fix: after a decision moves us to a new state, we need + // to walk through all non-pilot states (ATC replies, system transitions) + // until we reach the next pilot state, speaking each ATC message via TTS. + if (!decision.radio_check) { + await nextTick() + const atcMessages = collectAtcStatesUntilPilotTurn() + for (const msg of atcMessages) { + // Don't double-speak if the decision already had controller_say_tpl + // for this exact template + if (decision.controller_say_tpl && msg.say_tpl === decision.controller_say_tpl) { + continue + } + scheduleControllerSpeech(msg.say_tpl) + } + } } catch (e) { console.error('LLM decision failed', e) setLastTransmission(`${prefix}: ${transcript} (LLM failed)`) @@ -1951,6 +1990,18 @@ const startMonitoring = async (flightPlan: any) => { } await fetchAirportFrequencies(flightPlan.dep || flightPlan.departure) + + // If the start state is an ATC state, auto-advance and speak its message + // so the pilot sees the first prompt immediately after connecting. + try { + await nextTick() + const startMessages = collectAtcStatesUntilPilotTurn() + for (const msg of startMessages) { + scheduleControllerSpeech(msg.say_tpl) + } + } catch (err) { + console.warn('Initial state advance failed:', err) + } } const startDemoFlight = () => { diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 0ed51df..a0e0005 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -175,6 +175,42 @@ function buildSpokenVariants(key: string, value: string): string[] { return Array.from(variants) } +/** + * Quick heuristic readback check: verifies that the pilot's utterance + * contains the required fields (dest, runway, squawk, etc.) by matching + * against spoken variants of the expected values. + * Returns 'ok' if all required fields are present, 'missing' with the + * list of missing keys otherwise. + */ +function quickReadbackCheck( + utterance: string, + readbackKeys: string[], + variables: Record +): { status: 'ok' | 'missing'; missing: string[] } { + if (!readbackKeys.length) return { status: 'ok', missing: [] } + + const sanitized = sanitizeForQuickMatch(utterance) + const missing: string[] = [] + + for (const key of readbackKeys) { + const expected = resolveReadbackValue(key, { variables } as any) + if (!expected) continue // Can't verify if no expected value + + const variants = buildSpokenVariants(key, expected) + const found = variants.some(variant => + sanitized.includes(sanitizeForQuickMatch(variant)) + ) + if (!found) { + missing.push(key) + } + } + + return { + status: missing.length === 0 ? 'ok' : 'missing', + missing, + } +} + function pickTransition( transitions: Array<{ to: string }> | undefined, candidates: Array<{ id: string; state: any }> @@ -529,13 +565,10 @@ async function prepareDecisionCandidates( } } - for (const [flowSlug, tree] of Object.entries(system.flows || {})) { - const startStateId = tree.start_state - if (!startStateId) continue - const indexed = index.get(startStateId) - const state = indexed?.state ? { ...indexed.state } : tree.states?.[startStateId] - addCandidate(startStateId, flowSlug, state) - } + // Note: Previously, all flow start states were added as candidates here. + // This was removed because it polluted the candidate pool and caused the + // LLM to pick unrelated flow starts. Flow switches should be defined via + // explicit transitions in the decision tree instead. const candidates = Array.from(candidateMap.values()) const context = { variables: input.variables || {}, flags: input.flags || {} } @@ -906,9 +939,21 @@ function extractJsonObject(text: string): any | null { } } +function buildDecisionObject(stateId: string, candidate: DecisionCandidate | undefined, index: Map): LLMDecisionResult['decision'] { + const decision: LLMDecisionResult['decision'] = { next_state: stateId } + // Attach the say_tpl from the chosen state so the frontend can speak it + // without an extra lookup. Checks the candidate first, then the runtime index. + const sayTpl = candidate?.state?.say_tpl ?? index.get(stateId)?.state?.say_tpl + if (sayTpl) { + decision.controller_say_tpl = sayTpl + } + return decision +} + export async function routeDecision(input: LLMDecisionInput): Promise { const utterance = (input.pilot_utterance || '').trim() const prepared = await prepareDecisionCandidates(input, utterance) + const { index } = await getRuntimeSystemIndex() const trace: LLMDecisionTrace = { calls: [], @@ -916,6 +961,34 @@ export async function routeDecision(input: LLMDecisionInput): Promise 0 && utterance) { + const check = quickReadbackCheck(utterance, readbackKeys, input.variables || {}) + if (check.status === 'missing' && check.missing.length > 0) { + // Readback incomplete — try to route to bad_next (repeat instruction) + const badTargets = (input.state?.bad_next ?? []).map((t: any) => t?.to).filter(Boolean) + const badCandidate = badTargets.length > 0 + ? prepared.candidateIndex.get(badTargets[0]) ?? null + : null + if (badCandidate) { + trace.autoSelection = { + id: badCandidate.id, + flow: badCandidate.flow, + reason: `Readback missing fields: ${check.missing.join(', ')}`, + } + return { + decision: buildDecisionObject(badCandidate.id, badCandidate, index), + trace, + pilot_intent: 'incomplete_readback', + } + } + } + } + if (prepared.autoSelected) { trace.autoSelection = { id: prepared.autoSelected.id, @@ -923,7 +996,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise ({ id, state: states.value[id], flow: runtime.slug })) .filter(candidate => candidate.state) + // "Look through" auto-behavior check states (e.g. CD_READBACK_CHECK). + // If ALL candidates are check_readback/monitor states, expand them to + // their ok_next + bad_next targets so the LLM can evaluate the pilot's + // readback directly and route to the correct outcome. + const allAutoCheck = candidates.length > 0 && candidates.every(c => + c.state.auto === 'check_readback' || c.state.auto === 'monitor' + ) + if (allAutoCheck) { + const expanded: typeof candidates = [] + const seen = new Set() + for (const c of candidates) { + const targets = [ + ...(c.state.ok_next ?? []), + ...(c.state.bad_next ?? []), + ...(c.state.next ?? []), + ] + for (const t of targets) { + if (!t?.to || seen.has(t.to)) continue + seen.add(t.to) + const targetState = states.value[t.to] + if (targetState) { + expanded.push({ id: t.to, state: targetState, flow: runtime.slug }) + } + } + } + if (expanded.length > 0) { + candidates = expanded + } + } + return { state_id: s.id, state: { ...s }, @@ -895,6 +925,133 @@ export default function useCommunicationsEngine() { return processPilotTransmission(transcript) } + /** + * After a decision lands on a state, walk forward through all non-pilot + * states (ATC replies, system checks, handoffs) collecting ATC messages + * that need TTS playback, until we reach the next pilot state. + * + * This is the core auto-advance mechanism for the live ATC flow. + * It handles: + * - ATC states with say_tpl (collect for TTS, advance) + * - System states (check_readback, monitor, etc) — advance via ok_next + * - Handoff states (advance automatically) + * - States with single unambiguous transitions + */ + function collectAtcStatesUntilPilotTurn(maxHops = 30): Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> { + const messages: Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> = [] + const visited = new Set() + let hops = 0 + + while (hops++ < maxHops) { + const s = currentState.value + if (!s) break + + // Prevent infinite loops + if (visited.has(s.id)) break + visited.add(s.id) + + // If we're on a pilot state, stop — it's the pilot's turn to speak + if (s.role === 'pilot') break + + // If this is an end state, stop + const endStates = tree.value?.end_states ?? [] + if (endStates.includes(s.id)) break + + // Collect ATC/system messages for TTS + if (s.say_tpl) { + messages.push({ + stateId: s.id, + say_tpl: s.say_tpl, + rendered: renderTpl(s.say_tpl, exposeCtx()), + normalized: normalizeATCText(s.say_tpl, exposeCtxFlat()), + }) + } + + // Determine the next state to advance to. + // Strategy: + // 1. For auto-behavior states (check_readback, monitor, pop_stack), + // prefer ok_next (assume success for auto-advance) + // 2. For states with a single eligible transition, take it + // 3. For ambiguous states (multiple eligible), stop + let nextId: string | null = null + + const auto = s.auto + if (auto === 'check_readback' || auto === 'monitor' || auto === 'pop_stack_or_route_by_intent') { + // Auto-behavior states: prefer ok_next, then next + const okTransitions = (s.ok_next ?? []).filter(t => { + if (!t?.to) return false + if (t.when && !evaluateConditionExpression(t.when)) return false + if (t.guard && !evaluateConditionExpression(t.guard)) return false + return true + }) + if (okTransitions.length > 0) { + nextId = okTransitions[0].to + } + } + + if (!nextId) { + // Collect all eligible transitions + const allTransitions = [ + ...(s.next ?? []), + ...(s.ok_next ?? []), + ] as Array<{ to?: string; when?: string; guard?: string }> + + const eligible = allTransitions.filter(t => { + if (!t?.to) return false + if (t.when && !evaluateConditionExpression(t.when)) return false + if (t.guard && !evaluateConditionExpression(t.guard)) return false + return true + }) + + // Only advance if there's exactly one unambiguous path + if (eligible.length === 1) { + nextId = eligible[0].to! + } + } + + if (!nextId || !states.value[nextId]) break + + // Advance to the next state (moveTo handles logging, actions, handoffs) + moveTo(nextId) + } + + return messages + } + + /** + * Computed: expected pilot phrases for the current state. + * If we're on a pilot state, returns that state's utterance_tpl rendered. + * If we're on an ATC state, looks at the next candidates that are pilot states. + */ + const expectedPilotPhrases = computed>(() => { + const s = currentState.value + if (!s) return [] + + // If current state IS a pilot state with utterance_tpl, show it + if (s.role === 'pilot' && s.utterance_tpl) { + return [{ + stateId: s.id, + text: renderTpl(s.utterance_tpl, exposeCtx()), + normalized: normalizeATCText(s.utterance_tpl, exposeCtxFlat()), + }] + } + + // Otherwise look at next candidates that are pilot states + const results: Array<{ stateId: string; text: string; normalized: string }> = [] + for (const id of nextCandidates.value) { + const state = states.value[id] + if (!state) continue + if (state.role === 'pilot' && state.utterance_tpl) { + results.push({ + stateId: id, + text: renderTpl(state.utterance_tpl, exposeCtx()), + normalized: normalizeATCText(state.utterance_tpl, exposeCtxFlat()), + }) + } + } + return results + }) + function resolveTelemetryValue(parameter: string) { const value = (telemetry.value as any)[parameter] if (value !== undefined) return value @@ -1256,6 +1413,8 @@ export default function useCommunicationsEngine() { processUserTransmission, buildLLMContext, applyLLMDecision, + collectAtcStatesUntilPilotTurn, + expectedPilotPhrases, // Flow Control moveTo,