mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 03:45:33 +08:00
Add session timeline logging and admin sessions view
This commit is contained in:
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),
|
||||
|
||||
@@ -31,14 +31,8 @@ interface PTTRequest {
|
||||
interface PTTResponse {
|
||||
success: boolean;
|
||||
transcription: string;
|
||||
decision?: {
|
||||
next_state: string;
|
||||
controller_say_tpl?: string;
|
||||
off_schema?: boolean;
|
||||
radio_check?: boolean;
|
||||
activate_flow?: string;
|
||||
resume_previous?: boolean;
|
||||
};
|
||||
decision?: LLMDecisionResult['decision'];
|
||||
trace?: LLMDecisionResult['trace'];
|
||||
}
|
||||
|
||||
async function sh(cmd: string, args: string[]) {
|
||||
@@ -228,6 +222,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
id: candidate.id,
|
||||
flow: candidate.flow || undefined,
|
||||
state: candidateState
|
||||
};
|
||||
})
|
||||
@@ -235,12 +230,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,
|
||||
@@ -270,6 +270,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (decision) {
|
||||
result.decision = decision;
|
||||
}
|
||||
if (decisionResult?.trace) {
|
||||
result.trace = decisionResult.trace;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>(
|
||||
{
|
||||
|
||||
@@ -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() },
|
||||
})
|
||||
|
||||
|
||||
@@ -32,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -157,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,
|
||||
@@ -203,6 +209,7 @@ async function buildRuntimeTreeForDoc(
|
||||
roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [],
|
||||
phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [],
|
||||
states,
|
||||
entry_mode: flowDoc.isMain ? 'main' : flowDoc.entryMode || 'parallel',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +252,9 @@ export async function buildRuntimeDecisionSystem(): Promise<RuntimeDecisionSyste
|
||||
}, {})
|
||||
|
||||
const order = runtimeTrees.map((tree) => tree.slug)
|
||||
const main = flows['icao_atc_decision_tree'] ? 'icao_atc_decision_tree' : order[0]
|
||||
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,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
// server/utils/openai.ts
|
||||
import OpenAI from 'openai'
|
||||
import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech'
|
||||
import type {LLMDecision, LLMDecisionInput} from '../../shared/types/llm'
|
||||
import type {
|
||||
CandidateTraceEntry,
|
||||
CandidateTraceStep,
|
||||
DecisionCandidateTimeline,
|
||||
FlowActivationInstruction,
|
||||
FlowActivationMode,
|
||||
LLMDecision,
|
||||
LLMDecisionInput,
|
||||
LLMDecisionTrace,
|
||||
LLMDecisionTraceCall,
|
||||
} from '../../shared/types/llm'
|
||||
import type { DecisionNodeCondition, DecisionNodeTrigger, RuntimeDecisionState, RuntimeDecisionSystem } from '../../shared/types/decision'
|
||||
import { buildRuntimeDecisionSystem } from '../services/decisionFlowService'
|
||||
import {getServerRuntimeConfig} from './runtimeConfig'
|
||||
@@ -64,23 +74,6 @@ export async function decide(system: string, user: string): Promise<string> {
|
||||
return r.choices?.[0]?.message?.content?.trim() || ''
|
||||
}
|
||||
|
||||
export interface LLMDecisionTraceCall {
|
||||
stage: 'readback-check' | 'decision'
|
||||
request: Record<string, any>
|
||||
response?: any
|
||||
rawResponseText?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface LLMDecisionTrace {
|
||||
calls: LLMDecisionTraceCall[]
|
||||
fallback?: {
|
||||
used: boolean
|
||||
reason?: string
|
||||
selected?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface LLMDecisionResult {
|
||||
decision: LLMDecision
|
||||
trace?: LLMDecisionTrace
|
||||
@@ -211,12 +204,18 @@ interface DecisionCandidate {
|
||||
id: string
|
||||
flow: string
|
||||
state: RuntimeDecisionState
|
||||
triggers: DecisionNodeTrigger[]
|
||||
regexTriggers: DecisionNodeTrigger[]
|
||||
noneTriggers: DecisionNodeTrigger[]
|
||||
}
|
||||
|
||||
interface PreparedCandidateResult {
|
||||
filteredCandidates: DecisionCandidate[]
|
||||
finalCandidates: DecisionCandidate[]
|
||||
candidateFlowMap: Map<string, string>
|
||||
activeFlowSlug: string
|
||||
flowEntryModes: Map<string, FlowActivationMode>
|
||||
timeline: DecisionCandidateTimeline
|
||||
autoSelected?: DecisionCandidate | null
|
||||
}
|
||||
|
||||
const RUNTIME_CACHE_TTL_MS = 5_000
|
||||
@@ -322,34 +321,48 @@ function parseComparable(raw: any): any {
|
||||
return raw
|
||||
}
|
||||
|
||||
function compareValuesSafe(left: any, operator: string | undefined, right: any): boolean {
|
||||
function compareValuesSafe(left: any, operator: string | undefined, right: any): {
|
||||
result: boolean
|
||||
left: any
|
||||
right: any
|
||||
operator: string
|
||||
} {
|
||||
const normalizedLeft = normalizeComparable(left)
|
||||
const normalizedRight = normalizeComparable(parseComparable(right))
|
||||
switch (operator) {
|
||||
const op = operator || '=='
|
||||
let result = false
|
||||
switch (op) {
|
||||
case '>':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft > normalizedRight
|
||||
: false
|
||||
break
|
||||
case '>=':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft >= normalizedRight
|
||||
: false
|
||||
break
|
||||
case '<':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft < normalizedRight
|
||||
: false
|
||||
break
|
||||
case '<=':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft <= normalizedRight
|
||||
: false
|
||||
break
|
||||
case '!==':
|
||||
case '!=':
|
||||
return normalizedLeft !== normalizedRight
|
||||
result = normalizedLeft !== normalizedRight
|
||||
break
|
||||
case '===':
|
||||
case '==':
|
||||
default:
|
||||
return normalizedLeft === normalizedRight
|
||||
result = normalizedLeft === normalizedRight
|
||||
break
|
||||
}
|
||||
return { result, left: normalizedLeft, right: normalizedRight, operator: op }
|
||||
}
|
||||
|
||||
function resolveContextPath(
|
||||
@@ -380,18 +393,39 @@ function evaluateConditionEntry(
|
||||
condition: DecisionNodeCondition | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> },
|
||||
utterance: string
|
||||
): boolean {
|
||||
if (!condition) return true
|
||||
): { passed: boolean; detail?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } {
|
||||
if (!condition) return { passed: true }
|
||||
switch (condition.type) {
|
||||
case 'regex':
|
||||
return evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
case 'regex_not':
|
||||
return !evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
case 'regex': {
|
||||
const passed = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
return {
|
||||
passed,
|
||||
detail: passed ? undefined : { condition },
|
||||
}
|
||||
}
|
||||
case 'regex_not': {
|
||||
const matched = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
const passed = !matched
|
||||
return {
|
||||
passed,
|
||||
detail: passed ? undefined : { condition },
|
||||
}
|
||||
}
|
||||
case 'variable_value':
|
||||
default: {
|
||||
const left = resolveContextPath(condition.variable, context)
|
||||
const operator = condition.operator || '=='
|
||||
return compareValuesSafe(left, operator, condition.value)
|
||||
const comparison = compareValuesSafe(left, condition.operator, condition.value)
|
||||
return {
|
||||
passed: comparison.result,
|
||||
detail: comparison.result
|
||||
? undefined
|
||||
: {
|
||||
condition,
|
||||
actualValue: comparison.left,
|
||||
expectedValue: comparison.right,
|
||||
operator: comparison.operator,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,48 +434,26 @@ function evaluateConditionList(
|
||||
conditions: DecisionNodeCondition[] | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> },
|
||||
utterance: string
|
||||
): boolean {
|
||||
): { passed: boolean; failure?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } {
|
||||
if (!Array.isArray(conditions) || conditions.length === 0) {
|
||||
return true
|
||||
return { passed: true }
|
||||
}
|
||||
const ordered = [...conditions].sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0))
|
||||
for (const condition of ordered) {
|
||||
if (!evaluateConditionEntry(condition, context, utterance)) {
|
||||
return false
|
||||
const result = evaluateConditionEntry(condition, context, utterance)
|
||||
if (!result.passed) {
|
||||
return {
|
||||
passed: false,
|
||||
failure: {
|
||||
condition,
|
||||
actualValue: result.detail?.actualValue,
|
||||
expectedValue: result.detail?.expectedValue,
|
||||
operator: result.detail?.operator,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function filterDecisionCandidates(
|
||||
candidates: DecisionCandidate[],
|
||||
utterance: string,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> }
|
||||
): DecisionCandidate[] {
|
||||
if (!candidates.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const regexMatches: DecisionCandidate[] = []
|
||||
const noneMatches: DecisionCandidate[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const { matchesRegex, matchesNone } = analyzeTriggers(candidate.state?.triggers, utterance)
|
||||
if (matchesRegex) {
|
||||
regexMatches.push(candidate)
|
||||
} else if (matchesNone) {
|
||||
noneMatches.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const pool = regexMatches.length > 0 ? regexMatches : noneMatches
|
||||
if (!pool.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pool.filter(candidate =>
|
||||
evaluateConditionList(candidate.state?.conditions, context, utterance)
|
||||
)
|
||||
return { passed: true }
|
||||
}
|
||||
|
||||
async function prepareDecisionCandidates(
|
||||
@@ -465,12 +477,42 @@ async function prepareDecisionCandidates(
|
||||
activeFlowSlug = system.main || Object.keys(system.flows)[0] || ''
|
||||
}
|
||||
|
||||
const uniqueCandidates = new Map<string, DecisionCandidate>()
|
||||
const flowEntryModes = new Map<string, FlowActivationMode>()
|
||||
for (const [slug, tree] of Object.entries(system.flows || {})) {
|
||||
const mode = tree.entry_mode === 'main'
|
||||
? 'main'
|
||||
: tree.entry_mode === 'linear'
|
||||
? 'linear'
|
||||
: slug === system.main
|
||||
? 'main'
|
||||
: 'parallel'
|
||||
flowEntryModes.set(slug, mode)
|
||||
}
|
||||
|
||||
const addCandidate = (candidate: DecisionCandidate | null | undefined) => {
|
||||
if (!candidate || !candidate.id || !candidate.state) return
|
||||
if (uniqueCandidates.has(candidate.id)) return
|
||||
uniqueCandidates.set(candidate.id, candidate)
|
||||
const candidateMap = new Map<string, DecisionCandidate>()
|
||||
|
||||
const createCandidate = (id: string, flow: string | undefined, state: RuntimeDecisionState | undefined): DecisionCandidate | null => {
|
||||
if (!id || !state) return null
|
||||
const triggers = Array.isArray(state.triggers) ? state.triggers.filter(Boolean) : []
|
||||
const regexTriggers = triggers.filter(trigger => trigger?.type === 'regex')
|
||||
const noneTriggers = triggers.filter(trigger => trigger?.type === 'none')
|
||||
return {
|
||||
id,
|
||||
flow: flow || activeFlowSlug,
|
||||
state,
|
||||
triggers,
|
||||
regexTriggers,
|
||||
noneTriggers,
|
||||
}
|
||||
}
|
||||
|
||||
const addCandidate = (id: string | undefined, flow: string | undefined, state: RuntimeDecisionState | undefined) => {
|
||||
if (!id) return
|
||||
if (candidateMap.has(id)) return
|
||||
const candidate = createCandidate(id, flow, state)
|
||||
if (candidate) {
|
||||
candidateMap.set(id, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for (const raw of input.candidates || []) {
|
||||
@@ -478,14 +520,13 @@ async function prepareDecisionCandidates(
|
||||
const indexed = index.get(raw.id)
|
||||
const flow = raw.flow || indexed?.flow || activeFlowSlug
|
||||
const state = indexed?.state ? { ...indexed.state } : raw.state
|
||||
if (!state) continue
|
||||
addCandidate({ id: raw.id, flow: flow || activeFlowSlug, state })
|
||||
addCandidate(raw.id, flow, state)
|
||||
}
|
||||
|
||||
for (const raw of input.candidates || []) {
|
||||
if (!raw?.id || !raw.state) continue
|
||||
if (!uniqueCandidates.has(raw.id)) {
|
||||
addCandidate({ id: raw.id, flow: raw.flow || activeFlowSlug, state: raw.state })
|
||||
if (!candidateMap.has(raw.id)) {
|
||||
addCandidate(raw.id, raw.flow || activeFlowSlug, raw.state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,25 +534,211 @@ async function prepareDecisionCandidates(
|
||||
const startStateId = tree.start_state
|
||||
if (!startStateId) continue
|
||||
const indexed = index.get(startStateId)
|
||||
if (!indexed) continue
|
||||
addCandidate({ id: startStateId, flow: flowSlug, state: { ...indexed.state } })
|
||||
const state = indexed?.state ? { ...indexed.state } : tree.states?.[startStateId]
|
||||
addCandidate(startStateId, flowSlug, state)
|
||||
}
|
||||
|
||||
const candidates = Array.from(uniqueCandidates.values())
|
||||
const candidates = Array.from(candidateMap.values())
|
||||
const context = { variables: input.variables || {}, flags: input.flags || {} }
|
||||
const filteredCandidates = filterDecisionCandidates(candidates, utterance, context)
|
||||
const timelineSteps: CandidateTraceStep[] = []
|
||||
let fallbackUsed = false
|
||||
|
||||
const toTraceEntry = (candidate: DecisionCandidate): CandidateTraceEntry => ({
|
||||
id: candidate.id,
|
||||
flow: candidate.flow,
|
||||
name: candidate.state?.name,
|
||||
summary: candidate.state?.summary,
|
||||
role: candidate.state?.role,
|
||||
triggers: candidate.triggers,
|
||||
conditions: candidate.state?.conditions || [],
|
||||
})
|
||||
|
||||
const recordStep = (
|
||||
stage: CandidateTraceStage,
|
||||
label: string,
|
||||
stepCandidates: DecisionCandidate[],
|
||||
eliminated: CandidateTraceElimination[] = [],
|
||||
note?: string
|
||||
) => {
|
||||
timelineSteps.push({
|
||||
stage,
|
||||
label,
|
||||
candidates: stepCandidates.map(toTraceEntry),
|
||||
eliminated: eliminated.length ? eliminated : undefined,
|
||||
note,
|
||||
})
|
||||
}
|
||||
|
||||
const regexCandidates = candidates.filter(candidate => candidate.regexTriggers.length > 0)
|
||||
let workingSet: DecisionCandidate[] = []
|
||||
|
||||
if (regexCandidates.length > 0) {
|
||||
recordStep('regex_candidates', 'Regex candidates', regexCandidates)
|
||||
|
||||
const survivors: DecisionCandidate[] = []
|
||||
const eliminated: CandidateTraceElimination[] = []
|
||||
|
||||
for (const candidate of regexCandidates) {
|
||||
const matched = candidate.regexTriggers.some(trigger =>
|
||||
evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)
|
||||
)
|
||||
if (matched) {
|
||||
survivors.push(candidate)
|
||||
} else {
|
||||
eliminated.push({
|
||||
candidate: toTraceEntry(candidate),
|
||||
kind: 'regex',
|
||||
reason: 'No regex trigger matched the pilot utterance.',
|
||||
context: {
|
||||
patterns: candidate.regexTriggers.map(trigger => ({
|
||||
id: trigger.id,
|
||||
pattern: trigger.pattern,
|
||||
flags: trigger.patternFlags,
|
||||
})),
|
||||
transcript: utterance,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
recordStep(
|
||||
'regex_filtered',
|
||||
'Regex evaluation',
|
||||
survivors,
|
||||
eliminated,
|
||||
survivors.length ? undefined : 'No regex triggers matched the pilot transmission.'
|
||||
)
|
||||
|
||||
workingSet = survivors
|
||||
} else {
|
||||
recordStep('regex_candidates', 'Regex candidates', [], [], 'No regex-triggered transitions available.')
|
||||
workingSet = []
|
||||
}
|
||||
|
||||
let finalCandidates: DecisionCandidate[] = []
|
||||
|
||||
if (workingSet.length > 0) {
|
||||
const survivors: DecisionCandidate[] = []
|
||||
const eliminated: CandidateTraceElimination[] = []
|
||||
|
||||
for (const candidate of workingSet) {
|
||||
const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance)
|
||||
if (evaluation.passed) {
|
||||
survivors.push(candidate)
|
||||
} else if (evaluation.failure) {
|
||||
eliminated.push({
|
||||
candidate: toTraceEntry(candidate),
|
||||
kind: 'condition',
|
||||
reason: 'Node conditions were not satisfied.',
|
||||
context: {
|
||||
condition: evaluation.failure.condition,
|
||||
actualValue: evaluation.failure.actualValue,
|
||||
expectedValue: evaluation.failure.expectedValue,
|
||||
operator: evaluation.failure.operator,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
eliminated.push({
|
||||
candidate: toTraceEntry(candidate),
|
||||
kind: 'condition',
|
||||
reason: 'Node conditions were not satisfied.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
recordStep(
|
||||
'condition_filtered',
|
||||
'Condition evaluation',
|
||||
survivors,
|
||||
eliminated,
|
||||
survivors.length ? undefined : 'All regex candidates failed their conditions.'
|
||||
)
|
||||
|
||||
finalCandidates = survivors
|
||||
}
|
||||
|
||||
if (finalCandidates.length === 0) {
|
||||
fallbackUsed = true
|
||||
const fallbackCandidates = candidates.filter(candidate =>
|
||||
candidate.noneTriggers.length > 0 || (candidate.triggers.length === 0 && candidate.regexTriggers.length === 0)
|
||||
)
|
||||
|
||||
if (fallbackCandidates.length > 0) {
|
||||
recordStep('fallback_candidates', 'Fallback candidates', fallbackCandidates)
|
||||
|
||||
const survivors: DecisionCandidate[] = []
|
||||
const eliminated: CandidateTraceElimination[] = []
|
||||
|
||||
for (const candidate of fallbackCandidates) {
|
||||
const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance)
|
||||
if (evaluation.passed) {
|
||||
survivors.push(candidate)
|
||||
} else if (evaluation.failure) {
|
||||
eliminated.push({
|
||||
candidate: toTraceEntry(candidate),
|
||||
kind: 'condition',
|
||||
reason: 'Node conditions were not satisfied.',
|
||||
context: {
|
||||
condition: evaluation.failure.condition,
|
||||
actualValue: evaluation.failure.actualValue,
|
||||
expectedValue: evaluation.failure.expectedValue,
|
||||
operator: evaluation.failure.operator,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
eliminated.push({
|
||||
candidate: toTraceEntry(candidate),
|
||||
kind: 'condition',
|
||||
reason: 'Node conditions were not satisfied.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
recordStep(
|
||||
'fallback_filtered',
|
||||
'Fallback evaluation',
|
||||
survivors,
|
||||
eliminated,
|
||||
survivors.length ? undefined : 'No fallback candidates satisfied their conditions.'
|
||||
)
|
||||
|
||||
finalCandidates = survivors
|
||||
} else {
|
||||
recordStep('fallback_candidates', 'Fallback candidates', [], [], 'No fallback triggers defined.')
|
||||
recordStep('fallback_filtered', 'Fallback evaluation', [], [], 'No fallback candidates available.')
|
||||
}
|
||||
}
|
||||
|
||||
recordStep(
|
||||
'final',
|
||||
'Final candidates',
|
||||
finalCandidates,
|
||||
[],
|
||||
finalCandidates.length ? undefined : 'No transitions remain after evaluation.'
|
||||
)
|
||||
|
||||
const autoSelected = finalCandidates.length === 1 ? finalCandidates[0] : null
|
||||
|
||||
const candidateFlowMap = new Map<string, string>()
|
||||
for (const candidate of filteredCandidates) {
|
||||
for (const candidate of finalCandidates) {
|
||||
if (candidate.flow) {
|
||||
candidateFlowMap.set(candidate.id, candidate.flow)
|
||||
}
|
||||
}
|
||||
|
||||
const timeline: DecisionCandidateTimeline = {
|
||||
steps: timelineSteps,
|
||||
fallbackUsed,
|
||||
autoSelected: autoSelected ? toTraceEntry(autoSelected) : null,
|
||||
}
|
||||
|
||||
return {
|
||||
filteredCandidates,
|
||||
finalCandidates,
|
||||
candidateFlowMap,
|
||||
activeFlowSlug,
|
||||
flowEntryModes,
|
||||
timeline,
|
||||
autoSelected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,28 +876,74 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
|
||||
const prepared = await prepareDecisionCandidates(input, pilotUtterance)
|
||||
candidateFlowMap = prepared.candidateFlowMap
|
||||
const flowEntryModes = prepared.flowEntryModes
|
||||
if (prepared.activeFlowSlug) {
|
||||
activeFlowSlug = prepared.activeFlowSlug
|
||||
input.flow_slug = prepared.activeFlowSlug
|
||||
}
|
||||
input.candidates = prepared.filteredCandidates.map(candidate => ({
|
||||
trace.candidateTimeline = prepared.timeline
|
||||
if (prepared.autoSelected) {
|
||||
trace.autoSelection = {
|
||||
id: prepared.autoSelected.id,
|
||||
flow: prepared.autoSelected.flow,
|
||||
reason: 'Single candidate remained after trigger and condition evaluation.',
|
||||
}
|
||||
}
|
||||
input.candidates = prepared.finalCandidates.map(candidate => ({
|
||||
id: candidate.id,
|
||||
state: candidate.state,
|
||||
flow: candidate.flow,
|
||||
}))
|
||||
|
||||
const finalize = (decision: LLMDecision): LLMDecisionResult => {
|
||||
const targetState = decision.next_state
|
||||
if (targetState) {
|
||||
const targetFlow = candidateFlowMap.get(targetState)
|
||||
if (targetFlow && targetFlow !== activeFlowSlug) {
|
||||
decision.activate_flow = targetFlow
|
||||
const resolveActivationInstruction = (
|
||||
value?: string | FlowActivationInstruction | null
|
||||
): FlowActivationInstruction | undefined => {
|
||||
if (!value) return undefined
|
||||
if (typeof value === 'string') {
|
||||
const normalizedMode = flowEntryModes.get(value)
|
||||
|| (value === prepared.activeFlowSlug ? 'main' : undefined)
|
||||
return {
|
||||
slug: value,
|
||||
mode: normalizedMode || 'parallel',
|
||||
}
|
||||
}
|
||||
if (!trace.calls.length && !trace.fallback) {
|
||||
return {decision}
|
||||
if (!value.slug) return undefined
|
||||
const normalizedMode = value.mode
|
||||
|| flowEntryModes.get(value.slug)
|
||||
|| (value.slug === prepared.activeFlowSlug ? 'main' : undefined)
|
||||
return {
|
||||
slug: value.slug,
|
||||
mode: normalizedMode || 'parallel',
|
||||
}
|
||||
return {decision, trace}
|
||||
}
|
||||
|
||||
const finalize = (decision: LLMDecision): LLMDecisionResult => {
|
||||
const targetState = decision.next_state
|
||||
let activation = resolveActivationInstruction(decision.activate_flow as any)
|
||||
if (!activation && targetState) {
|
||||
const targetFlow = candidateFlowMap.get(targetState)
|
||||
if (targetFlow && targetFlow !== activeFlowSlug) {
|
||||
activation = resolveActivationInstruction(targetFlow)
|
||||
}
|
||||
}
|
||||
|
||||
if (activation) {
|
||||
decision.activate_flow = activation
|
||||
} else if (decision.activate_flow) {
|
||||
delete (decision as any).activate_flow
|
||||
}
|
||||
|
||||
const shouldAttachTrace = Boolean(
|
||||
trace.calls.length
|
||||
|| trace.fallback
|
||||
|| (trace.candidateTimeline && trace.candidateTimeline.steps.length)
|
||||
|| trace.autoSelection
|
||||
)
|
||||
|
||||
if (!shouldAttachTrace) {
|
||||
return { decision }
|
||||
}
|
||||
return { decision, trace }
|
||||
}
|
||||
|
||||
async function handleReadbackCheck(): Promise<LLMDecisionResult> {
|
||||
@@ -799,24 +1072,6 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
}
|
||||
}
|
||||
|
||||
// Instant detection without the LLM for common cases
|
||||
if (pilotText.includes('radio check') || pilotText.includes('signal test') ||
|
||||
(pilotText.includes('read') && (pilotText.includes('check') || pilotText.includes('you')))) {
|
||||
return finalize({
|
||||
next_state: input.state_id,
|
||||
radio_check: true,
|
||||
controller_say_tpl: `${input.variables.callsign}, read you five by five.`
|
||||
})
|
||||
}
|
||||
|
||||
// Emergency ohne LLM
|
||||
if (pilotText.startsWith('mayday') && input.flags.in_air) {
|
||||
return finalize({next_state: 'INT_MAYDAY'})
|
||||
}
|
||||
if (pilotText.startsWith('pan pan') && input.flags.in_air) {
|
||||
return finalize({next_state: 'INT_PANPAN'})
|
||||
}
|
||||
|
||||
const optimizedInput = optimizeInputForLLM(input)
|
||||
|
||||
// Check whether the next states require ATC responses
|
||||
|
||||
Reference in New Issue
Block a user