This commit is contained in:
itsrubberduck
2026-02-13 08:50:02 +01:00
parent 76a9e05563
commit cabdc8db6d
5 changed files with 327 additions and 14 deletions

Binary file not shown.

30
CLAUDE.md Normal file
View File

@@ -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`

View File

@@ -474,6 +474,25 @@
append-inner-icon="mdi-send"
@click:append-inner="sendPilotText"
/>
<!-- Suggested next phrases (debug aid) -->
<div v-if="expectedPilotPhrases.length" class="space-y-1">
<p class="text-[11px] uppercase tracking-[0.25em] text-white/40">Suggested phrases</p>
<div class="flex flex-wrap gap-2">
<v-chip
v-for="phrase in expectedPilotPhrases"
:key="phrase.stateId"
size="small"
color="cyan"
variant="outlined"
class="cursor-pointer text-xs font-mono"
@click="pilotInput = phrase.text"
>
{{ phrase.text }}
</v-chip>
</div>
</div>
<p class="text-xs text-white/50">
For emergencies when PTT fails or for testing
</p>
@@ -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 = () => {

View File

@@ -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<string, any>
): { 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<string, IndexedStateEntry>): 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<LLMDecisionResult> {
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<LLMDecisio
}
let pilotIntent: string | null = null
// Heuristic readback check: if the current state requires a readback
// (e.g. pilot just read back a clearance), verify the required fields
// are present in the utterance BEFORE routing. This catches obvious
// readback errors without needing an LLM call.
const readbackKeys = READBACK_REQUIREMENTS[input.state_id] || input.state?.readback_required || []
if (readbackKeys.length > 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<LLMDecisio
reason: 'Heuristic routing resolved a single remaining candidate.',
}
return {
decision: { next_state: prepared.autoSelected.id },
decision: buildDecisionObject(prepared.autoSelected.id, prepared.autoSelected, index),
trace,
pilot_intent: pilotIntent,
}
@@ -940,7 +1013,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
reason: 'No viable candidates after heuristic evaluation; falling back to default transition.',
selected: fallbackState,
}
return { decision: { next_state: fallbackState }, trace, pilot_intent: pilotIntent }
return { decision: buildDecisionObject(fallbackState, undefined, index), trace, pilot_intent: pilotIntent }
}
const optimizedInput = optimizeInputForLLM({
@@ -1020,14 +1093,14 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
if (resolved) {
return {
decision: { next_state: resolved.id },
decision: buildDecisionObject(resolved.id, resolved, index),
trace,
pilot_intent: pilotIntent,
}
}
return {
decision: { next_state: nextState },
decision: buildDecisionObject(nextState, undefined, index),
trace,
pilot_intent: pilotIntent,
}
@@ -1047,7 +1120,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
}
return {
decision: { next_state: fallbackState },
decision: buildDecisionObject(fallbackState, fallbackCandidate ?? undefined, index),
trace,
pilot_intent: pilotIntent,
}

View File

@@ -794,10 +794,40 @@ export default function useCommunicationsEngine() {
if (!s) {
throw new Error('Decision state unavailable')
}
const candidates = nextCandidates.value
let candidates = nextCandidates.value
.map(id => ({ 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<string>()
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<string>()
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<Array<{ stateId: string; text: string; normalized: string }>>(() => {
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,