diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index c1ead49..7288008 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..233beab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# OpenSquawk - Project Guide + +## Architecture +- **Nuxt 4** (Vue 3 SFC) frontend in `/app` +- **H3 server** handlers in `/server` +- **Shared types/utils** in `/shared` +- MongoDB models in `/server/models` + +## Key Files +- `/shared/utils/communicationsEngine.ts` — Core state machine composable (used by `/pm` live ATC) +- `/server/utils/openai.ts` — LLM decision router (`routeDecision()`) +- `/server/services/decisionFlowService.ts` — Builds runtime decision trees from MongoDB +- `/app/pages/pm.vue` — Live ATC page (speech-to-text, PTT, text input) +- `/app/pages/classroom.vue` — Classroom learning mode (separate system, does NOT use communicationsEngine) + +## Live ATC Flow (/pm) +1. User inputs (PTT or text) → `handlePilotTransmission()` +2. `processPilotTransmission()` logs the pilot message +3. `buildLLMContext()` builds candidates from `nextCandidates` +4. POST `/api/llm/decide` → `routeDecision()` selects next state +5. `applyLLMDecision()` moves to next state, updates vars/flags +6. `collectAtcStatesUntilPilotTurn()` advances through ATC/system states +7. Each ATC `say_tpl` is spoken via TTS (`scheduleControllerSpeech`) + +## Decision Tree States +States have `role: 'pilot' | 'atc' | 'system'`. ATC states have `say_tpl` (what controller says). Pilot states have `utterance_tpl` (expected pilot response). Transitions: `next`, `ok_next`, `bad_next`, `timer_next`. + +## Commands +- `bun run dev` — dev server +- Decision trees are stored in MongoDB and fetched via `/api/decision-flows/runtime` diff --git a/app/pages/pm.vue b/app/pages/pm.vue index bf80f91..4730e0f 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -474,6 +474,25 @@ append-inner-icon="mdi-send" @click:append-inner="sendPilotText" /> + + +
+

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,