mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
Build decision flow editor and runtime integration
This commit is contained in:
203
server/services/decisionFlowService.ts
Normal file
203
server/services/decisionFlowService.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createError } from 'h3'
|
||||
import { DecisionFlow, type DecisionFlowDocument } from '../models/DecisionFlow'
|
||||
import { DecisionNode, type DecisionNodeDocument } from '../models/DecisionNode'
|
||||
import type {
|
||||
DecisionFlowModel,
|
||||
DecisionFlowSummary,
|
||||
DecisionNodeModel,
|
||||
DecisionNodeTransition,
|
||||
RuntimeDecisionAutoTransition,
|
||||
RuntimeDecisionState,
|
||||
RuntimeDecisionTree,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): DecisionFlowModel {
|
||||
return {
|
||||
id: String(doc._id),
|
||||
slug: doc.slug,
|
||||
name: doc.name,
|
||||
description: doc.description || undefined,
|
||||
schemaVersion: doc.schemaVersion || undefined,
|
||||
startState: doc.startState,
|
||||
endStates: Array.isArray(doc.endStates) ? doc.endStates : [],
|
||||
variables: doc.variables || {},
|
||||
flags: doc.flags || {},
|
||||
policies: doc.policies || {},
|
||||
hooks: doc.hooks || {},
|
||||
roles: Array.isArray(doc.roles) ? doc.roles : [],
|
||||
phases: Array.isArray(doc.phases) ? doc.phases : [],
|
||||
layout: doc.layout || undefined,
|
||||
metadata: doc.metadata || undefined,
|
||||
createdAt: doc.createdAt?.toISOString?.() || new Date().toISOString(),
|
||||
updatedAt: doc.updatedAt?.toISOString?.() || new Date().toISOString(),
|
||||
nodeCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeNodeDocument(doc: DecisionNodeDocument): DecisionNodeModel {
|
||||
const obj = doc.toObject<DecisionNodeDocument>({ virtuals: false })
|
||||
return {
|
||||
stateId: obj.stateId,
|
||||
title: obj.title || undefined,
|
||||
summary: obj.summary || undefined,
|
||||
role: obj.role as any,
|
||||
phase: obj.phase,
|
||||
sayTemplate: obj.sayTemplate || undefined,
|
||||
utteranceTemplate: obj.utteranceTemplate || undefined,
|
||||
elseSayTemplate: obj.elseSayTemplate || undefined,
|
||||
readbackRequired: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : [],
|
||||
autoBehavior: obj.autoBehavior || undefined,
|
||||
actions: Array.isArray(obj.actions) ? obj.actions : [],
|
||||
handoff: obj.handoff || undefined,
|
||||
guard: obj.guard || undefined,
|
||||
trigger: obj.trigger || undefined,
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
transitions: Array.isArray(obj.transitions) ? obj.transitions : [],
|
||||
layout: obj.layout || undefined,
|
||||
metadata: obj.metadata || undefined,
|
||||
llmTemplate: obj.llmTemplate || undefined,
|
||||
createdAt: obj.createdAt?.toISOString?.(),
|
||||
updatedAt: obj.updatedAt?.toISOString?.(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDecisionFlows(): Promise<DecisionFlowSummary[]> {
|
||||
const flows = await DecisionFlow.find().sort({ updatedAt: -1 }).lean()
|
||||
if (!flows.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ids = flows.map((flow) => flow._id)
|
||||
const counts = await DecisionNode.aggregate([
|
||||
{ $match: { flow: { $in: ids } } },
|
||||
{ $group: { _id: '$flow', count: { $sum: 1 } } },
|
||||
])
|
||||
|
||||
const countMap = counts.reduce<Record<string, number>>((acc, entry) => {
|
||||
acc[String(entry._id)] = entry.count
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return flows.map((flow) => ({
|
||||
id: String(flow._id),
|
||||
slug: flow.slug,
|
||||
name: flow.name,
|
||||
description: flow.description || undefined,
|
||||
startState: flow.startState,
|
||||
nodeCount: countMap[String(flow._id)] || 0,
|
||||
updatedAt: flow.updatedAt?.toISOString?.() || new Date().toISOString(),
|
||||
createdAt: flow.createdAt?.toISOString?.() || new Date().toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getFlowWithNodes(slug: string): Promise<{ flow: DecisionFlowModel; nodes: DecisionNodeModel[] }> {
|
||||
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 }).sort({ stateId: 1 })
|
||||
const flow = serializeFlowDocument(flowDoc, nodes.length)
|
||||
const serializedNodes = nodes.map((node) => serializeNodeDocument(node))
|
||||
|
||||
return { flow, nodes: serializedNodes }
|
||||
}
|
||||
|
||||
function toRuntimeTransitions(
|
||||
transitions: DecisionNodeTransition[],
|
||||
types: Array<DecisionNodeTransition['type']>,
|
||||
includeAuto = false
|
||||
) {
|
||||
return transitions
|
||||
.filter((transition) => types.includes(transition.type) || (includeAuto && transition.type === 'auto'))
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((transition) => ({
|
||||
to: transition.target,
|
||||
label: transition.label || undefined,
|
||||
when: transition.condition || undefined,
|
||||
guard: transition.guard || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function toRuntimeTimers(transitions: DecisionNodeTransition[]): RuntimeDecisionState['timer_next'] {
|
||||
return transitions
|
||||
.filter((transition) => transition.type === 'timer' && transition.timer)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((transition) => ({
|
||||
to: transition.target,
|
||||
after_s: transition.timer?.afterSeconds ?? 0,
|
||||
label: transition.label || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function toRuntimeAutoTransitions(transitions: DecisionNodeTransition[]): RuntimeDecisionAutoTransition[] {
|
||||
return transitions
|
||||
.filter((transition) => transition.autoTrigger)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((transition) => ({
|
||||
id: transition.key,
|
||||
to: transition.target,
|
||||
label: transition.label || undefined,
|
||||
description: transition.description || undefined,
|
||||
condition: transition.condition || undefined,
|
||||
guard: transition.guard || undefined,
|
||||
trigger: transition.autoTrigger || null,
|
||||
metadata: transition.metadata || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState {
|
||||
const obj = node.toObject<DecisionNodeDocument>({ virtuals: false })
|
||||
const transitions = Array.isArray(obj.transitions) ? obj.transitions : []
|
||||
|
||||
return {
|
||||
role: obj.role as any,
|
||||
phase: obj.phase,
|
||||
say_tpl: obj.sayTemplate || undefined,
|
||||
utterance_tpl: obj.utteranceTemplate || undefined,
|
||||
else_say_tpl: obj.elseSayTemplate || undefined,
|
||||
next: toRuntimeTransitions(transitions, ['next'], true),
|
||||
ok_next: toRuntimeTransitions(transitions, ['ok']),
|
||||
bad_next: toRuntimeTransitions(transitions, ['bad']),
|
||||
timer_next: toRuntimeTimers(transitions),
|
||||
auto: obj.autoBehavior || null,
|
||||
readback_required: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : undefined,
|
||||
actions: Array.isArray(obj.actions) ? obj.actions : undefined,
|
||||
handoff: obj.handoff || undefined,
|
||||
guard: obj.guard || undefined,
|
||||
trigger: obj.trigger || undefined,
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
auto_transitions: toRuntimeAutoTransitions(transitions),
|
||||
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 })
|
||||
const states = nodes.reduce<Record<string, RuntimeDecisionState>>((acc, node) => {
|
||||
acc[node.stateId] = serializeRuntimeState(node)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
schema_version: flowDoc.schemaVersion || '1.0',
|
||||
name: flowDoc.slug,
|
||||
description: flowDoc.description || undefined,
|
||||
start_state: flowDoc.startState,
|
||||
end_states: Array.isArray(flowDoc.endStates) ? flowDoc.endStates : [],
|
||||
variables: flowDoc.variables || {},
|
||||
flags: flowDoc.flags || {},
|
||||
policies: flowDoc.policies || {},
|
||||
hooks: flowDoc.hooks || {},
|
||||
roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [],
|
||||
phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [],
|
||||
states,
|
||||
}
|
||||
}
|
||||
197
server/services/decisionImportService.ts
Normal file
197
server/services/decisionImportService.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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'
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
flow: serializedFlow,
|
||||
nodes,
|
||||
importedStates: nodes.length,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user