mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
fix pm
This commit is contained in:
Binary file not shown.
30
CLAUDE.md
Normal file
30
CLAUDE.md
Normal 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`
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user