diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 2d01093..7674853 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -5,7 +5,8 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; -import { getOpenAIClient, routeDecision, type LLMDecisionResult } from "../../utils/openai"; +import { getOpenAIClient, routeDecision } from "../../utils/openai"; +import type { LLMDecisionResult } from "~~/shared/types/llm"; import { createReadStream } from "node:fs"; import { TransmissionLog } from "../../models/TransmissionLog"; import { getUserFromEvent } from "../../utils/auth"; @@ -33,6 +34,7 @@ interface PTTResponse { transcription: string; decision?: LLMDecisionResult['decision']; trace?: LLMDecisionResult['trace']; + active_nodes?: LLMDecisionResult['active_nodes']; } async function sh(cmd: string, args: string[]) { @@ -273,6 +275,9 @@ export default defineEventHandler(async (event) => { if (decisionResult?.trace) { result.trace = decisionResult.trace; } + if (decisionResult?.active_nodes?.length) { + result.active_nodes = decisionResult.active_nodes; + } return result; diff --git a/server/utils/openai.ts b/server/utils/openai.ts index a7ec3dd..caf0305 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -2,6 +2,7 @@ import OpenAI from 'openai' import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech' import type { + ActiveNodeSummary, CandidateTraceEntry, CandidateTraceStep, DecisionCandidateTimeline, @@ -9,6 +10,7 @@ import type { FlowActivationMode, LLMDecision, LLMDecisionInput, + LLMDecisionResult, LLMDecisionTrace, LLMDecisionTraceCall, } from '../../shared/types/llm' @@ -74,10 +76,6 @@ export async function decide(system: string, user: string): Promise { return r.choices?.[0]?.message?.content?.trim() || '' } -export interface LLMDecisionResult { - decision: LLMDecision - trace?: LLMDecisionTrace -} type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain' @@ -212,6 +210,8 @@ interface DecisionCandidate { interface PreparedCandidateResult { finalCandidates: DecisionCandidate[] candidateFlowMap: Map + candidateIndex: Map + finalCandidateIndex: Map activeFlowSlug: string flowEntryModes: Map timeline: DecisionCandidateTimeline @@ -726,6 +726,11 @@ async function prepareDecisionCandidates( } } + const finalCandidateIndex = new Map() + for (const candidate of finalCandidates) { + finalCandidateIndex.set(candidate.id, candidate) + } + const timeline: DecisionCandidateTimeline = { steps: timelineSteps, fallbackUsed, @@ -735,6 +740,8 @@ async function prepareDecisionCandidates( return { finalCandidates, candidateFlowMap, + candidateIndex: candidateMap, + finalCandidateIndex, activeFlowSlug, flowEntryModes, timeline, @@ -918,21 +925,98 @@ export async function routeDecision(input: LLMDecisionInput): Promise { - const targetState = decision.next_state + const targetState = typeof decision.next_state === 'string' ? decision.next_state : '' + const normalizedControllerSay = typeof decision.controller_say_tpl === 'string' + ? decision.controller_say_tpl.trim() + : '' + + const targetCandidate = targetState + ? prepared.finalCandidateIndex.get(targetState) || prepared.candidateIndex.get(targetState) + : undefined + const targetFlow = targetCandidate?.flow || (targetState ? candidateFlowMap.get(targetState) : undefined) + const candidateSayTemplate = targetCandidate?.state?.say_tpl + const candidateRole = targetCandidate?.state?.role + + if ((!normalizedControllerSay.length) && candidateRole === 'atc' && typeof candidateSayTemplate === 'string') { + decision.controller_say_tpl = candidateSayTemplate + } + let activation = resolveActivationInstruction(decision.activate_flow as any) - if (!activation && targetState) { - const targetFlow = candidateFlowMap.get(targetState) - if (targetFlow && targetFlow !== activeFlowSlug) { - activation = resolveActivationInstruction(targetFlow) - } + if (!activation && targetFlow && targetFlow !== activeFlowSlug) { + activation = resolveActivationInstruction(targetFlow) + } + + const finalControllerSay = typeof decision.controller_say_tpl === 'string' + ? decision.controller_say_tpl.trim() + : '' + const atcWillSpeak = Boolean( + (finalControllerSay && finalControllerSay.length) + || (candidateRole === 'atc' && typeof candidateSayTemplate === 'string' && candidateSayTemplate.trim().length) + ) + + if (targetCandidate?.state?.auto === 'pop_stack_or_route_by_intent') { + decision.resume_previous = true } if (activation) { + if (activation.slug !== activeFlowSlug && atcWillSpeak && activation.mode !== 'main') { + activation.mode = 'linear' + } decision.activate_flow = activation } else if (decision.activate_flow) { delete (decision as any).activate_flow } + let activeNodes: ActiveNodeSummary[] | undefined + if (activation?.mode === 'parallel') { + const nodes: ActiveNodeSummary[] = [] + + if (input.state_id && activeFlowSlug) { + const previousSay = typeof input.state?.say_tpl === 'string' ? input.state.say_tpl : undefined + nodes.push({ + flow: activeFlowSlug, + state: input.state_id, + role: input.state?.role, + say_tpl: previousSay, + controller_say_tpl: input.state?.role === 'atc' ? previousSay : undefined, + }) + } + + if (targetState) { + const flowForTarget = targetFlow || activeFlowSlug + if (flowForTarget) { + nodes.push({ + flow: flowForTarget, + state: targetState, + role: candidateRole, + say_tpl: typeof candidateSayTemplate === 'string' ? candidateSayTemplate : undefined, + controller_say_tpl: candidateRole === 'atc' + ? (decision.controller_say_tpl || candidateSayTemplate || undefined) + : undefined, + }) + } + } + + if (nodes.length) { + const seen = new Set() + activeNodes = nodes.filter(node => { + if (!node.flow || !node.state) { + return false + } + const key = `${node.flow}::${node.state}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) + } + + if (!activeNodes?.length) { + activeNodes = undefined + } + } + const shouldAttachTrace = Boolean( trace.calls.length || trace.fallback @@ -940,10 +1024,14 @@ export async function routeDecision(input: LLMDecisionInput): Promise { diff --git a/shared/types/llm.ts b/shared/types/llm.ts index 58de96b..a443eca 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -17,6 +17,14 @@ export interface FlowActivationInstruction { mode?: FlowActivationMode } +export interface ActiveNodeSummary { + flow: string + state: string + role?: string + say_tpl?: string + controller_say_tpl?: string +} + export interface CandidateTraceEntry { id: string flow: string @@ -107,3 +115,9 @@ export interface LLMDecisionTrace { reason?: string } } + +export interface LLMDecisionResult { + decision: LLMDecision + trace?: LLMDecisionTrace + active_nodes?: ActiveNodeSummary[] +}