mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
merge
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
161
app/pages/pm.vue
161
app/pages/pm.vue
@@ -2,17 +2,26 @@
|
||||
<div class="min-h-screen bg-[#050910] text-white">
|
||||
<div class="mx-auto w-full max-w-[420px] px-4 pb-24 pt-6 sm:px-6">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center justify-between pb-6">
|
||||
<header class="flex flex-col gap-4 pb-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.35em] text-cyan-400/80">OpenSquawk</p>
|
||||
<h1 class="text-2xl font-semibold">Pilot Monitoring</h1>
|
||||
<p class="mt-1 text-sm text-white/70">Alpha Build • Decision Tree • VATSIM</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat" class="mb-1">
|
||||
{{ currentState?.id || 'INIT' }}
|
||||
</v-chip>
|
||||
<div class="text-xs text-white/50">{{ currentState?.phase || 'Setup' }}</div>
|
||||
<div class="flex flex-col items-stretch gap-2 sm:items-end">
|
||||
<div class="text-right space-y-1">
|
||||
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat">
|
||||
{{ currentState?.id || 'INIT' }}
|
||||
</v-chip>
|
||||
<div class="text-xs text-white/50">{{ currentState?.phase || 'Setup' }}</div>
|
||||
<div class="flex items-center justify-end gap-2 text-[11px] text-white/60">
|
||||
<v-chip size="x-small" color="cyan" variant="outlined">{{ activeFlowInfo.name }}</v-chip>
|
||||
<v-chip size="x-small" color="purple" variant="text">{{ activeFlowInfo.modeLabel }}</v-chip>
|
||||
</div>
|
||||
<p v-if="activeFlowInfo.description" class="text-[10px] text-white/40">
|
||||
{{ activeFlowInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -601,6 +610,21 @@
|
||||
<v-chip size="small" color="grey" variant="outlined">LLM</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[11px] text-white/50">
|
||||
<span>Session: {{ sessionLabel }}</span>
|
||||
<div class="flex flex-wrap gap-2" v-if="traceAutoSelection || (traceFallback?.used) || timelineUsedFallback">
|
||||
<v-chip v-if="traceAutoSelection" size="x-small" color="cyan" variant="outlined">
|
||||
Auto: {{ traceAutoSelection.id }}
|
||||
</v-chip>
|
||||
<v-chip v-if="timelineUsedFallback" size="x-small" color="orange" variant="tonal">
|
||||
Fallback candidates
|
||||
</v-chip>
|
||||
<v-chip v-if="traceFallback?.used" size="x-small" color="red" variant="tonal">
|
||||
Fallback: {{ traceFallback.reason || 'triggered' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 rounded-2xl border border-white/10 bg-black/30 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Current node</p>
|
||||
<p class="font-mono text-sm text-white">{{ debugState?.id || '—' }}</p>
|
||||
@@ -652,6 +676,57 @@
|
||||
</div>
|
||||
<p v-else class="text-xs text-white/50">No further decisions available.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 rounded-2xl border border-white/10 bg-black/30 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Decision timeline</p>
|
||||
<div v-if="timelineSteps.length" class="space-y-3">
|
||||
<div
|
||||
v-for="(step, index) in timelineSteps"
|
||||
:key="`${step.stage}-${index}`"
|
||||
class="space-y-2 rounded-xl border border-white/10 bg-black/40 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-sm text-white">{{ step.label }}</p>
|
||||
<p class="text-[11px] text-white/50 uppercase tracking-[0.2em]">{{ step.stage }}</p>
|
||||
</div>
|
||||
<v-chip size="x-small" color="cyan" variant="outlined">
|
||||
{{ step.candidates.length }} candidates
|
||||
</v-chip>
|
||||
</div>
|
||||
<p v-if="step.note" class="text-[11px] text-white/50">{{ step.note }}</p>
|
||||
<div v-if="step.candidates.length" class="space-y-2">
|
||||
<div
|
||||
v-for="candidate in step.candidates"
|
||||
:key="candidate.id"
|
||||
class="rounded-lg border border-white/10 bg-black/30 p-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-sm text-white">{{ candidate.id }}</span>
|
||||
<span class="text-[11px] text-white/50">{{ candidate.flow || 'current' }}</span>
|
||||
</div>
|
||||
<p v-if="candidate.summary" class="text-[11px] text-white/60 mt-1">{{ candidate.summary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="step.eliminated?.length" class="space-y-2">
|
||||
<p class="text-[11px] text-red-200/80 uppercase tracking-[0.25em]">Eliminated</p>
|
||||
<div
|
||||
v-for="elim in step.eliminated"
|
||||
:key="`${step.stage}-${elim.candidate.id}`"
|
||||
class="space-y-1 rounded-lg border border-red-400/30 bg-red-500/10 p-2 text-xs text-red-100"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-sm">{{ elim.candidate.id }}</span>
|
||||
<span class="text-[11px] text-red-200/80">{{ elim.kind }}</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-red-100/80">{{ elim.reason }}</p>
|
||||
<p v-if="describeElimination(elim)" class="text-[10px] text-red-100/70">{{ describeElimination(elim) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-[11px] text-white/50">No decision timeline available yet.</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
@@ -696,6 +771,14 @@
|
||||
</div>
|
||||
<p class="text-sm text-white font-mono">{{ entry.message }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<v-chip
|
||||
v-if="entry.flow"
|
||||
size="x-small"
|
||||
color="purple"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ entry.flow }}
|
||||
</v-chip>
|
||||
<v-chip size="x-small" color="cyan" variant="outlined">{{ entry.frequency || 'N/A' }}</v-chip>
|
||||
<span class="text-xs text-white/40">{{ entry.state }}</span>
|
||||
</div>
|
||||
@@ -953,6 +1036,10 @@ const {
|
||||
flags,
|
||||
flightContext,
|
||||
currentStep,
|
||||
availableFlows,
|
||||
activeFlow,
|
||||
sessionId: engineSessionId,
|
||||
lastDecisionTrace,
|
||||
initializeFlight,
|
||||
updateFrequencyVariables,
|
||||
fetchRuntimeTree,
|
||||
@@ -1023,6 +1110,51 @@ const clearLog = () => {
|
||||
clearLastTransmission()
|
||||
}
|
||||
|
||||
const activeFlowInfo = computed(() => {
|
||||
const slug = activeFlow.value
|
||||
const flows = availableFlows.value
|
||||
const entry = (slug ? flows.find((flow) => flow.slug === slug) : undefined) || flows.find((flow) => flow.mode === 'main') || flows[0]
|
||||
const resolvedSlug = entry?.slug || slug || ''
|
||||
const name = entry?.name || resolvedSlug || 'Main Flow'
|
||||
const description = entry?.description || ''
|
||||
const mode = entry?.mode || (resolvedSlug && resolvedSlug === slug ? 'parallel' : 'parallel')
|
||||
const modeLabel = mode === 'main' ? 'Main' : mode === 'linear' ? 'Linear' : 'Parallel'
|
||||
return { slug: resolvedSlug, name, description, mode, modeLabel }
|
||||
})
|
||||
|
||||
const decisionTrace = computed(() => lastDecisionTrace.value)
|
||||
const timelineSteps = computed(() => decisionTrace.value?.candidateTimeline?.steps ?? [])
|
||||
const timelineUsedFallback = computed(() => Boolean(decisionTrace.value?.candidateTimeline?.fallbackUsed))
|
||||
const traceAutoSelection = computed(() => decisionTrace.value?.autoSelection ?? null)
|
||||
const traceFallback = computed(() => decisionTrace.value?.fallback ?? null)
|
||||
const sessionLabel = computed(() => engineSessionId.value || flags.session_id || '-')
|
||||
|
||||
function describeElimination(entry: any): string {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return ''
|
||||
}
|
||||
if (entry.kind === 'regex' && entry.context?.patterns?.length) {
|
||||
const patterns = entry.context.patterns
|
||||
.map((pattern: any) => pattern?.pattern)
|
||||
.filter((value: string | undefined) => Boolean(value))
|
||||
.join(', ')
|
||||
return patterns ? `Patterns: ${patterns}` : entry.reason
|
||||
}
|
||||
if (entry.kind === 'condition' && entry.context?.condition) {
|
||||
const condition = entry.context.condition
|
||||
if (condition.type === 'regex' || condition.type === 'regex_not') {
|
||||
const flag = condition.pattern ? `/${condition.pattern}/${condition.patternFlags || 'i'}` : ''
|
||||
return flag ? `Condition: ${condition.type} ${flag}` : entry.reason
|
||||
}
|
||||
const variable = condition.variable || 'value'
|
||||
const operator = condition.operator || '=='
|
||||
const expected = entry.context?.expectedValue ?? condition.value ?? '—'
|
||||
const actual = entry.context?.actualValue ?? '—'
|
||||
return `${variable} ${operator} ${expected} (actual: ${actual})`
|
||||
}
|
||||
return entry.reason
|
||||
}
|
||||
|
||||
// UI State
|
||||
const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login')
|
||||
const loading = ref(false)
|
||||
@@ -1476,7 +1608,8 @@ const speakPrepared = async (prepared: PreparedSpeech, options: SpeechOptions =
|
||||
speed,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
tag: options.tag || 'controller-reply'
|
||||
tag: options.tag || 'controller-reply',
|
||||
sessionId: engineSessionId.value || flags.session_id || undefined,
|
||||
})
|
||||
|
||||
if (response.success && response.audio) {
|
||||
@@ -1519,7 +1652,8 @@ const speakPlainText = (text: string, options: SpeechOptions = {}) => {
|
||||
speed,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId,
|
||||
tag: options.tag || 'announcement'
|
||||
tag: options.tag || 'announcement',
|
||||
sessionId: engineSessionId.value || flags.session_id || undefined,
|
||||
})
|
||||
|
||||
if (response.success && response.audio) {
|
||||
@@ -1562,23 +1696,18 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
|
||||
const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot'
|
||||
setLastTransmission(`${prefix}: ${transcript}`)
|
||||
|
||||
const quickResponse = processPilotTransmission(transcript)
|
||||
processPilotTransmission(transcript)
|
||||
|
||||
if (readbackEnabled.value) {
|
||||
speakPilotReadback(transcript)
|
||||
}
|
||||
|
||||
if (quickResponse) {
|
||||
scheduleControllerSpeech(quickResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = buildLLMContext(transcript)
|
||||
|
||||
try {
|
||||
const decision = await api.post('/api/llm/decide', ctx)
|
||||
const { decision, trace } = await api.post('/api/llm/decide', ctx)
|
||||
|
||||
applyLLMDecision(decision)
|
||||
applyLLMDecision(decision, trace)
|
||||
|
||||
if (decision.controller_say_tpl && !decision.radio_check) {
|
||||
scheduleControllerSpeech(decision.controller_say_tpl)
|
||||
|
||||
107
server/api/admin/logs/sessions.get.ts
Normal file
107
server/api/admin/logs/sessions.get.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { requireAdmin } from '../../../utils/auth'
|
||||
import { TransmissionLog } from '../../../models/TransmissionLog'
|
||||
|
||||
interface SessionSummaryEntry {
|
||||
sessionId: string
|
||||
startedAt: string | null
|
||||
updatedAt: string | null
|
||||
entryCount: number
|
||||
callsign?: string
|
||||
lastMessage?: {
|
||||
text: string
|
||||
role: string
|
||||
channel: string
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
|
||||
function toISO(value: any): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.valueOf()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
function extractCallsign(entry: any): string | undefined {
|
||||
const fromMetadata = (payload: any) =>
|
||||
payload?.metadata?.context?.variables?.callsign ||
|
||||
payload?.metadata?.context?.variables?.CALLSIGN ||
|
||||
payload?.metadata?.context?.variables?.Callsign
|
||||
|
||||
return (
|
||||
fromMetadata(entry.lastEntry) ||
|
||||
fromMetadata(entry.firstEntry) ||
|
||||
entry.lastEntry?.metadata?.variables?.callsign ||
|
||||
entry.firstEntry?.metadata?.variables?.callsign ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1)
|
||||
const pageSizeRaw = parseInt(String(query.pageSize ?? query.limit ?? '20'), 10)
|
||||
const pageSize = Math.min(Math.max(pageSizeRaw || 20, 1), 100)
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const matchStage = { sessionId: { $exists: true, $nin: [null, ''] } }
|
||||
|
||||
const [sessionDocs, totalCountAgg] = await Promise.all([
|
||||
TransmissionLog.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $sort: { sessionId: 1, createdAt: 1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$sessionId',
|
||||
firstEntry: { $first: '$$ROOT' },
|
||||
lastEntry: { $last: '$$ROOT' },
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { 'lastEntry.createdAt': -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: pageSize },
|
||||
]).exec(),
|
||||
TransmissionLog.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $group: { _id: '$sessionId' } },
|
||||
{ $count: 'count' },
|
||||
]).exec(),
|
||||
])
|
||||
|
||||
const total = totalCountAgg?.[0]?.count || 0
|
||||
|
||||
const items: SessionSummaryEntry[] = sessionDocs.map((entry: any) => {
|
||||
const first = entry.firstEntry || {}
|
||||
const last = entry.lastEntry || {}
|
||||
return {
|
||||
sessionId: entry._id,
|
||||
startedAt: toISO(first.createdAt),
|
||||
updatedAt: toISO(last.createdAt),
|
||||
entryCount: entry.count || 0,
|
||||
callsign: extractCallsign(entry),
|
||||
lastMessage: last.text
|
||||
? {
|
||||
text: last.text,
|
||||
role: last.role,
|
||||
channel: last.channel,
|
||||
createdAt: toISO(last.createdAt) || new Date().toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
pages: Math.ceil(total / pageSize) || 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
85
server/api/admin/logs/sessions/[sessionId].get.ts
Normal file
85
server/api/admin/logs/sessions/[sessionId].get.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
import { requireAdmin } from '../../../../utils/auth'
|
||||
import { TransmissionLog } from '../../../../models/TransmissionLog'
|
||||
|
||||
function toISO(value: any): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.valueOf()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
function mapEntry(doc: any) {
|
||||
return {
|
||||
id: String(doc._id),
|
||||
role: doc.role,
|
||||
channel: doc.channel,
|
||||
direction: doc.direction,
|
||||
text: doc.text,
|
||||
normalized: doc.normalized || undefined,
|
||||
createdAt: toISO(doc.createdAt) || new Date().toISOString(),
|
||||
metadata: doc.metadata || undefined,
|
||||
sessionId: doc.sessionId || undefined,
|
||||
user: doc.user
|
||||
? {
|
||||
id: String(doc.user._id),
|
||||
email: doc.user.email,
|
||||
name: doc.user.name || undefined,
|
||||
role: doc.user.role,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function extractCallsign(entries: any[]): string | undefined {
|
||||
for (const entry of [...entries].reverse()) {
|
||||
const callsign =
|
||||
entry.metadata?.context?.variables?.callsign ||
|
||||
entry.metadata?.context?.variables?.CALLSIGN ||
|
||||
entry.metadata?.variables?.callsign
|
||||
if (callsign) {
|
||||
return callsign
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const sessionId = event.context.params?.sessionId
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Session ID is required' })
|
||||
}
|
||||
|
||||
const items = await TransmissionLog.find({ sessionId })
|
||||
.sort({ createdAt: 1 })
|
||||
.populate('user', 'email name role')
|
||||
.lean()
|
||||
.exec()
|
||||
|
||||
if (!items.length) {
|
||||
return {
|
||||
sessionId,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
entryCount: 0,
|
||||
callsign: undefined,
|
||||
entries: [],
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = toISO(items[0].createdAt)
|
||||
const updatedAt = toISO(items[items.length - 1].createdAt)
|
||||
const callsign = extractCallsign(items)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
entryCount: items.length,
|
||||
callsign,
|
||||
entries: items.map(mapEntry),
|
||||
}
|
||||
})
|
||||
@@ -17,6 +17,7 @@ type TransmissionListItem = {
|
||||
createdAt: string
|
||||
metadata?: Record<string, any>
|
||||
user?: { id: string; email: string; name?: string; role: string }
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
function escapeRegExp(input: string) {
|
||||
@@ -33,6 +34,7 @@ function mapTransmission(doc: any): TransmissionListItem {
|
||||
normalized: doc.normalized || undefined,
|
||||
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
|
||||
metadata: doc.metadata || undefined,
|
||||
sessionId: doc.sessionId || undefined,
|
||||
user: doc.user
|
||||
? {
|
||||
id: String(doc.user._id),
|
||||
|
||||
@@ -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";
|
||||
@@ -17,9 +18,10 @@ interface PTTRequest {
|
||||
context: {
|
||||
state_id: string;
|
||||
state: any;
|
||||
candidates: Array<{ id: string; state: any }>;
|
||||
candidates: Array<{ id: string; state: any; flow?: string }>;
|
||||
variables: Record<string, any>;
|
||||
flags: Record<string, any>;
|
||||
flow_slug?: string;
|
||||
};
|
||||
moduleId: string;
|
||||
lessonId: string;
|
||||
@@ -30,12 +32,9 @@ interface PTTRequest {
|
||||
interface PTTResponse {
|
||||
success: boolean;
|
||||
transcription: string;
|
||||
decision?: {
|
||||
next_state: string;
|
||||
controller_say_tpl?: string;
|
||||
off_schema?: boolean;
|
||||
radio_check?: boolean;
|
||||
};
|
||||
decision?: LLMDecisionResult['decision'];
|
||||
trace?: LLMDecisionResult['trace'];
|
||||
active_nodes?: LLMDecisionResult['active_nodes'];
|
||||
}
|
||||
|
||||
async function sh(cmd: string, args: string[]) {
|
||||
@@ -225,6 +224,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
id: candidate.id,
|
||||
flow: candidate.flow || undefined,
|
||||
state: candidateState
|
||||
};
|
||||
})
|
||||
@@ -232,12 +232,17 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const selectedCandidate = contextCandidates?.find(c => c.id === decision?.next_state);
|
||||
|
||||
const sessionId = typeof body.context?.flags?.session_id === 'string'
|
||||
? body.context.flags.session_id
|
||||
: undefined;
|
||||
|
||||
await TransmissionLog.create({
|
||||
user: user?._id,
|
||||
role: "pilot",
|
||||
channel: "ptt",
|
||||
direction: "incoming",
|
||||
text: transcribedText,
|
||||
sessionId,
|
||||
metadata: {
|
||||
moduleId: body.moduleId,
|
||||
lessonId: body.lessonId,
|
||||
@@ -267,6 +272,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (decision) {
|
||||
result.decision = decision;
|
||||
}
|
||||
if (decisionResult?.trace) {
|
||||
result.trace = decisionResult.trace;
|
||||
}
|
||||
if (decisionResult?.active_nodes?.length) {
|
||||
result.active_nodes = decisionResult.active_nodes;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
|
||||
@@ -123,10 +123,16 @@ export default defineEventHandler(async (event) => {
|
||||
lessonId?: string;
|
||||
tag?: string;
|
||||
format?: AudioFmt | "smallest";
|
||||
sessionId?: string;
|
||||
}>(event);
|
||||
|
||||
const user = await requireUserSession(event);
|
||||
|
||||
const rawSessionId = typeof body?.sessionId === "string"
|
||||
? body.sessionId.trim()
|
||||
: "";
|
||||
const sessionId = rawSessionId.length ? rawSessionId : undefined;
|
||||
|
||||
const raw = (body?.text || "").trim();
|
||||
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
|
||||
|
||||
@@ -226,6 +232,7 @@ export default defineEventHandler(async (event) => {
|
||||
direction: "outgoing",
|
||||
text: raw,
|
||||
normalized,
|
||||
sessionId,
|
||||
metadata: {
|
||||
level,
|
||||
voice,
|
||||
|
||||
6
server/api/decision-flows/runtime.get.ts
Normal file
6
server/api/decision-flows/runtime.get.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { buildRuntimeDecisionSystem } from '../../services/decisionFlowService'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const system = await buildRuntimeDecisionSystem()
|
||||
return system
|
||||
})
|
||||
@@ -43,6 +43,9 @@ export default defineEventHandler(async (event) => {
|
||||
.filter((phase: string) => phase.length)
|
||||
: []
|
||||
|
||||
const entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel'
|
||||
const isMain = body.isMain === true
|
||||
|
||||
const endStates = Array.isArray(body.endStates)
|
||||
? body.endStates
|
||||
.map((state: any) => (typeof state === 'string' ? state.trim() : ''))
|
||||
@@ -62,10 +65,19 @@ export default defineEventHandler(async (event) => {
|
||||
hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {},
|
||||
roles,
|
||||
phases,
|
||||
entryMode,
|
||||
isMain,
|
||||
})
|
||||
|
||||
await flow.save()
|
||||
|
||||
if (isMain) {
|
||||
await DecisionFlow.updateMany(
|
||||
{ _id: { $ne: flow._id } },
|
||||
{ $set: { isMain: false } }
|
||||
)
|
||||
}
|
||||
|
||||
const { flow: serialized } = await getFlowWithNodes(slug)
|
||||
return serialized
|
||||
})
|
||||
|
||||
@@ -59,6 +59,14 @@ export default defineEventHandler(async (event) => {
|
||||
flow.phases = phases
|
||||
}
|
||||
|
||||
if (typeof body.entryMode === 'string') {
|
||||
flow.entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel'
|
||||
}
|
||||
|
||||
if (typeof body.isMain === 'boolean') {
|
||||
flow.isMain = body.isMain
|
||||
}
|
||||
|
||||
if (body.variables && typeof body.variables === 'object') {
|
||||
flow.variables = body.variables
|
||||
flow.markModified('variables')
|
||||
@@ -145,6 +153,13 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
await flow.save()
|
||||
|
||||
if (flow.isMain) {
|
||||
await DecisionFlow.updateMany(
|
||||
{ _id: { $ne: flow._id } },
|
||||
{ $set: { isMain: false } }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await getFlowWithNodes(slug)
|
||||
return data
|
||||
})
|
||||
|
||||
@@ -6,9 +6,16 @@ import {
|
||||
sanitizeLayout,
|
||||
sanitizeLLMTemplate,
|
||||
sanitizeMetadata,
|
||||
sanitizeNodeCondition,
|
||||
sanitizeNodeTrigger,
|
||||
sanitizeTransition,
|
||||
} from '../../../../utils/decisionSanitizer'
|
||||
import { serializeNodeDocument } from '../../../../services/decisionFlowService'
|
||||
import type {
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
|
||||
|
||||
@@ -47,9 +54,44 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'phase is required' })
|
||||
}
|
||||
|
||||
const transitions = Array.isArray(body.transitions)
|
||||
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
: []
|
||||
let transitions: DecisionNodeTransition[] = []
|
||||
try {
|
||||
transitions = Array.isArray(body.transitions)
|
||||
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
: []
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Transition ungültig',
|
||||
data: { formError: error?.message || 'Transition ungültig', field: 'transitions' },
|
||||
})
|
||||
}
|
||||
|
||||
let triggers: DecisionNodeTrigger[] = []
|
||||
try {
|
||||
triggers = Array.isArray(body.triggers)
|
||||
? body.triggers.map((trigger: any, index: number) => sanitizeNodeTrigger(trigger, index))
|
||||
: []
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Trigger ungültig',
|
||||
data: { formError: error?.message || 'Trigger ungültig', field: 'triggers' },
|
||||
})
|
||||
}
|
||||
|
||||
let conditions: DecisionNodeCondition[] = []
|
||||
try {
|
||||
conditions = Array.isArray(body.conditions)
|
||||
? body.conditions.map((condition: any, index: number) => sanitizeNodeCondition(condition, index))
|
||||
: []
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Bedingung ungültig',
|
||||
data: { formError: error?.message || 'Bedingung ungültig', field: 'conditions' },
|
||||
})
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(body.layout) || { x: 0, y: 0 }
|
||||
const metadata = sanitizeMetadata(body.metadata)
|
||||
@@ -89,6 +131,8 @@ export default defineEventHandler(async (event) => {
|
||||
frequency: typeof body.frequency === 'string' ? body.frequency.trim() || undefined : undefined,
|
||||
frequencyName:
|
||||
typeof body.frequencyName === 'string' ? body.frequencyName.trim() || undefined : undefined,
|
||||
triggers,
|
||||
conditions,
|
||||
transitions,
|
||||
layout,
|
||||
metadata,
|
||||
|
||||
@@ -6,9 +6,16 @@ import {
|
||||
sanitizeLayout,
|
||||
sanitizeLLMTemplate,
|
||||
sanitizeMetadata,
|
||||
sanitizeNodeCondition,
|
||||
sanitizeNodeTrigger,
|
||||
sanitizeTransition,
|
||||
} from '../../../../../../utils/decisionSanitizer'
|
||||
import { serializeNodeDocument } from '../../../../../../services/decisionFlowService'
|
||||
import type {
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
|
||||
|
||||
@@ -113,7 +120,49 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
if (Array.isArray(body.transitions)) {
|
||||
node.transitions = body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
let sanitizedTransitions: DecisionNodeTransition[]
|
||||
try {
|
||||
sanitizedTransitions = body.transitions.map((transition: any, index: number) =>
|
||||
sanitizeTransition(transition, index)
|
||||
)
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Transition ungültig',
|
||||
data: { formError: error?.message || 'Transition ungültig', field: 'transitions' },
|
||||
})
|
||||
}
|
||||
node.transitions = sanitizedTransitions
|
||||
}
|
||||
|
||||
if (Array.isArray(body.triggers)) {
|
||||
let sanitizedTriggers: DecisionNodeTrigger[]
|
||||
try {
|
||||
sanitizedTriggers = body.triggers.map((trigger: any, index: number) => sanitizeNodeTrigger(trigger, index))
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Trigger ungültig',
|
||||
data: { formError: error?.message || 'Trigger ungültig', field: 'triggers' },
|
||||
})
|
||||
}
|
||||
node.triggers = sanitizedTriggers
|
||||
}
|
||||
|
||||
if (Array.isArray(body.conditions)) {
|
||||
let sanitizedConditions: DecisionNodeCondition[]
|
||||
try {
|
||||
sanitizedConditions = body.conditions.map((condition: any, index: number) =>
|
||||
sanitizeNodeCondition(condition, index)
|
||||
)
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Bedingung ungültig',
|
||||
data: { formError: error?.message || 'Bedingung ungültig', field: 'conditions' },
|
||||
})
|
||||
}
|
||||
node.conditions = sanitizedConditions
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(body.layout)
|
||||
|
||||
@@ -13,9 +13,9 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { decision, trace } = await routeDecision(body)
|
||||
const result = await routeDecision(body)
|
||||
const { decision, trace } = result
|
||||
|
||||
// Log for debugging when off-schema or radio check triggers
|
||||
if (decision.off_schema) {
|
||||
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.log('[ATC] Decision trace captured with', trace.calls.length, 'call(s)')
|
||||
}
|
||||
|
||||
return decision
|
||||
return result
|
||||
} catch (err: any) {
|
||||
console.error('Router failed:', err)
|
||||
throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' })
|
||||
|
||||
@@ -12,6 +12,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (url.pathname.startsWith('/api/bridge/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/decision-flows/runtime') {
|
||||
return
|
||||
}
|
||||
if (event.node.req.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface DecisionFlowDocument extends mongoose.Document {
|
||||
phases: string[]
|
||||
layout?: DecisionFlowLayout
|
||||
metadata?: DecisionFlowMetadata
|
||||
entryMode?: 'parallel' | 'linear'
|
||||
isMain?: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
@@ -37,6 +39,8 @@ const decisionFlowSchema = new mongoose.Schema<DecisionFlowDocument>(
|
||||
hooks: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
|
||||
roles: { type: [String], default: () => [] },
|
||||
phases: { type: [String], default: () => [] },
|
||||
entryMode: { type: String, enum: ['parallel', 'linear'], default: 'parallel' },
|
||||
isMain: { type: Boolean, default: false },
|
||||
layout: {
|
||||
type: new mongoose.Schema<DecisionFlowLayout>(
|
||||
{
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import mongoose from 'mongoose'
|
||||
import type {
|
||||
DecisionNodeAutoTrigger,
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeLayout,
|
||||
DecisionNodeLLMPlaceholder,
|
||||
DecisionNodeLLMTemplate,
|
||||
DecisionNodeMetadata,
|
||||
DecisionNodeModel,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
@@ -88,6 +90,37 @@ const autoTriggerSchema = new mongoose.Schema<DecisionNodeAutoTrigger>(
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const triggerSchema = new mongoose.Schema<DecisionNodeTrigger>(
|
||||
{
|
||||
id: { type: String, required: true },
|
||||
type: { type: String, enum: ['auto_time', 'auto_variable', 'regex', 'none'], required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
delaySeconds: { type: Number },
|
||||
variable: { type: String },
|
||||
operator: { type: String },
|
||||
value: { type: mongoose.Schema.Types.Mixed },
|
||||
pattern: { type: String },
|
||||
patternFlags: { type: String },
|
||||
description: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const conditionSchema = new mongoose.Schema<DecisionNodeCondition>(
|
||||
{
|
||||
id: { type: String, required: true },
|
||||
type: { type: String, enum: ['variable_value', 'regex', 'regex_not'], required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
variable: { type: String },
|
||||
operator: { type: String },
|
||||
value: { type: mongoose.Schema.Types.Mixed },
|
||||
pattern: { type: String },
|
||||
patternFlags: { type: String },
|
||||
description: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const transitionSchema = new mongoose.Schema<DecisionNodeTransition>(
|
||||
{
|
||||
key: { type: String, required: true },
|
||||
@@ -158,6 +191,8 @@ const decisionNodeSchema = new mongoose.Schema<DecisionNodeDocument>(
|
||||
trigger: { type: String },
|
||||
frequency: { type: String },
|
||||
frequencyName: { type: String },
|
||||
triggers: { type: [triggerSchema], default: undefined },
|
||||
conditions: { type: [conditionSchema], default: undefined },
|
||||
transitions: { type: [transitionSchema], default: () => [] },
|
||||
layout: { type: layoutSchema, default: undefined },
|
||||
metadata: { type: metadataSchema, default: undefined },
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface TransmissionLogDocument extends mongoose.Document {
|
||||
text: string
|
||||
normalized?: string
|
||||
metadata?: Record<string, any>
|
||||
sessionId?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ const transmissionSchema = new mongoose.Schema<TransmissionLogDocument>({
|
||||
text: { type: String, required: true },
|
||||
normalized: { type: String },
|
||||
metadata: { type: Schema.Types.Mixed },
|
||||
sessionId: { type: String, index: true },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
RuntimeDecisionAutoTransition,
|
||||
RuntimeDecisionState,
|
||||
RuntimeDecisionTree,
|
||||
RuntimeDecisionSystem,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): DecisionFlowModel {
|
||||
@@ -31,6 +32,8 @@ export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0):
|
||||
createdAt: doc.createdAt?.toISOString?.() || new Date().toISOString(),
|
||||
updatedAt: doc.updatedAt?.toISOString?.() || new Date().toISOString(),
|
||||
nodeCount,
|
||||
entryMode: doc.entryMode || 'parallel',
|
||||
isMain: doc.isMain || false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +56,8 @@ export function serializeNodeDocument(doc: DecisionNodeDocument): DecisionNodeMo
|
||||
trigger: obj.trigger || undefined,
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
triggers: Array.isArray(obj.triggers) ? obj.triggers : [],
|
||||
conditions: Array.isArray(obj.conditions) ? obj.conditions : [],
|
||||
transitions: Array.isArray(obj.transitions) ? obj.transitions : [],
|
||||
layout: obj.layout || undefined,
|
||||
metadata: obj.metadata || undefined,
|
||||
@@ -88,6 +93,8 @@ export async function listDecisionFlows(): Promise<DecisionFlowSummary[]> {
|
||||
nodeCount: countMap[String(flow._id)] || 0,
|
||||
updatedAt: flow.updatedAt?.toISOString?.() || new Date().toISOString(),
|
||||
createdAt: flow.createdAt?.toISOString?.() || new Date().toISOString(),
|
||||
entryMode: flow.entryMode || 'parallel',
|
||||
isMain: Boolean(flow.isMain),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -154,6 +161,8 @@ function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState
|
||||
return {
|
||||
role: obj.role as any,
|
||||
phase: obj.phase,
|
||||
name: obj.title || undefined,
|
||||
summary: obj.summary || undefined,
|
||||
say_tpl: obj.sayTemplate || undefined,
|
||||
utterance_tpl: obj.utteranceTemplate || undefined,
|
||||
else_say_tpl: obj.elseSayTemplate || undefined,
|
||||
@@ -170,25 +179,26 @@ function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
auto_transitions: toRuntimeAutoTransitions(transitions),
|
||||
triggers: Array.isArray(obj.triggers) ? obj.triggers : undefined,
|
||||
conditions: Array.isArray(obj.conditions) ? obj.conditions : undefined,
|
||||
metadata: obj.metadata || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
|
||||
const flowDoc = await DecisionFlow.findOne({ slug })
|
||||
if (!flowDoc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const nodes = await DecisionNode.find({ flow: flowDoc._id })
|
||||
async function buildRuntimeTreeForDoc(
|
||||
flowDoc: DecisionFlowDocument,
|
||||
nodeDocs?: DecisionNodeDocument[]
|
||||
): Promise<RuntimeDecisionTree> {
|
||||
const nodes = nodeDocs ?? (await DecisionNode.find({ flow: flowDoc._id }))
|
||||
const states = nodes.reduce<Record<string, RuntimeDecisionState>>((acc, node) => {
|
||||
acc[node.stateId] = serializeRuntimeState(node)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
slug: flowDoc.slug,
|
||||
schema_version: flowDoc.schemaVersion || '1.0',
|
||||
name: flowDoc.slug,
|
||||
name: flowDoc.name || flowDoc.slug,
|
||||
description: flowDoc.description || undefined,
|
||||
start_state: flowDoc.startState,
|
||||
end_states: Array.isArray(flowDoc.endStates) ? flowDoc.endStates : [],
|
||||
@@ -199,5 +209,56 @@ export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDec
|
||||
roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [],
|
||||
phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [],
|
||||
states,
|
||||
entry_mode: flowDoc.isMain ? 'main' : flowDoc.entryMode || 'parallel',
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
|
||||
const flowDoc = await DecisionFlow.findOne({ slug })
|
||||
if (!flowDoc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
return buildRuntimeTreeForDoc(flowDoc)
|
||||
}
|
||||
|
||||
export async function buildRuntimeDecisionSystem(): Promise<RuntimeDecisionSystem> {
|
||||
const flowDocs = await DecisionFlow.find().sort({ updatedAt: -1 })
|
||||
if (!flowDocs.length) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'No decision flows available' })
|
||||
}
|
||||
|
||||
const flowIds = flowDocs.map((doc) => doc._id)
|
||||
|
||||
const nodeDocs = await DecisionNode.find({ flow: { $in: flowIds } })
|
||||
const groupedNodes = nodeDocs.reduce<Record<string, DecisionNodeDocument[]>>((acc, node) => {
|
||||
const key = String(node.flow)
|
||||
if (!acc[key]) {
|
||||
acc[key] = []
|
||||
}
|
||||
acc[key].push(node)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const runtimeTrees: RuntimeDecisionTree[] = []
|
||||
for (const doc of flowDocs) {
|
||||
const nodes = groupedNodes[String(doc._id)] || []
|
||||
runtimeTrees.push(await buildRuntimeTreeForDoc(doc, nodes))
|
||||
}
|
||||
|
||||
const flows = runtimeTrees.reduce<Record<string, RuntimeDecisionTree>>((acc, tree) => {
|
||||
acc[tree.slug] = tree
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const order = runtimeTrees.map((tree) => tree.slug)
|
||||
const preferredMain = flowDocs.find((doc) => doc.isMain)?.slug
|
||||
const fallbackMain = flowDocs.find((doc) => doc.slug === 'icao_atc_decision_tree')?.slug
|
||||
const main = preferredMain || fallbackMain || order[0]
|
||||
|
||||
return {
|
||||
main,
|
||||
order,
|
||||
flows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +1,21 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import atcDecisionTree from '~~/shared/data/atcDecisionTree'
|
||||
import { DecisionFlow } from '../models/DecisionFlow'
|
||||
import { DecisionNode } from '../models/DecisionNode'
|
||||
import type { DecisionNodeTransition } from '~~/shared/types/decision'
|
||||
import { getFlowWithNodes } from './decisionFlowService'
|
||||
|
||||
interface LegacyTransition {
|
||||
to?: string
|
||||
when?: string
|
||||
label?: string
|
||||
condition?: string
|
||||
guard?: string
|
||||
after_s?: number
|
||||
allowManualProceed?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ImportDecisionTreeOptions {
|
||||
slug?: string
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
pilot: '#0ea5e9',
|
||||
atc: '#22d3ee',
|
||||
system: '#f59e0b',
|
||||
}
|
||||
|
||||
function createTransition(
|
||||
type: DecisionNodeTransition['type'],
|
||||
data: LegacyTransition,
|
||||
order: number
|
||||
): DecisionNodeTransition | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const target = typeof data.to === 'string' ? data.to.trim() : ''
|
||||
if (!target) return null
|
||||
|
||||
const transition: DecisionNodeTransition = {
|
||||
key: `${type}_${randomUUID().slice(0, 8)}`,
|
||||
type,
|
||||
target,
|
||||
order,
|
||||
}
|
||||
|
||||
const label = typeof data.label === 'string' ? data.label.trim() : undefined
|
||||
if (label) transition.label = label
|
||||
|
||||
const condition =
|
||||
typeof data.when === 'string'
|
||||
? data.when.trim()
|
||||
: typeof data.condition === 'string'
|
||||
? data.condition.trim()
|
||||
: undefined
|
||||
if (condition) transition.condition = condition
|
||||
|
||||
const guard = typeof data.guard === 'string' ? data.guard.trim() : undefined
|
||||
if (guard) transition.guard = guard
|
||||
|
||||
const description = typeof data.description === 'string' ? data.description.trim() : undefined
|
||||
if (description) transition.description = description
|
||||
|
||||
if (type === 'timer') {
|
||||
const after = typeof data.after_s === 'number' ? data.after_s : Number(data.after_s)
|
||||
if (Number.isFinite(after)) {
|
||||
transition.timer = {
|
||||
afterSeconds: Number(after),
|
||||
allowManualProceed: data.allowManualProceed !== false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transition
|
||||
}
|
||||
|
||||
export async function importATCDecisionTree(options: ImportDecisionTreeOptions = {}) {
|
||||
const slug = typeof options.slug === 'string' && options.slug.trim().length
|
||||
? options.slug.trim()
|
||||
: atcDecisionTree.name || 'icao_atc_decision_tree'
|
||||
: 'icao_atc_decision_tree'
|
||||
|
||||
const name = typeof options.name === 'string' && options.name.trim().length
|
||||
? options.name.trim()
|
||||
: 'ATC Decision Tree'
|
||||
|
||||
const existingFlow = await DecisionFlow.findOne({ slug })
|
||||
const flow = existingFlow || new DecisionFlow({ slug, name })
|
||||
|
||||
flow.name = name
|
||||
flow.description = options.description ?? atcDecisionTree.description ?? flow.description
|
||||
flow.schemaVersion = atcDecisionTree.schema_version || '1.0'
|
||||
flow.startState = atcDecisionTree.start_state
|
||||
flow.endStates = Array.isArray(atcDecisionTree.end_states) ? atcDecisionTree.end_states : []
|
||||
flow.variables = atcDecisionTree.variables || {}
|
||||
flow.flags = atcDecisionTree.flags || {}
|
||||
flow.policies = atcDecisionTree.policies || {}
|
||||
flow.hooks = atcDecisionTree.hooks || {}
|
||||
flow.roles = Array.isArray(atcDecisionTree.roles) ? atcDecisionTree.roles : flow.roles
|
||||
flow.phases = Array.isArray(atcDecisionTree.phases) ? atcDecisionTree.phases : flow.phases
|
||||
flow.layout = flow.layout || { zoom: 0.9, pan: { x: 0, y: 0 }, groups: [] }
|
||||
|
||||
await flow.save()
|
||||
|
||||
await DecisionNode.deleteMany({ flow: flow._id })
|
||||
|
||||
const phases = Array.isArray(flow.phases) && flow.phases.length ? flow.phases : ['General']
|
||||
const phaseColumns = new Map<string, number>()
|
||||
phases.forEach((phase, index) => phaseColumns.set(phase, index))
|
||||
const phaseRowCounters = new Map<string, number>()
|
||||
|
||||
const stateEntries = Object.entries(atcDecisionTree.states || {})
|
||||
const nodesToInsert = stateEntries.map(([stateId, state]) => {
|
||||
const role = typeof state.role === 'string' ? state.role : 'system'
|
||||
const phase = typeof state.phase === 'string' ? state.phase : 'General'
|
||||
|
||||
const columnIndex = phaseColumns.has(phase) ? phaseColumns.get(phase)! : phaseColumns.size
|
||||
if (!phaseColumns.has(phase)) {
|
||||
phaseColumns.set(phase, columnIndex)
|
||||
}
|
||||
const rowIndex = phaseRowCounters.get(phase) || 0
|
||||
phaseRowCounters.set(phase, rowIndex + 1)
|
||||
|
||||
const transitions: DecisionNodeTransition[] = []
|
||||
let order = 0
|
||||
|
||||
if (Array.isArray(state.next)) {
|
||||
for (const entry of state.next as LegacyTransition[]) {
|
||||
const transition = createTransition('next', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.ok_next)) {
|
||||
for (const entry of state.ok_next as LegacyTransition[]) {
|
||||
const transition = createTransition('ok', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.bad_next)) {
|
||||
for (const entry of state.bad_next as LegacyTransition[]) {
|
||||
const transition = createTransition('bad', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.timer_next)) {
|
||||
for (const entry of state.timer_next as LegacyTransition[]) {
|
||||
const transition = createTransition('timer', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
const readbackRequired = Array.isArray(state.readback_required)
|
||||
? state.readback_required.filter((item: any) => typeof item === 'string' && item.trim().length)
|
||||
: []
|
||||
|
||||
const layout = {
|
||||
x: columnIndex * 340,
|
||||
y: rowIndex * 220,
|
||||
color: ROLE_COLORS[role] || '#38bdf8',
|
||||
}
|
||||
|
||||
return {
|
||||
flow: flow._id,
|
||||
stateId,
|
||||
title: typeof state.title === 'string' ? state.title.trim() || undefined : undefined,
|
||||
summary: typeof state.summary === 'string' ? state.summary.trim() || undefined : undefined,
|
||||
role,
|
||||
phase,
|
||||
sayTemplate: typeof state.say_tpl === 'string' ? state.say_tpl : undefined,
|
||||
utteranceTemplate: typeof state.utterance_tpl === 'string' ? state.utterance_tpl : undefined,
|
||||
elseSayTemplate: typeof state.else_say_tpl === 'string' ? state.else_say_tpl : undefined,
|
||||
readbackRequired,
|
||||
autoBehavior: typeof state.auto === 'string' ? state.auto : undefined,
|
||||
actions: Array.isArray(state.actions) ? state.actions : [],
|
||||
handoff: state.handoff && typeof state.handoff === 'object' ? state.handoff : undefined,
|
||||
guard: typeof state.guard === 'string' ? state.guard : undefined,
|
||||
trigger: typeof state.trigger === 'string' ? state.trigger : undefined,
|
||||
frequency: typeof state.frequency === 'string' ? state.frequency : undefined,
|
||||
frequencyName: typeof state.frequencyName === 'string' ? state.frequencyName : undefined,
|
||||
transitions,
|
||||
layout,
|
||||
}
|
||||
})
|
||||
|
||||
await DecisionNode.insertMany(nodesToInsert)
|
||||
|
||||
const { flow: serializedFlow, nodes } = await getFlowWithNodes(slug)
|
||||
const { flow, nodes } = await getFlowWithNodes(slug)
|
||||
|
||||
return {
|
||||
flow: serializedFlow,
|
||||
flow,
|
||||
nodes,
|
||||
importedStates: nodes.length,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
DecisionComparisonOperator,
|
||||
DecisionNodeAutoTrigger,
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeLayout,
|
||||
DecisionNodeLLMPlaceholder,
|
||||
DecisionNodeLLMTemplate,
|
||||
DecisionNodeMetadata,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
const TRANSITION_TYPES = new Set(['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return'])
|
||||
const AUTO_TRIGGER_TYPES = new Set(['telemetry', 'variable', 'expression'])
|
||||
const NODE_TRIGGER_TYPES = new Set(['auto_time', 'auto_variable', 'regex', 'none'])
|
||||
const NODE_CONDITION_TYPES = new Set(['variable_value', 'regex', 'regex_not'])
|
||||
const COMPARISON_OPERATORS = new Set(['>', '>=', '<', '<=', '==', '!='])
|
||||
const TELEMETRY_PARAMETERS = new Set([
|
||||
'altitude_ft',
|
||||
'speed_kts',
|
||||
'groundspeed_kts',
|
||||
'vertical_speed_fpm',
|
||||
'heading_deg',
|
||||
'distance_nm',
|
||||
])
|
||||
|
||||
function asTrimmedString(input: any): string | undefined {
|
||||
if (typeof input === 'string') {
|
||||
@@ -43,6 +56,42 @@ function asBoolean(input: any, fallback: boolean): boolean {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asComparisonOperatorValue(input: any, fallback: DecisionComparisonOperator = '=='): DecisionComparisonOperator {
|
||||
const operator = asTrimmedString(input)
|
||||
if (operator && COMPARISON_OPERATORS.has(operator)) {
|
||||
return operator as DecisionComparisonOperator
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asTelemetryParameter(
|
||||
input: any,
|
||||
fallback: NonNullable<DecisionNodeAutoTrigger['parameter']> = 'altitude_ft'
|
||||
): NonNullable<DecisionNodeAutoTrigger['parameter']> {
|
||||
const parameter = asTrimmedString(input)
|
||||
if (parameter && TELEMETRY_PARAMETERS.has(parameter)) {
|
||||
return parameter as NonNullable<DecisionNodeAutoTrigger['parameter']>
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asTelemetryValue(input: any, fallback: number | string = 0): number | string {
|
||||
const numeric = asNumber(input)
|
||||
if (typeof numeric === 'number') return numeric
|
||||
const stringValue = asTrimmedString(input)
|
||||
if (stringValue !== undefined) return stringValue
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asVariableValue(input: any, fallback: number | string | boolean = ''): number | string | boolean {
|
||||
const numeric = asNumber(input)
|
||||
if (typeof numeric === 'number') return numeric
|
||||
if (typeof input === 'boolean') return input
|
||||
const stringValue = asTrimmedString(input)
|
||||
if (stringValue !== undefined) return stringValue
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function sanitizeLayout(raw: any): DecisionNodeLayout | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const x = asNumber(raw.x) ?? 0
|
||||
@@ -133,70 +182,97 @@ export function sanitizeLLMTemplate(raw: any): DecisionNodeLLMTemplate | undefin
|
||||
}
|
||||
|
||||
export function sanitizeAutoTrigger(raw: any): DecisionNodeAutoTrigger | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const type = asTrimmedString(raw.type)
|
||||
if (!type || !AUTO_TRIGGER_TYPES.has(type)) {
|
||||
throw new Error('Invalid auto trigger type')
|
||||
}
|
||||
const payload = raw && typeof raw === 'object' ? raw : {}
|
||||
const type = asTrimmedString(payload.type)
|
||||
const normalizedType =
|
||||
type && AUTO_TRIGGER_TYPES.has(type) ? (type as DecisionNodeAutoTrigger['type']) : 'expression'
|
||||
|
||||
const trigger: DecisionNodeAutoTrigger = {
|
||||
id: asTrimmedString(raw.id) || `auto_${randomUUID()}`,
|
||||
type: type as DecisionNodeAutoTrigger['type'],
|
||||
id: asTrimmedString(payload.id) || `auto_${randomUUID()}`,
|
||||
type: normalizedType,
|
||||
}
|
||||
|
||||
if (type === 'expression') {
|
||||
const expression = asTrimmedString(raw.expression)
|
||||
if (!expression) {
|
||||
throw new Error('Expression trigger requires an expression')
|
||||
}
|
||||
trigger.expression = expression
|
||||
} else if (type === 'telemetry') {
|
||||
const parameter = asTrimmedString(raw.parameter)
|
||||
if (!parameter) {
|
||||
throw new Error('Telemetry trigger requires a parameter')
|
||||
}
|
||||
trigger.parameter = parameter as DecisionNodeAutoTrigger['parameter']
|
||||
const operator = asTrimmedString(raw.operator)
|
||||
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
|
||||
throw new Error('Telemetry trigger requires a valid operator')
|
||||
}
|
||||
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
|
||||
const value = raw.value !== undefined ? raw.value : undefined
|
||||
if (value === undefined) {
|
||||
throw new Error('Telemetry trigger requires a value')
|
||||
}
|
||||
const numericValue = asNumber(value)
|
||||
trigger.value = numericValue !== undefined ? numericValue : value
|
||||
const unit = asTrimmedString(raw.unit)
|
||||
if (normalizedType === 'expression') {
|
||||
trigger.expression = asTrimmedString(payload.expression) ?? ''
|
||||
} else if (normalizedType === 'telemetry') {
|
||||
trigger.parameter = asTelemetryParameter(payload.parameter)
|
||||
trigger.operator = asComparisonOperatorValue(payload.operator)
|
||||
trigger.value = asTelemetryValue(payload.value, 0)
|
||||
const unit = asTrimmedString(payload.unit)
|
||||
if (unit) trigger.unit = unit
|
||||
} else if (type === 'variable') {
|
||||
const variable = asTrimmedString(raw.variable)
|
||||
if (!variable) {
|
||||
throw new Error('Variable trigger requires a variable path')
|
||||
}
|
||||
trigger.variable = variable
|
||||
const operator = asTrimmedString(raw.operator)
|
||||
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
|
||||
throw new Error('Variable trigger requires a valid operator')
|
||||
}
|
||||
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
|
||||
const value = raw.value !== undefined ? raw.value : undefined
|
||||
if (value === undefined) {
|
||||
throw new Error('Variable trigger requires a value')
|
||||
}
|
||||
const numericValue = asNumber(value)
|
||||
trigger.value = numericValue !== undefined ? numericValue : value
|
||||
} else if (normalizedType === 'variable') {
|
||||
trigger.variable = asTrimmedString(payload.variable) ?? ''
|
||||
trigger.operator = asComparisonOperatorValue(payload.operator)
|
||||
trigger.value = asVariableValue(payload.value, '')
|
||||
}
|
||||
|
||||
if (raw.once !== undefined) {
|
||||
trigger.once = asBoolean(raw.once, true)
|
||||
}
|
||||
const delayMs = asNumber(raw.delayMs)
|
||||
trigger.once = asBoolean(payload.once, true)
|
||||
const delayMs = asNumber(payload.delayMs)
|
||||
if (typeof delayMs === 'number') trigger.delayMs = delayMs
|
||||
const description = asTrimmedString(raw.description)
|
||||
const description = asTrimmedString(payload.description)
|
||||
if (description) trigger.description = description
|
||||
|
||||
return trigger
|
||||
}
|
||||
|
||||
export function sanitizeNodeTrigger(raw: any, index = 0): DecisionNodeTrigger {
|
||||
const payload = raw && typeof raw === 'object' ? raw : {}
|
||||
const type = asTrimmedString(payload.type)
|
||||
const normalizedType =
|
||||
type && NODE_TRIGGER_TYPES.has(type) ? (type as DecisionNodeTrigger['type']) : 'none'
|
||||
|
||||
const trigger: DecisionNodeTrigger = {
|
||||
id: asTrimmedString(payload.id) || `trigger_${randomUUID()}`,
|
||||
type: normalizedType,
|
||||
order: typeof payload.order === 'number' ? payload.order : index,
|
||||
}
|
||||
|
||||
if (trigger.type === 'auto_time') {
|
||||
trigger.delaySeconds = asNumber(payload.delaySeconds) ?? 0
|
||||
} else if (trigger.type === 'auto_variable') {
|
||||
trigger.variable = asTrimmedString(payload.variable) ?? ''
|
||||
trigger.operator = asComparisonOperatorValue(payload.operator)
|
||||
trigger.value = asVariableValue(payload.value, '')
|
||||
} else if (trigger.type === 'regex') {
|
||||
trigger.pattern = asTrimmedString(payload.pattern) ?? ''
|
||||
trigger.patternFlags = asTrimmedString(payload.patternFlags) ?? ''
|
||||
}
|
||||
|
||||
const description = asTrimmedString(payload.description)
|
||||
if (description) trigger.description = description
|
||||
|
||||
return trigger
|
||||
}
|
||||
|
||||
export function sanitizeNodeCondition(raw: any, index = 0): DecisionNodeCondition {
|
||||
const payload = raw && typeof raw === 'object' ? raw : {}
|
||||
const type = asTrimmedString(payload.type)
|
||||
const normalizedType =
|
||||
type && NODE_CONDITION_TYPES.has(type)
|
||||
? (type as DecisionNodeCondition['type'])
|
||||
: 'variable_value'
|
||||
|
||||
const condition: DecisionNodeCondition = {
|
||||
id: asTrimmedString(payload.id) || `condition_${randomUUID()}`,
|
||||
type: normalizedType,
|
||||
order: typeof payload.order === 'number' ? payload.order : index,
|
||||
}
|
||||
|
||||
const description = asTrimmedString(payload.description)
|
||||
if (description) condition.description = description
|
||||
|
||||
if (condition.type === 'variable_value') {
|
||||
condition.variable = asTrimmedString(payload.variable) ?? ''
|
||||
condition.operator = asComparisonOperatorValue(payload.operator)
|
||||
condition.value = asVariableValue(payload.value, '')
|
||||
} else {
|
||||
condition.pattern = asTrimmedString(payload.pattern) ?? ''
|
||||
condition.patternFlags = asTrimmedString(payload.patternFlags) ?? ''
|
||||
}
|
||||
|
||||
return condition
|
||||
}
|
||||
|
||||
export function sanitizeTransition(raw: any, index = 0): DecisionNodeTransition {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid transition payload')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,35 @@ export interface DecisionNodeAutoTrigger {
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
export type DecisionNodeTriggerType = 'auto_time' | 'auto_variable' | 'regex' | 'none'
|
||||
|
||||
export interface DecisionNodeTrigger {
|
||||
id: string
|
||||
type: DecisionNodeTriggerType
|
||||
order?: number
|
||||
delaySeconds?: number
|
||||
variable?: string
|
||||
operator?: DecisionComparisonOperator
|
||||
value?: number | string | boolean
|
||||
pattern?: string
|
||||
patternFlags?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type DecisionNodeConditionType = 'variable_value' | 'regex' | 'regex_not'
|
||||
|
||||
export interface DecisionNodeCondition {
|
||||
id: string
|
||||
type: DecisionNodeConditionType
|
||||
order?: number
|
||||
variable?: string
|
||||
operator?: DecisionComparisonOperator
|
||||
value?: number | string | boolean
|
||||
pattern?: string
|
||||
patternFlags?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface DecisionTransitionMetadata {
|
||||
color?: string
|
||||
icon?: string
|
||||
@@ -97,6 +126,8 @@ export interface DecisionNodeMetadata {
|
||||
complexity?: 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
export type DecisionFlowEntryMode = 'parallel' | 'linear'
|
||||
|
||||
export interface DecisionNodeModel {
|
||||
stateId: string
|
||||
title?: string
|
||||
@@ -114,6 +145,8 @@ export interface DecisionNodeModel {
|
||||
trigger?: string
|
||||
frequency?: string
|
||||
frequencyName?: string
|
||||
triggers?: DecisionNodeTrigger[]
|
||||
conditions?: DecisionNodeCondition[]
|
||||
transitions: DecisionNodeTransition[]
|
||||
layout?: DecisionNodeLayout
|
||||
metadata?: DecisionNodeMetadata
|
||||
@@ -166,6 +199,8 @@ export interface DecisionFlowModel {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
nodeCount?: number
|
||||
entryMode?: DecisionFlowEntryMode
|
||||
isMain?: boolean
|
||||
}
|
||||
|
||||
export interface RuntimeDecisionAutoTransition {
|
||||
@@ -182,6 +217,8 @@ export interface RuntimeDecisionAutoTransition {
|
||||
export interface RuntimeDecisionState {
|
||||
role: DecisionNodeRole
|
||||
phase: string
|
||||
name?: string
|
||||
summary?: string
|
||||
say_tpl?: string
|
||||
utterance_tpl?: string
|
||||
else_say_tpl?: string
|
||||
@@ -198,10 +235,13 @@ export interface RuntimeDecisionState {
|
||||
frequency?: string
|
||||
frequencyName?: string
|
||||
auto_transitions?: RuntimeDecisionAutoTransition[]
|
||||
triggers?: DecisionNodeTrigger[]
|
||||
conditions?: DecisionNodeCondition[]
|
||||
metadata?: DecisionNodeMetadata
|
||||
}
|
||||
|
||||
export interface RuntimeDecisionTree {
|
||||
slug: string
|
||||
schema_version: string
|
||||
name: string
|
||||
description?: string
|
||||
@@ -214,6 +254,13 @@ export interface RuntimeDecisionTree {
|
||||
roles: DecisionNodeRole[]
|
||||
phases: string[]
|
||||
states: Record<string, RuntimeDecisionState>
|
||||
entry_mode?: 'main' | DecisionFlowEntryMode
|
||||
}
|
||||
|
||||
export interface RuntimeDecisionSystem {
|
||||
main: string
|
||||
order: string[]
|
||||
flows: Record<string, RuntimeDecisionTree>
|
||||
}
|
||||
|
||||
export interface DecisionFlowSummary {
|
||||
@@ -225,4 +272,6 @@ export interface DecisionFlowSummary {
|
||||
nodeCount: number
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
entryMode?: DecisionFlowEntryMode
|
||||
isMain?: boolean
|
||||
}
|
||||
|
||||
@@ -1,10 +1,83 @@
|
||||
import type { DecisionNodeCondition, DecisionNodeTrigger } from './decision'
|
||||
|
||||
export interface LLMDecisionInput {
|
||||
state_id: string
|
||||
state: any
|
||||
candidates: Array<{ id: string; state: any }>
|
||||
candidates: Array<{ id: string; state: any; flow?: string }>
|
||||
variables: Record<string, any>
|
||||
flags: Record<string, any>
|
||||
pilot_utterance: string
|
||||
flow_slug?: string
|
||||
}
|
||||
|
||||
export type FlowActivationMode = 'main' | 'parallel' | 'linear'
|
||||
|
||||
export interface FlowActivationInstruction {
|
||||
slug: string
|
||||
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
|
||||
name?: string
|
||||
summary?: string
|
||||
role?: string
|
||||
triggers?: DecisionNodeTrigger[]
|
||||
conditions?: DecisionNodeCondition[]
|
||||
}
|
||||
|
||||
export type CandidateTraceStage =
|
||||
| 'regex_candidates'
|
||||
| 'regex_filtered'
|
||||
| 'condition_filtered'
|
||||
| 'fallback_candidates'
|
||||
| 'fallback_filtered'
|
||||
| 'final'
|
||||
|
||||
export interface CandidateTraceEliminationContext {
|
||||
patterns?: Array<{ id?: string; pattern?: string; flags?: string }>
|
||||
transcript?: string
|
||||
condition?: {
|
||||
id?: string
|
||||
type: DecisionNodeCondition['type']
|
||||
variable?: string
|
||||
operator?: string
|
||||
value?: number | string | boolean
|
||||
pattern?: string
|
||||
patternFlags?: string
|
||||
}
|
||||
actualValue?: any
|
||||
expectedValue?: any
|
||||
}
|
||||
|
||||
export interface CandidateTraceElimination {
|
||||
candidate: CandidateTraceEntry
|
||||
kind: 'regex' | 'condition'
|
||||
reason: string
|
||||
context?: CandidateTraceEliminationContext
|
||||
}
|
||||
|
||||
export interface CandidateTraceStep {
|
||||
stage: CandidateTraceStage
|
||||
label: string
|
||||
candidates: CandidateTraceEntry[]
|
||||
eliminated?: CandidateTraceElimination[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface DecisionCandidateTimeline {
|
||||
steps: CandidateTraceStep[]
|
||||
fallbackUsed?: boolean
|
||||
autoSelected?: CandidateTraceEntry | null
|
||||
}
|
||||
|
||||
export interface LLMDecision {
|
||||
@@ -14,4 +87,37 @@ export interface LLMDecision {
|
||||
controller_say_tpl?: string
|
||||
off_schema?: boolean
|
||||
radio_check?: boolean
|
||||
activate_flow?: string | FlowActivationInstruction
|
||||
resume_previous?: boolean
|
||||
}
|
||||
|
||||
export interface LLMDecisionTraceCall {
|
||||
stage: 'readback-check' | 'decision'
|
||||
request: Record<string, any>
|
||||
response?: any
|
||||
rawResponseText?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface LLMDecisionTraceFallback {
|
||||
used: boolean
|
||||
reason?: string
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface LLMDecisionTrace {
|
||||
calls: LLMDecisionTraceCall[]
|
||||
fallback?: LLMDecisionTraceFallback
|
||||
candidateTimeline?: DecisionCandidateTimeline
|
||||
autoSelection?: {
|
||||
id: string
|
||||
flow: string
|
||||
reason?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface LLMDecisionResult {
|
||||
decision: LLMDecision
|
||||
trace?: LLMDecisionTrace
|
||||
active_nodes?: ActiveNodeSummary[]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// communicationsEngine composable
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { ref, computed, readonly, reactive } from 'vue'
|
||||
import type {
|
||||
RuntimeDecisionTree,
|
||||
RuntimeDecisionSystem,
|
||||
RuntimeDecisionState,
|
||||
RuntimeDecisionAutoTransition,
|
||||
DecisionNodeAutoTrigger,
|
||||
} from '../types/decision'
|
||||
import type { FlowActivationInstruction, FlowActivationMode, LLMDecisionTrace } from '../types/llm'
|
||||
import { normalizeRadioPhrase } from './radioSpeech'
|
||||
|
||||
// --- DecisionTree runtime types ---
|
||||
@@ -19,6 +21,7 @@ interface EngineFlags {
|
||||
stack: string[]
|
||||
off_schema_count: number
|
||||
radio_checks_done: number
|
||||
session_id: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -93,6 +96,19 @@ export interface EngineLog {
|
||||
state: string
|
||||
radioCheck?: boolean
|
||||
offSchema?: boolean
|
||||
flow?: string
|
||||
}
|
||||
|
||||
interface FlowSnapshot {
|
||||
tree: RuntimeDecisionTree
|
||||
variables: Record<string, any>
|
||||
flags: EngineFlags
|
||||
telemetry: TelemetryState
|
||||
currentStateId: string
|
||||
communicationLog: EngineLog[]
|
||||
autoHistory: Map<string, Set<string>>
|
||||
flightContext: FlightContext
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
type TelemetryState = {
|
||||
@@ -106,11 +122,42 @@ type TelemetryState = {
|
||||
[key: string]: number
|
||||
}
|
||||
|
||||
function createSessionId(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function normalizeATCText(text: string, context: Record<string, any>): string {
|
||||
const rendered = renderTpl(text, context)
|
||||
return normalizeRadioPhrase(rendered)
|
||||
}
|
||||
|
||||
function createDefaultFlightContext(): FlightContext {
|
||||
return {
|
||||
callsign: '',
|
||||
aircraft: 'A320',
|
||||
dep: 'EDDF',
|
||||
dest: 'EDDM',
|
||||
stand: 'A12',
|
||||
runway: '25R',
|
||||
squawk: '1234',
|
||||
atis_code: 'K',
|
||||
sid: 'ANEKI7S',
|
||||
transition: 'ANEKI',
|
||||
flight_level: 'FL360',
|
||||
atis_freq: '118.025',
|
||||
ground_freq: '121.700',
|
||||
tower_freq: '118.700',
|
||||
departure_freq: '125.350',
|
||||
approach_freq: '120.800',
|
||||
handoff_freq: '121.800',
|
||||
qnh_hpa: 1015,
|
||||
taxi_route: 'A, V',
|
||||
remarks: 'standard',
|
||||
time_now: undefined,
|
||||
phase: 'clearance',
|
||||
}
|
||||
}
|
||||
|
||||
function renderTpl(tpl: string, ctx: Record<string, any>): string {
|
||||
return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => {
|
||||
const parts = key.split('.')
|
||||
@@ -121,8 +168,17 @@ function renderTpl(tpl: string, ctx: Record<string, any>): string {
|
||||
}
|
||||
|
||||
export default function useCommunicationsEngine() {
|
||||
const runtimeSystem = ref<RuntimeDecisionSystem | null>(null)
|
||||
const flowOrder = ref<string[]>([])
|
||||
const activeFlowSlug = ref<string>('')
|
||||
const sessionId = ref<string>('')
|
||||
const flowStack = ref<string[]>([])
|
||||
|
||||
const tree = ref<RuntimeDecisionTree | null>(null)
|
||||
const ready = ref(false)
|
||||
const lastDecisionTrace = ref<LLMDecisionTrace | null>(null)
|
||||
|
||||
const flowSnapshots = reactive<Record<string, FlowSnapshot>>({})
|
||||
|
||||
const states = computed<Record<string, RuntimeDecisionState>>(() => tree.value?.states ?? {})
|
||||
|
||||
@@ -148,31 +204,8 @@ export default function useCommunicationsEngine() {
|
||||
heading_deg: 0,
|
||||
})
|
||||
|
||||
const autoExecutionHistory = new Map<string, Set<string>>()
|
||||
|
||||
// Flight context used for pm_alt.vue integration
|
||||
const flightContext = ref<FlightContext>({
|
||||
callsign: '',
|
||||
aircraft: 'A320',
|
||||
dep: 'EDDF',
|
||||
dest: 'EDDM',
|
||||
stand: 'A12',
|
||||
runway: '25R',
|
||||
squawk: '1234',
|
||||
atis_code: 'K',
|
||||
sid: 'ANEKI7S',
|
||||
transition: 'ANEKI',
|
||||
flight_level: 'FL360',
|
||||
atis_freq: '118.025',
|
||||
ground_freq: '121.700',
|
||||
tower_freq: '118.700',
|
||||
departure_freq: '125.350',
|
||||
approach_freq: '120.800',
|
||||
handoff_freq: '121.800',
|
||||
qnh_hpa: 1015,
|
||||
taxi_route: 'A, V',
|
||||
phase: 'clearance'
|
||||
})
|
||||
const flightContext = ref<FlightContext>(createDefaultFlightContext())
|
||||
|
||||
const currentState = computed<RuntimeDecisionState & { id: string } | null>(() => {
|
||||
const stateMap = states.value
|
||||
@@ -183,6 +216,198 @@ export default function useCommunicationsEngine() {
|
||||
return base ? { ...base, id } : null
|
||||
})
|
||||
|
||||
function ensureSnapshot(slug: string): FlowSnapshot {
|
||||
const snapshot = flowSnapshots[slug]
|
||||
if (!snapshot) {
|
||||
throw new Error(`Flow snapshot not loaded: ${slug}`)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function getActiveSnapshot(): FlowSnapshot | null {
|
||||
if (!activeFlowSlug.value) return null
|
||||
return flowSnapshots[activeFlowSlug.value] || null
|
||||
}
|
||||
|
||||
function assignActiveVariables(next: Record<string, any>) {
|
||||
variables.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].variables = next
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionValue(raw?: string): string {
|
||||
if (raw && typeof raw === 'string' && raw.trim().length) {
|
||||
sessionId.value = raw.trim()
|
||||
} else if (!sessionId.value) {
|
||||
sessionId.value = createSessionId()
|
||||
}
|
||||
return sessionId.value
|
||||
}
|
||||
|
||||
function assignActiveFlags(next: EngineFlags) {
|
||||
const normalizedSession = ensureSessionValue(next?.session_id)
|
||||
next.session_id = normalizedSession
|
||||
flags.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].flags = next
|
||||
}
|
||||
}
|
||||
|
||||
function assignActiveTelemetry(next: TelemetryState) {
|
||||
telemetry.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].telemetry = next
|
||||
}
|
||||
}
|
||||
|
||||
function assignCommunicationLog(next: EngineLog[]) {
|
||||
communicationLog.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].communicationLog = next
|
||||
}
|
||||
}
|
||||
|
||||
function assignFlightContext(next: FlightContext) {
|
||||
flightContext.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].flightContext = next
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveStateId(stateId: string) {
|
||||
currentStateId.value = stateId
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].currentStateId = stateId
|
||||
}
|
||||
}
|
||||
|
||||
function createSnapshotFromTree(treeData: RuntimeDecisionTree): FlowSnapshot {
|
||||
const variables = { ...treeData.variables }
|
||||
const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {}
|
||||
const stack = Array.isArray((baseFlags as any).stack) ? [...(baseFlags as any).stack] : []
|
||||
const flags: EngineFlags = {
|
||||
in_air: Boolean((baseFlags as any).in_air),
|
||||
emergency_active: Boolean((baseFlags as any).emergency_active),
|
||||
current_unit: typeof (baseFlags as any).current_unit === 'string'
|
||||
? (baseFlags as any).current_unit
|
||||
: 'DEL',
|
||||
stack,
|
||||
off_schema_count: Number((baseFlags as any).off_schema_count) || 0,
|
||||
radio_checks_done: Number((baseFlags as any).radio_checks_done) || 0,
|
||||
session_id: '',
|
||||
...(baseFlags as EngineFlags),
|
||||
}
|
||||
if (!Array.isArray(flags.stack)) {
|
||||
flags.stack = []
|
||||
}
|
||||
if (typeof flags.session_id !== 'string') {
|
||||
flags.session_id = ''
|
||||
}
|
||||
|
||||
const telemetry: TelemetryState = {
|
||||
altitude_ft: Number((baseFlags as any).altitude_ft) || 0,
|
||||
speed_kts: Number((baseFlags as any).speed_kts) || 0,
|
||||
groundspeed_kts: Number((baseFlags as any).groundspeed_kts) || 0,
|
||||
vertical_speed_fpm: Number((baseFlags as any).vertical_speed_fpm) || 0,
|
||||
latitude_deg: Number((baseFlags as any).latitude_deg) || 0,
|
||||
longitude_deg: Number((baseFlags as any).longitude_deg) || 0,
|
||||
heading_deg: Number((baseFlags as any).heading_deg) || 0,
|
||||
}
|
||||
|
||||
const log: EngineLog[] = []
|
||||
const snapshotContext = createDefaultFlightContext()
|
||||
snapshotContext.phase = 'clearance'
|
||||
|
||||
const autoHistory = new Map<string, Set<string>>()
|
||||
if (treeData.start_state) {
|
||||
autoHistory.set(treeData.start_state, new Set())
|
||||
}
|
||||
|
||||
return {
|
||||
tree: treeData,
|
||||
variables,
|
||||
flags,
|
||||
telemetry,
|
||||
currentStateId: treeData.start_state,
|
||||
communicationLog: log,
|
||||
autoHistory,
|
||||
flightContext: snapshotContext,
|
||||
ready: true,
|
||||
}
|
||||
}
|
||||
|
||||
function persistActiveSnapshot() {
|
||||
if (!activeFlowSlug.value) return
|
||||
const snapshot = flowSnapshots[activeFlowSlug.value]
|
||||
if (!snapshot) return
|
||||
snapshot.variables = variables.value
|
||||
snapshot.flags = flags.value
|
||||
snapshot.telemetry = telemetry.value
|
||||
snapshot.currentStateId = currentStateId.value
|
||||
snapshot.communicationLog = communicationLog.value
|
||||
snapshot.flightContext = flightContext.value
|
||||
snapshot.ready = ready.value
|
||||
}
|
||||
|
||||
function activateFlow(slug: string) {
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
if (activeFlowSlug.value && activeFlowSlug.value !== slug) {
|
||||
persistActiveSnapshot()
|
||||
}
|
||||
activeFlowSlug.value = slug
|
||||
tree.value = snapshot.tree
|
||||
assignActiveVariables(snapshot.variables)
|
||||
assignActiveFlags(snapshot.flags)
|
||||
assignActiveTelemetry(snapshot.telemetry)
|
||||
assignCommunicationLog(snapshot.communicationLog)
|
||||
assignFlightContext(snapshot.flightContext)
|
||||
setActiveStateId(snapshot.currentStateId)
|
||||
ready.value = snapshot.ready
|
||||
}
|
||||
|
||||
function resolveFlowMode(slug: string | undefined): FlowActivationMode {
|
||||
if (!slug) return 'parallel'
|
||||
const system = runtimeSystem.value
|
||||
if (!system) return 'parallel'
|
||||
if (slug === system.main) return 'main'
|
||||
const treeData = system.flows[slug]
|
||||
if (!treeData) return 'parallel'
|
||||
if (treeData.entry_mode === 'main') return 'main'
|
||||
if (treeData.entry_mode === 'linear') return 'linear'
|
||||
return 'parallel'
|
||||
}
|
||||
|
||||
function normalizeFlowInstruction(target: string | FlowActivationInstruction | null | undefined): FlowActivationInstruction | null {
|
||||
if (!target) return null
|
||||
if (typeof target === 'string') {
|
||||
return { slug: target, mode: resolveFlowMode(target) }
|
||||
}
|
||||
if (!target.slug) return null
|
||||
return { slug: target.slug, mode: target.mode ?? resolveFlowMode(target.slug) }
|
||||
}
|
||||
|
||||
function setActiveFlow(target: string | FlowActivationInstruction, options: { skipStack?: boolean } = {}) {
|
||||
const instruction = normalizeFlowInstruction(target)
|
||||
if (!instruction) {
|
||||
throw new Error(`Flow snapshot not loaded: ${typeof target === 'string' ? target : target?.slug}`)
|
||||
}
|
||||
const slug = instruction.slug
|
||||
if (!slug || !flowSnapshots[slug]) {
|
||||
throw new Error(`Flow snapshot not loaded: ${slug}`)
|
||||
}
|
||||
const previous = activeFlowSlug.value
|
||||
const shouldPush = !options.skipStack
|
||||
&& instruction.mode === 'linear'
|
||||
&& previous
|
||||
&& previous !== slug
|
||||
if (shouldPush) {
|
||||
flowStack.value.push(previous)
|
||||
}
|
||||
activateFlow(slug)
|
||||
ready.value = true
|
||||
queueMicrotask(() => evaluateAutoTransitions())
|
||||
}
|
||||
const nextCandidates = computed<string[]>(() => {
|
||||
const s = currentState.value
|
||||
if (!s) return []
|
||||
@@ -210,69 +435,141 @@ export default function useCommunicationsEngine() {
|
||||
return tree.value
|
||||
}
|
||||
|
||||
function resetAutoHistory(stateId: string) {
|
||||
autoExecutionHistory.set(stateId, new Set())
|
||||
function resetAutoHistory(stateId: string, slug = activeFlowSlug.value) {
|
||||
if (!slug) return
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
snapshot.autoHistory.set(stateId, new Set())
|
||||
}
|
||||
|
||||
function markAutoExecuted(stateId: string, transitionId: string) {
|
||||
if (!autoExecutionHistory.has(stateId)) {
|
||||
autoExecutionHistory.set(stateId, new Set())
|
||||
function markAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value) {
|
||||
if (!slug) return
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
if (!snapshot.autoHistory.has(stateId)) {
|
||||
snapshot.autoHistory.set(stateId, new Set())
|
||||
}
|
||||
autoExecutionHistory.get(stateId)!.add(transitionId)
|
||||
snapshot.autoHistory.get(stateId)!.add(transitionId)
|
||||
}
|
||||
|
||||
function hasAutoExecuted(stateId: string, transitionId: string): boolean {
|
||||
const set = autoExecutionHistory.get(stateId)
|
||||
function hasAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value): boolean {
|
||||
if (!slug) return false
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
const set = snapshot.autoHistory.get(stateId)
|
||||
return set ? set.has(transitionId) : false
|
||||
}
|
||||
|
||||
function resetEngineFromTree(treeData: RuntimeDecisionTree) {
|
||||
tree.value = treeData
|
||||
variables.value = { ...treeData.variables }
|
||||
const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {}
|
||||
const stack = Array.isArray(baseFlags.stack) ? [...baseFlags.stack] : []
|
||||
flags.value = {
|
||||
in_air: Boolean(baseFlags.in_air),
|
||||
emergency_active: Boolean(baseFlags.emergency_active),
|
||||
current_unit: typeof baseFlags.current_unit === 'string' ? baseFlags.current_unit : 'DEL',
|
||||
stack,
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0,
|
||||
...baseFlags,
|
||||
const system: RuntimeDecisionSystem = {
|
||||
main: treeData.slug,
|
||||
order: [treeData.slug],
|
||||
flows: { [treeData.slug]: treeData },
|
||||
}
|
||||
if (!Array.isArray(flags.value.stack)) {
|
||||
flags.value.stack = []
|
||||
resetEngineFromSystem(system, { activeSlug: treeData.slug })
|
||||
}
|
||||
|
||||
function resetEngineFromSystem(system: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) {
|
||||
runtimeSystem.value = system
|
||||
const order = Array.isArray(system.order) && system.order.length
|
||||
? [...system.order]
|
||||
: Object.keys(system.flows)
|
||||
flowOrder.value = order
|
||||
|
||||
for (const key of Object.keys(flowSnapshots)) {
|
||||
delete flowSnapshots[key]
|
||||
}
|
||||
currentStateId.value = treeData.start_state
|
||||
communicationLog.value = []
|
||||
telemetry.value = {
|
||||
altitude_ft: Number(baseFlags.altitude_ft) || 0,
|
||||
speed_kts: Number(baseFlags.speed_kts) || 0,
|
||||
groundspeed_kts: Number(baseFlags.groundspeed_kts) || 0,
|
||||
vertical_speed_fpm: Number(baseFlags.vertical_speed_fpm) || 0,
|
||||
latitude_deg: Number(baseFlags.latitude_deg) || 0,
|
||||
longitude_deg: Number(baseFlags.longitude_deg) || 0,
|
||||
heading_deg: Number(baseFlags.heading_deg) || 0,
|
||||
|
||||
for (const slug of order) {
|
||||
const treeData = system.flows[slug]
|
||||
if (!treeData) continue
|
||||
flowSnapshots[slug] = createSnapshotFromTree(treeData)
|
||||
}
|
||||
|
||||
flowStack.value = []
|
||||
sessionId.value = createSessionId()
|
||||
for (const slug of order) {
|
||||
const snapshot = flowSnapshots[slug]
|
||||
if (snapshot) {
|
||||
snapshot.flags.session_id = sessionId.value
|
||||
}
|
||||
}
|
||||
|
||||
const preferred = options.activeSlug && system.flows[options.activeSlug]
|
||||
? options.activeSlug
|
||||
: system.main && system.flows[system.main]
|
||||
? system.main
|
||||
: order[0]
|
||||
|
||||
if (preferred) {
|
||||
activateFlow(preferred)
|
||||
ready.value = true
|
||||
const snapshot = ensureSnapshot(preferred)
|
||||
resetAutoHistory(snapshot.currentStateId, preferred)
|
||||
evaluateAutoTransitions()
|
||||
} else {
|
||||
activeFlowSlug.value = ''
|
||||
tree.value = null
|
||||
ready.value = false
|
||||
assignActiveVariables({})
|
||||
assignActiveFlags({
|
||||
in_air: false,
|
||||
emergency_active: false,
|
||||
current_unit: 'DEL',
|
||||
stack: [],
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0,
|
||||
session_id: ensureSessionValue(),
|
||||
})
|
||||
assignActiveTelemetry({
|
||||
altitude_ft: 0,
|
||||
speed_kts: 0,
|
||||
groundspeed_kts: 0,
|
||||
vertical_speed_fpm: 0,
|
||||
latitude_deg: 0,
|
||||
longitude_deg: 0,
|
||||
heading_deg: 0,
|
||||
})
|
||||
assignCommunicationLog([])
|
||||
assignFlightContext(createDefaultFlightContext())
|
||||
setActiveStateId('')
|
||||
}
|
||||
autoExecutionHistory.clear()
|
||||
resetAutoHistory(currentStateId.value)
|
||||
flightContext.value.phase = 'clearance'
|
||||
ready.value = true
|
||||
evaluateAutoTransitions()
|
||||
}
|
||||
|
||||
function loadRuntimeTree(data: RuntimeDecisionTree) {
|
||||
resetEngineFromTree(data)
|
||||
}
|
||||
|
||||
function loadRuntimeSystem(data: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) {
|
||||
resetEngineFromSystem(data, options)
|
||||
}
|
||||
|
||||
const activeFlow = computed(() => activeFlowSlug.value)
|
||||
|
||||
const mainFlowSlug = computed(() => runtimeSystem.value?.main || '')
|
||||
|
||||
const availableFlows = computed(() => {
|
||||
if (!runtimeSystem.value) return [] as Array<{ slug: string; name: string; description?: string; start: string }>
|
||||
return flowOrder.value
|
||||
.filter((slug) => Boolean(runtimeSystem.value!.flows[slug]))
|
||||
.map((slug) => {
|
||||
const treeData = runtimeSystem.value!.flows[slug]
|
||||
return {
|
||||
slug,
|
||||
name: treeData.name || slug,
|
||||
description: treeData.description,
|
||||
start: treeData.start_state,
|
||||
mode: treeData.entry_mode || (slug === runtimeSystem.value!.main ? 'main' : 'parallel'),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function fetchRuntimeTree(slug = 'icao_atc_decision_tree') {
|
||||
ready.value = false
|
||||
const fetcher: any = (globalThis as any).$fetch
|
||||
if (typeof fetcher !== 'function') {
|
||||
throw new Error('Universal fetch is not available in this context')
|
||||
}
|
||||
const data = await fetcher<RuntimeDecisionTree>(`/api/decision-flows/${slug}/runtime`)
|
||||
resetEngineFromTree(data)
|
||||
const data = await fetcher<RuntimeDecisionSystem>('/api/decision-flows/runtime')
|
||||
const activeSlug = slug && data.flows[slug] ? slug : data.main
|
||||
resetEngineFromSystem(data, { activeSlug })
|
||||
}
|
||||
|
||||
function normalizeComparableValue(value: any): any {
|
||||
@@ -417,7 +714,7 @@ export default function useCommunicationsEngine() {
|
||||
function initializeFlight(fpl: any) {
|
||||
const runtime = ensureTree()
|
||||
// Set variables
|
||||
variables.value = {
|
||||
const nextVariables = {
|
||||
...variables.value,
|
||||
callsign: fpl.callsign || fpl.callsign,
|
||||
acf_type: fpl.aircraft?.split('/')[0] || 'A320',
|
||||
@@ -448,6 +745,7 @@ export default function useCommunicationsEngine() {
|
||||
remarks: 'standard',
|
||||
time_now: new Date().toISOString()
|
||||
}
|
||||
assignActiveVariables(nextVariables)
|
||||
|
||||
// Update flight context
|
||||
Object.assign(flightContext.value, {
|
||||
@@ -455,19 +753,21 @@ export default function useCommunicationsEngine() {
|
||||
phase: 'clearance'
|
||||
})
|
||||
|
||||
flags.value = {
|
||||
const nextFlags: EngineFlags = {
|
||||
...flags.value,
|
||||
in_air: false,
|
||||
emergency_active: false,
|
||||
current_unit: 'DEL',
|
||||
stack: [],
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0
|
||||
radio_checks_done: 0,
|
||||
session_id: ensureSessionValue(flags.value.session_id)
|
||||
}
|
||||
assignActiveFlags(nextFlags)
|
||||
|
||||
currentStateId.value = runtime.start_state
|
||||
communicationLog.value = []
|
||||
resetAutoHistory(currentStateId.value)
|
||||
setActiveStateId(runtime.start_state)
|
||||
assignCommunicationLog([])
|
||||
resetAutoHistory(runtime.start_state)
|
||||
}
|
||||
|
||||
function updateFrequencyVariables(update: Partial<Record<FrequencyVariableKey, string>>) {
|
||||
@@ -495,7 +795,7 @@ export default function useCommunicationsEngine() {
|
||||
throw new Error('Decision state unavailable')
|
||||
}
|
||||
const candidates = nextCandidates.value
|
||||
.map(id => ({ id, state: states.value[id] }))
|
||||
.map(id => ({ id, state: states.value[id], flow: runtime.slug }))
|
||||
.filter(candidate => candidate.state)
|
||||
|
||||
return {
|
||||
@@ -506,14 +806,18 @@ export default function useCommunicationsEngine() {
|
||||
flags: { ...flags.value },
|
||||
pilot_utterance: pilotTranscript,
|
||||
tree: runtime.name,
|
||||
flow_slug: runtime.slug,
|
||||
}
|
||||
}
|
||||
|
||||
function applyLLMDecision(decision: any) {
|
||||
function applyLLMDecision(decision: any, trace?: LLMDecisionTrace | null) {
|
||||
if (!decision || typeof decision !== 'object') {
|
||||
lastDecisionTrace.value = null
|
||||
return
|
||||
}
|
||||
|
||||
lastDecisionTrace.value = trace ?? null
|
||||
|
||||
if (decision.updates && typeof decision.updates === 'object') {
|
||||
Object.assign(variables.value, decision.updates)
|
||||
}
|
||||
@@ -530,6 +834,17 @@ export default function useCommunicationsEngine() {
|
||||
flags.value.stack = decision.stack.slice()
|
||||
}
|
||||
|
||||
if (decision.activate_flow) {
|
||||
const activation = normalizeFlowInstruction(decision.activate_flow as any)
|
||||
if (activation && (activation.slug !== activeFlowSlug.value || activation.mode === 'main')) {
|
||||
try {
|
||||
setActiveFlow(activation)
|
||||
} catch (err) {
|
||||
console.warn('[Engine] Failed to activate flow from decision', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.off_schema) {
|
||||
flags.value.off_schema_count++
|
||||
console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`)
|
||||
@@ -555,7 +870,10 @@ export default function useCommunicationsEngine() {
|
||||
: null
|
||||
|
||||
if (resumeFlow) {
|
||||
resumePriorFlow()
|
||||
const resumed = resumeLinearFlow()
|
||||
if (!resumed) {
|
||||
resumeStackedState()
|
||||
}
|
||||
} else if (!decision.radio_check && nextState) {
|
||||
moveTo(nextState)
|
||||
}
|
||||
@@ -569,31 +887,8 @@ export default function useCommunicationsEngine() {
|
||||
if (!ready.value) {
|
||||
return null
|
||||
}
|
||||
// Log pilot input
|
||||
speak('pilot', transcript, currentStateId.value)
|
||||
|
||||
// Radio check detection (fallback if the LLM misses it)
|
||||
const t = transcript.toLowerCase()
|
||||
if (t.includes('radio check') || (t.includes('read') && t.includes('check'))) {
|
||||
const callsign = variables.value.callsign || ''
|
||||
const response = `${callsign}, read you five by five.`
|
||||
flags.value.radio_checks_done++
|
||||
|
||||
setTimeout(() => {
|
||||
speak('atc', response, currentStateId.value, { radioCheck: true })
|
||||
}, 500)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Emergency Interrupts
|
||||
if (flags.value.in_air && /^(mayday|pan\s*pan)/.test(t)) {
|
||||
const intId = t.startsWith('mayday') ? 'INT_MAYDAY' : 'INT_PANPAN'
|
||||
moveTo(intId)
|
||||
return null
|
||||
}
|
||||
|
||||
return null // Let the LLM decide
|
||||
return null
|
||||
}
|
||||
|
||||
function processUserTransmission(transcript: string): string | null {
|
||||
@@ -624,7 +919,7 @@ export default function useCommunicationsEngine() {
|
||||
const fallback = typeof raw === 'number' ? raw : Number(raw)
|
||||
next[key] = Number.isNaN(fallback) ? current : fallback
|
||||
}
|
||||
telemetry.value = next
|
||||
assignActiveTelemetry(next)
|
||||
queueMicrotask(() => evaluateAutoTransitions())
|
||||
}
|
||||
|
||||
@@ -693,7 +988,7 @@ export default function useCommunicationsEngine() {
|
||||
flags.value.stack.push(currentStateId.value)
|
||||
}
|
||||
|
||||
currentStateId.value = stateId
|
||||
setActiveStateId(stateId)
|
||||
resetAutoHistory(stateId)
|
||||
const s = currentState.value
|
||||
if (!s) return
|
||||
@@ -749,11 +1044,31 @@ export default function useCommunicationsEngine() {
|
||||
}
|
||||
}
|
||||
|
||||
function resumePriorFlow() {
|
||||
function resumeStackedState() {
|
||||
const prev = flags.value.stack.pop()
|
||||
if (prev) moveTo(prev)
|
||||
}
|
||||
|
||||
function resumeLinearFlow(): boolean {
|
||||
const previousFlow = flowStack.value.pop()
|
||||
if (previousFlow) {
|
||||
try {
|
||||
setActiveFlow({ slug: previousFlow, mode: resolveFlowMode(previousFlow) }, { skipStack: true })
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[Engine] Failed to resume linear flow', err)
|
||||
}
|
||||
} else if (mainFlowSlug.value && activeFlowSlug.value !== mainFlowSlug.value) {
|
||||
try {
|
||||
setActiveFlow({ slug: mainFlowSlug.value, mode: 'main' }, { skipStack: true })
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[Engine] Failed to restore main flow', err)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function speak(speaker: Role, tpl: string, stateId: string, options: { radioCheck?: boolean, offSchema?: boolean } = {}) {
|
||||
const msg = renderTpl(tpl, exposeCtx())
|
||||
const entry: EngineLog = {
|
||||
@@ -764,7 +1079,8 @@ export default function useCommunicationsEngine() {
|
||||
normalized: normalizeATCText(msg, exposeCtxFlat()),
|
||||
state: stateId,
|
||||
radioCheck: options.radioCheck,
|
||||
offSchema: options.offSchema
|
||||
offSchema: options.offSchema,
|
||||
flow: activeFlowSlug.value || undefined,
|
||||
}
|
||||
communicationLog.value.push(entry)
|
||||
}
|
||||
@@ -855,7 +1171,11 @@ export default function useCommunicationsEngine() {
|
||||
nextCandidates,
|
||||
activeFrequency,
|
||||
communicationLog: readonly(communicationLog),
|
||||
clearCommunicationLog: () => { communicationLog.value = [] },
|
||||
clearCommunicationLog: () => { assignCommunicationLog([]) },
|
||||
activeFlow,
|
||||
availableFlows,
|
||||
sessionId: readonly(sessionId),
|
||||
lastDecisionTrace: readonly(lastDecisionTrace),
|
||||
|
||||
// pm_alt.vue integration
|
||||
flightContext: readonly(flightContext),
|
||||
@@ -865,7 +1185,9 @@ export default function useCommunicationsEngine() {
|
||||
initializeFlight,
|
||||
updateFrequencyVariables,
|
||||
loadRuntimeTree,
|
||||
loadRuntimeSystem,
|
||||
fetchRuntimeTree,
|
||||
setActiveFlow,
|
||||
isReady,
|
||||
|
||||
// Communication
|
||||
@@ -876,7 +1198,6 @@ export default function useCommunicationsEngine() {
|
||||
|
||||
// Flow Control
|
||||
moveTo,
|
||||
resumePriorFlow,
|
||||
|
||||
// Utilities
|
||||
normalizeATCText,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// utils/openaiDecision.ts
|
||||
import type { LLMDecision, LLMDecisionInput } from '../types/llm'
|
||||
import type { LLMDecisionInput, LLMDecisionResult } from '../types/llm'
|
||||
|
||||
/** Client-seitig: ruft den Backend-Endpunkt auf */
|
||||
export async function decideNextStateLLM(input: LLMDecisionInput): Promise<LLMDecision> {
|
||||
return await $fetch<LLMDecision>('/api/llm/decide', {
|
||||
export async function decideNextStateLLM(input: LLMDecisionInput): Promise<LLMDecisionResult> {
|
||||
return await $fetch<LLMDecisionResult>('/api/llm/decide', {
|
||||
method: 'POST',
|
||||
body: input
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user