feat: integrate llm-backed routing with fallback

This commit is contained in:
Remi
2025-10-16 19:55:13 +02:00
parent 48ef8affc3
commit d9de0c1c93
5 changed files with 316 additions and 127 deletions

View File

@@ -10,7 +10,8 @@
"preview": "nuxt preview",
"start": "node .output/server/index.mjs",
"postinstall": "nuxt prepare",
"import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts"
"import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts",
"test": "tsx --tsconfig tsconfig.tests.json --test server/utils/openai.test.ts"
},
"dependencies": {
"@nuxt/image": "1.11.0",

136
server/utils/openai.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { describe, it, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import type { RuntimeDecisionState, RuntimeDecisionSystem } from '~~/shared/types/decision'
import type { LLMDecisionInput } from '~~/shared/types/llm'
import { __setRuntimeDecisionSystemForTests, routeDecision } from './openai'
const createState = (overrides: Partial<RuntimeDecisionState>): RuntimeDecisionState => ({
role: 'pilot',
phase: 'ground',
name: 'State',
summary: 'Generic state',
say_tpl: undefined,
utterance_tpl: undefined,
else_say_tpl: undefined,
next: [],
ok_next: [],
bad_next: [],
timer_next: [],
auto: null,
readback_required: undefined,
actions: undefined,
handoff: undefined,
guard: undefined,
trigger: undefined,
frequency: undefined,
frequencyName: undefined,
auto_transitions: [],
triggers: [],
conditions: [],
metadata: undefined,
...overrides,
})
const START = createState({
name: 'Start',
summary: 'Start of flow',
role: 'atc',
})
const ACK = createState({
name: 'Acknowledge',
summary: 'Acknowledge pilot readback',
triggers: [
{ type: 'regex', pattern: 'roger', patternFlags: 'i' },
],
})
const TAXI = createState({
name: 'Taxi clearance',
summary: 'Pilot requesting taxi clearance',
triggers: [
{ type: 'regex', pattern: 'request', patternFlags: 'i' },
],
})
const HOLD = createState({
name: 'Hold position',
summary: 'Pilot requesting hold position',
triggers: [
{ type: 'regex', pattern: 'request', patternFlags: 'i' },
],
})
START.next = [
{ to: 'ACK' },
{ to: 'TAXI' },
{ to: 'HOLD' },
]
const runtimeSystem: RuntimeDecisionSystem = {
main: 'main',
order: ['main'],
flows: {
main: {
slug: 'main',
start_state: 'START',
entry_mode: 'main',
states: {
START,
ACK,
TAXI,
HOLD,
},
},
},
}
const baseInput: Omit<LLMDecisionInput, 'candidates'> = {
state_id: 'START',
state: START,
variables: { callsign: 'TEST123' },
flags: { current_unit: 'TWR', in_air: false },
pilot_utterance: '',
}
describe('routeDecision', () => {
beforeEach(() => {
__setRuntimeDecisionSystemForTests(runtimeSystem)
})
it('returns heuristic decision when exactly one candidate matches', async () => {
const input: LLMDecisionInput = {
...baseInput,
pilot_utterance: 'Roger that',
candidates: [
{ id: 'ACK', state: ACK },
],
}
const result = await routeDecision(input)
assert.equal(result.decision.next_state, 'ACK')
assert.equal(result.trace?.calls.length ?? 0, 0)
assert.equal(result.trace?.autoSelection?.id, 'ACK')
})
it('falls back to heuristic selection when OpenAI call fails', async () => {
const input: LLMDecisionInput = {
...baseInput,
pilot_utterance: 'Request taxi instructions',
candidates: [
{ id: 'TAXI', state: TAXI },
{ id: 'HOLD', state: HOLD },
],
}
const result = await routeDecision(input)
assert.equal(result.trace?.calls.length, 1)
assert.ok(result.trace?.calls[0]?.error)
assert.equal(result.trace?.fallback?.used, true)
assert.equal(result.decision.next_state, 'TAXI')
})
})

View File

@@ -874,144 +874,167 @@ function optimizeInputForLLM(input: LLMDecisionInput) {
}
function summarizeCandidateForPrompt(candidate: DecisionCandidate) {
const { state } = candidate
return {
id: candidate.id,
flow: candidate.flow,
role: state?.role,
phase: state?.phase,
summary: state?.summary,
say_tpl: state?.say_tpl,
utterance_tpl: state?.utterance_tpl,
handoff: state?.handoff,
}
}
function extractJsonObject(text: string): any | null {
if (!text) return null
const trimmed = text.trim()
try {
return JSON.parse(trimmed)
} catch {}
const match = trimmed.match(/\{[\s\S]*\}/)
if (!match) {
return null
}
try {
return JSON.parse(match[0])
} catch {
return null
}
}
export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisionResult> {
const utterance = (input.pilot_utterance || '').trim()
const { system, index } = await getRuntimeSystemIndex()
const prepared = await prepareDecisionCandidates(input, utterance)
const currentEntry = index.get(input.state_id)
const activeFlowSlug =
input.flow_slug
|| currentEntry?.flow
|| system.main
|| Object.keys(system.flows || {})[0]
|| ''
const candidateMap = new Map<string, DecisionCandidate>()
const addCandidate = (
id?: string,
flow?: string,
providedState?: RuntimeDecisionState
) => {
if (!id || candidateMap.has(id)) {
return
}
const indexed = index.get(id)
const state = providedState || indexed?.state
if (!state) {
return
}
const triggers = Array.isArray(state.triggers) ? state.triggers.filter(Boolean) : []
const regexTriggers = triggers.filter(trigger => trigger?.type === 'regex')
const noneTriggers = triggers.filter(trigger => trigger?.type === 'none')
const flowSlug = flow || indexed?.flow || activeFlowSlug
candidateMap.set(id, {
id,
flow: flowSlug,
state,
triggers,
regexTriggers,
noneTriggers,
})
const trace: LLMDecisionTrace = {
calls: [],
candidateTimeline: prepared.timeline,
}
if (currentEntry?.state) {
const transitions = [
...(currentEntry.state.next || []),
...(currentEntry.state.ok_next || []),
...(currentEntry.state.bad_next || []),
...(currentEntry.state.timer_next || []),
]
for (const transition of transitions) {
if (!transition?.to) continue
addCandidate(transition.to, currentEntry.flow)
if (prepared.autoSelected) {
trace.autoSelection = {
id: prepared.autoSelected.id,
flow: prepared.autoSelected.flow,
reason: 'Heuristic routing resolved a single remaining candidate.',
}
return {
decision: { next_state: prepared.autoSelected.id },
trace,
}
}
for (const raw of input.candidates || []) {
if (!raw?.id) continue
addCandidate(raw.id, raw.flow, raw.state)
}
const candidatePool = prepared.finalCandidates.length > 0
? prepared.finalCandidates
: Array.from(prepared.candidateIndex.values())
for (const [flowSlug, tree] of Object.entries(system.flows || {})) {
if (flowSlug === activeFlowSlug) continue
const start = tree?.start_state
if (!start) continue
const startState = tree?.states?.[start]
addCandidate(start, flowSlug, startState)
}
let candidates = Array.from(candidateMap.values())
if (candidates.length === 0) {
return { decision: { next_state: input.state_id } }
}
candidates = candidates.filter(candidate =>
!candidate.triggers.some(trigger => trigger?.type === 'auto_time' || trigger?.type === 'auto_variable')
)
const regexCandidates = candidates.filter(candidate => candidate.regexTriggers.length > 0)
const regexMatches = regexCandidates.filter(candidate =>
candidate.regexTriggers.some(trigger => evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance))
)
let trace: LLMDecisionTrace | undefined
if (regexMatches.length > 0) {
trace = { calls: [] }
}
let workingSet = regexMatches
if (workingSet.length === 0) {
workingSet = candidates.filter(candidate => candidate.regexTriggers.length === 0)
}
if (workingSet.length === 0) {
return { decision: { next_state: input.state_id } }
}
const context = { variables: input.variables || {}, flags: input.flags || {} }
const survivors = workingSet.filter(candidate =>
evaluateConditionList(candidate.state?.conditions, context, utterance).passed
)
if (survivors.length === 1) {
const [winner] = survivors
if (regexMatches.length > 0 && winner.regexTriggers.length > 0) {
if (!trace) {
trace = { calls: [] }
}
const patterns = winner.regexTriggers
.map(trigger => trigger?.pattern ? `/${trigger.pattern}/${trigger.patternFlags || 'i'}` : '')
.filter(pattern => Boolean(pattern))
trace.autoSelection = {
id: winner.id,
flow: winner.flow,
reason: patterns.length
? `Regex trigger matched ${patterns.join(', ')}`
: 'Regex trigger matched pilot utterance'
}
return { decision: { next_state: winner.id }, trace }
}
return { decision: { next_state: winner.id } }
}
if (survivors.length === 0) {
return { decision: { next_state: input.state_id } }
}
const [first] = survivors
if (trace) {
if (candidatePool.length === 0) {
const fallbackState = fallbackNextState(input)
trace.fallback = {
used: true,
reason: 'Multiple candidates matched after filtering; defaulting to first match.',
selected: first.id,
reason: 'No viable candidates after heuristic evaluation; falling back to default transition.',
selected: fallbackState,
}
return { decision: { next_state: first.id }, trace }
return { decision: { next_state: fallbackState }, trace }
}
return { decision: { next_state: first.id } }
const optimizedInput = optimizeInputForLLM({
...input,
candidates: candidatePool.map(candidate => ({
id: candidate.id,
flow: candidate.flow,
state: candidate.state,
})),
})
const candidateSummaries = candidatePool
.map(candidate => {
const summary = [
`${candidate.id}`,
candidate.state?.summary || candidate.state?.say_tpl || candidate.state?.utterance_tpl || '',
]
.filter(Boolean)
.join(' — ')
return `- ${summary}`
})
.join('\n')
const systemPrompt = [
'You are an assistant that selects the correct next state in an aviation decision tree.',
'Evaluate the pilot transmission and choose the most appropriate candidate state id from the provided list.',
'Respond strictly with a JSON object: {"next_state": "STATE_ID", "reason": "short rationale"}.',
'Only use state ids that were provided. If you cannot decide, choose the best heuristic option.',
].join(' ')
const userPrompt = [
`Pilot transmission: "${utterance || '(silence)'}"`,
'Candidate options:',
candidateSummaries,
'Context (JSON):',
JSON.stringify(optimizedInput, null, 2),
].join('\n')
const callEntry = {
stage: 'decision' as const,
request: {
systemPrompt,
userPrompt,
candidates: candidatePool.map(summarizeCandidateForPrompt),
},
}
trace.calls.push(callEntry)
try {
const rawResponse = await decide(systemPrompt, userPrompt)
callEntry.rawResponseText = rawResponse
const parsed = extractJsonObject(rawResponse)
if (parsed && typeof parsed.next_state === 'string' && parsed.next_state.trim().length > 0) {
callEntry.response = parsed
const resolved =
prepared.finalCandidateIndex.get(parsed.next_state)
|| prepared.candidateIndex.get(parsed.next_state)
if (resolved) {
return {
decision: { next_state: resolved.id },
trace,
}
}
return {
decision: { next_state: parsed.next_state },
trace,
}
}
throw new Error('LLM response missing next_state field')
} catch (err: any) {
callEntry.error = err?.message || String(err)
const fallbackCandidate = prepared.finalCandidates[0] || candidatePool[0]
const fallbackState = fallbackCandidate?.id || fallbackNextState(input)
trace.fallback = {
used: true,
reason: 'OpenAI decision failed or was inconclusive; falling back to heuristic selection.',
selected: fallbackState,
}
return {
decision: { next_state: fallbackState },
trace,
}
}
}
export function __setRuntimeDecisionSystemForTests(system: RuntimeDecisionSystem) {
runtimeSystemCache = {
system,
index: buildRuntimeIndex(system),
timestamp: Date.now(),
}
}

View File

@@ -0,0 +1,16 @@
export function useRuntimeConfig() {
return {
openaiKey: process.env.OPENAI_API_KEY || '',
openaiProject: process.env.OPENAI_PROJECT || '',
openaiBaseUrl: process.env.OPENAI_BASE_URL || '',
llmModel: process.env.OPENAI_LLM_MODEL || 'gpt-5-nano',
ttsModel: process.env.OPENAI_TTS_MODEL || 'tts-1',
defaultVoiceId: process.env.OPENAI_VOICE_ID || 'alloy',
openaipApiKey: process.env.OPENAIP_API_KEY || '',
usePiper: false,
piperPort: 5001,
useSpeaches: false,
speachesBaseUrl: '',
speechModelId: 'speaches-ai/piper-en_US-ryan-low',
}
}

13
tsconfig.tests.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.scripts.json",
"compilerOptions": {
"paths": {
"#imports": ["./tests/stubs/nuxt-imports.ts"],
"~~/*": ["./*"],
"@@/*": ["./*"],
"~/\*": ["./app/*"],
"@/*": ["./app/*"]
}
},
"include": ["server/**/*.ts", "shared/**/*.ts", "tests/**/*.ts"]
}