Build decision flow editor and runtime integration

This commit is contained in:
Remi
2025-09-20 18:33:32 +02:00
parent 6999235668
commit cf6748b9bc
23 changed files with 4030 additions and 115 deletions

View File

@@ -0,0 +1,12 @@
import { createError } from 'h3'
import { buildRuntimeDecisionTree } from '../../../services/decisionFlowService'
export default defineEventHandler(async (event) => {
const slugParam = event.context.params?.slug
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const tree = await buildRuntimeDecisionTree(slugParam.trim())
return tree
})

View File

@@ -0,0 +1,8 @@
import { listDecisionFlows } from '../../services/decisionFlowService'
import { requireAdmin } from '../../utils/auth'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const flows = await listDecisionFlows()
return flows
})

View File

@@ -0,0 +1,71 @@
import { readBody, createError } from 'h3'
import { requireAdmin } from '../../utils/auth'
import { DecisionFlow } from '../../models/DecisionFlow'
import { getFlowWithNodes } from '../../services/decisionFlowService'
function sanitizeSlug(input: string) {
return input.toLowerCase().replace(/[^a-z0-9-_]/gi, '-')
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await readBody<Record<string, any>>(event)
const rawSlug = typeof body.slug === 'string' ? body.slug.trim() : ''
const slug = rawSlug ? sanitizeSlug(rawSlug) : ''
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Slug is required' })
}
const rawName = typeof body.name === 'string' ? body.name.trim() : ''
if (!rawName) {
throw createError({ statusCode: 400, statusMessage: 'Name is required' })
}
const existing = await DecisionFlow.findOne({ slug })
if (existing) {
throw createError({ statusCode: 409, statusMessage: 'A decision flow with this slug already exists' })
}
const startState = (typeof body.startState === 'string' ? body.startState.trim() : '') || 'START'
const description = typeof body.description === 'string' ? body.description.trim() : undefined
const schemaVersion = (typeof body.schemaVersion === 'string' ? body.schemaVersion.trim() : '') || '1.0'
const roles = Array.isArray(body.roles)
? body.roles
.map((role: any) => (typeof role === 'string' ? role.trim() : ''))
.filter((role: string) => role.length)
: ['pilot', 'atc', 'system']
const phases = Array.isArray(body.phases)
? body.phases
.map((phase: any) => (typeof phase === 'string' ? phase.trim() : ''))
.filter((phase: string) => phase.length)
: []
const endStates = Array.isArray(body.endStates)
? body.endStates
.map((state: any) => (typeof state === 'string' ? state.trim() : ''))
.filter((state: string) => state.length)
: [startState]
const flow = new DecisionFlow({
slug,
name: rawName,
description,
schemaVersion,
startState,
endStates,
variables: body.variables && typeof body.variables === 'object' ? body.variables : {},
flags: body.flags && typeof body.flags === 'object' ? body.flags : {},
policies: body.policies && typeof body.policies === 'object' ? body.policies : {},
hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {},
roles,
phases,
})
await flow.save()
const { flow: serialized } = await getFlowWithNodes(slug)
return serialized
})

View File

@@ -0,0 +1,14 @@
import { createError } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { getFlowWithNodes } from '../../../../services/decisionFlowService'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slug = event.context.params?.slug
if (typeof slug !== 'string' || !slug.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const data = await getFlowWithNodes(slug.trim())
return data
})

View File

@@ -0,0 +1,150 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { DecisionFlow } from '../../../../models/DecisionFlow'
import { getFlowWithNodes } from '../../../../services/decisionFlowService'
function sanitizeStringArray(values: any): string[] | undefined {
if (!Array.isArray(values)) return undefined
const mapped = values
.map((value: any) => (typeof value === 'string' ? value.trim() : ''))
.filter((value: string) => value.length)
if (!mapped.length) return undefined
return Array.from(new Set(mapped))
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const slug = slugParam.trim()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const body = await readBody<Record<string, any>>(event)
if (typeof body.name === 'string' && body.name.trim()) {
flow.name = body.name.trim()
}
if (typeof body.description === 'string') {
flow.description = body.description.trim() || undefined
}
if (typeof body.schemaVersion === 'string') {
const version = body.schemaVersion.trim()
flow.schemaVersion = version || flow.schemaVersion
}
if (typeof body.startState === 'string' && body.startState.trim()) {
flow.startState = body.startState.trim()
}
const endStates = sanitizeStringArray(body.endStates)
if (endStates) {
flow.endStates = endStates
}
const roles = sanitizeStringArray(body.roles)
if (roles) {
flow.roles = roles
}
const phases = sanitizeStringArray(body.phases)
if (phases) {
flow.phases = phases
}
if (body.variables && typeof body.variables === 'object') {
flow.variables = body.variables
flow.markModified('variables')
}
if (body.flags && typeof body.flags === 'object') {
flow.flags = body.flags
flow.markModified('flags')
}
if (body.policies && typeof body.policies === 'object') {
flow.policies = body.policies
flow.markModified('policies')
}
if (body.hooks && typeof body.hooks === 'object') {
flow.hooks = body.hooks
flow.markModified('hooks')
}
if (body.layout && typeof body.layout === 'object') {
const layout = flow.layout || { zoom: 1, pan: { x: 0, y: 0 }, groups: [] }
const zoomValue = body.layout.zoom
const zoom = typeof zoomValue === 'number' ? zoomValue : Number(zoomValue)
if (Number.isFinite(zoom)) {
layout.zoom = Math.min(Math.max(zoom, 0.25), 3)
}
if (body.layout.pan && typeof body.layout.pan === 'object') {
const panX = body.layout.pan.x
const panY = body.layout.pan.y
const parsedX = typeof panX === 'number' ? panX : Number(panX)
const parsedY = typeof panY === 'number' ? panY : Number(panY)
if (Number.isFinite(parsedX)) layout.pan = layout.pan || { x: 0, y: 0 }
if (Number.isFinite(parsedX)) layout.pan.x = parsedX
if (Number.isFinite(parsedY)) layout.pan = layout.pan || { x: 0, y: 0 }
if (Number.isFinite(parsedY)) layout.pan.y = parsedY
}
if (Array.isArray(body.layout.groups)) {
layout.groups = body.layout.groups
.map((group: any) => {
if (!group || typeof group !== 'object') return null
const id = typeof group.id === 'string' ? group.id.trim() : ''
const label = typeof group.label === 'string' ? group.label.trim() : ''
if (!id || !label) return null
if (!group.bounds || typeof group.bounds !== 'object') return null
const bounds = {
x: Number(group.bounds.x) || 0,
y: Number(group.bounds.y) || 0,
width: Number(group.bounds.width) || 0,
height: Number(group.bounds.height) || 0,
}
return {
id,
label,
color: typeof group.color === 'string' ? group.color.trim() || undefined : undefined,
bounds,
}
})
.filter((group): group is NonNullable<typeof group> => Boolean(group))
}
flow.layout = layout
flow.markModified('layout')
}
if (body.metadata && typeof body.metadata === 'object') {
const metadata = flow.metadata || {}
if (typeof body.metadata.notes === 'string') {
metadata.notes = body.metadata.notes.trim() || undefined
}
if (Array.isArray(body.metadata.tags)) {
metadata.tags = body.metadata.tags
.map((tag: any) => (typeof tag === 'string' ? tag.trim() : ''))
.filter((tag: string) => tag.length)
}
if (typeof body.metadata.ownerId === 'string') {
metadata.ownerId = body.metadata.ownerId.trim() || undefined
}
if (typeof body.metadata.lastEditedBy === 'string') {
metadata.lastEditedBy = body.metadata.lastEditedBy.trim() || undefined
}
flow.metadata = metadata
flow.markModified('metadata')
}
await flow.save()
const data = await getFlowWithNodes(slug)
return data
})

View File

@@ -0,0 +1,101 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { DecisionFlow } from '../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../models/DecisionNode'
import {
sanitizeLayout,
sanitizeLLMTemplate,
sanitizeMetadata,
sanitizeTransition,
} from '../../../../utils/decisionSanitizer'
import { serializeNodeDocument } from '../../../../services/decisionFlowService'
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const slug = slugParam.trim()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const body = await readBody<Record<string, any>>(event)
const rawStateId = typeof body.stateId === 'string' ? body.stateId.trim() : ''
if (!rawStateId) {
throw createError({ statusCode: 400, statusMessage: 'stateId is required' })
}
const stateId = rawStateId.toUpperCase()
const existingNode = await DecisionNode.findOne({ flow: flow._id, stateId })
if (existingNode) {
throw createError({ statusCode: 409, statusMessage: 'State already exists in this flow' })
}
const role = typeof body.role === 'string' ? body.role.trim().toLowerCase() : ''
if (!ROLE_SET.has(role)) {
throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' })
}
const phase = typeof body.phase === 'string' ? body.phase.trim() : ''
if (!phase) {
throw createError({ statusCode: 400, statusMessage: 'phase is required' })
}
const transitions = Array.isArray(body.transitions)
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
: []
const layout = sanitizeLayout(body.layout) || { x: 0, y: 0 }
const metadata = sanitizeMetadata(body.metadata)
const llmTemplate = sanitizeLLMTemplate(body.llmTemplate)
const readbackRequired = Array.isArray(body.readbackRequired)
? body.readbackRequired
.map((entry: any) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry: string) => entry.length)
: []
const node = new DecisionNode({
flow: flow._id,
stateId,
title: typeof body.title === 'string' ? body.title.trim() || undefined : undefined,
summary: typeof body.summary === 'string' ? body.summary.trim() || undefined : undefined,
role,
phase,
sayTemplate: typeof body.sayTemplate === 'string' ? body.sayTemplate.trim() || undefined : undefined,
utteranceTemplate:
typeof body.utteranceTemplate === 'string' ? body.utteranceTemplate.trim() || undefined : undefined,
elseSayTemplate:
typeof body.elseSayTemplate === 'string' ? body.elseSayTemplate.trim() || undefined : undefined,
readbackRequired,
autoBehavior: typeof body.autoBehavior === 'string' ? body.autoBehavior.trim() || undefined : undefined,
actions: Array.isArray(body.actions) ? body.actions : [],
handoff:
body.handoff && typeof body.handoff === 'object' && typeof body.handoff.to === 'string'
? {
to: body.handoff.to.trim(),
freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined,
note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined,
}
: undefined,
guard: typeof body.guard === 'string' ? body.guard.trim() || undefined : undefined,
trigger: typeof body.trigger === 'string' ? body.trigger.trim() || undefined : undefined,
frequency: typeof body.frequency === 'string' ? body.frequency.trim() || undefined : undefined,
frequencyName:
typeof body.frequencyName === 'string' ? body.frequencyName.trim() || undefined : undefined,
transitions,
layout,
metadata,
llmTemplate,
})
await node.save()
return serializeNodeDocument(node)
})

View File

@@ -0,0 +1,31 @@
import { createError } from 'h3'
import { requireAdmin } from '../../../../../../utils/auth'
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../../../models/DecisionNode'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
const stateParam = event.context.params?.stateId
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
if (typeof stateParam !== 'string' || !stateParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
}
const slug = slugParam.trim()
const stateId = stateParam.trim().toUpperCase()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const result = await DecisionNode.deleteOne({ flow: flow._id, stateId })
if (!result.deletedCount) {
throw createError({ statusCode: 404, statusMessage: 'State not found' })
}
return { success: true }
})

View File

@@ -0,0 +1,137 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../../../utils/auth'
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../../../models/DecisionNode'
import {
sanitizeLayout,
sanitizeLLMTemplate,
sanitizeMetadata,
sanitizeTransition,
} from '../../../../../../utils/decisionSanitizer'
import { serializeNodeDocument } from '../../../../../../services/decisionFlowService'
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
const stateParam = event.context.params?.stateId
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
if (typeof stateParam !== 'string' || !stateParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
}
const slug = slugParam.trim()
const stateId = stateParam.trim().toUpperCase()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const node = await DecisionNode.findOne({ flow: flow._id, stateId })
if (!node) {
throw createError({ statusCode: 404, statusMessage: 'State not found' })
}
const body = await readBody<Record<string, any>>(event)
if (typeof body.title === 'string') {
node.title = body.title.trim() || undefined
}
if (typeof body.summary === 'string') {
node.summary = body.summary.trim() || undefined
}
if (typeof body.role === 'string') {
const role = body.role.trim().toLowerCase()
if (!ROLE_SET.has(role)) {
throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' })
}
node.role = role
}
if (typeof body.phase === 'string' && body.phase.trim()) {
node.phase = body.phase.trim()
}
if (typeof body.sayTemplate === 'string') {
node.sayTemplate = body.sayTemplate.trim() || undefined
}
if (typeof body.utteranceTemplate === 'string') {
node.utteranceTemplate = body.utteranceTemplate.trim() || undefined
}
if (typeof body.elseSayTemplate === 'string') {
node.elseSayTemplate = body.elseSayTemplate.trim() || undefined
}
if (Array.isArray(body.readbackRequired)) {
node.readbackRequired = body.readbackRequired
.map((entry: any) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry: string) => entry.length)
}
if (typeof body.autoBehavior === 'string') {
node.autoBehavior = body.autoBehavior.trim() || undefined
}
if (Array.isArray(body.actions)) {
node.actions = body.actions
}
if (body.handoff && typeof body.handoff === 'object') {
if (typeof body.handoff.to === 'string' && body.handoff.to.trim()) {
node.handoff = {
to: body.handoff.to.trim(),
freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined,
note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined,
}
} else {
node.handoff = undefined
}
}
if (typeof body.guard === 'string') {
node.guard = body.guard.trim() || undefined
}
if (typeof body.trigger === 'string') {
node.trigger = body.trigger.trim() || undefined
}
if (typeof body.frequency === 'string') {
node.frequency = body.frequency.trim() || undefined
}
if (typeof body.frequencyName === 'string') {
node.frequencyName = body.frequencyName.trim() || undefined
}
if (Array.isArray(body.transitions)) {
node.transitions = body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
}
const layout = sanitizeLayout(body.layout)
if (layout) {
node.layout = layout
}
const metadata = sanitizeMetadata(body.metadata)
if (metadata) {
node.metadata = metadata
}
const llmTemplate = sanitizeLLMTemplate(body.llmTemplate)
if (llmTemplate) {
node.llmTemplate = llmTemplate
}
await node.save()
return serializeNodeDocument(node)
})

View File

@@ -0,0 +1,42 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../../../utils/auth'
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../../../models/DecisionNode'
import { sanitizeLayout } from '../../../../../../utils/decisionSanitizer'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
const stateParam = event.context.params?.stateId
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
if (typeof stateParam !== 'string' || !stateParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
}
const slug = slugParam.trim()
const stateId = stateParam.trim().toUpperCase()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const node = await DecisionNode.findOne({ flow: flow._id, stateId })
if (!node) {
throw createError({ statusCode: 404, statusMessage: 'State not found' })
}
const body = await readBody<Record<string, any>>(event)
const layoutUpdate = sanitizeLayout(body)
if (!layoutUpdate) {
throw createError({ statusCode: 400, statusMessage: 'Invalid layout payload' })
}
const existingLayout = node.layout || { x: 0, y: 0 }
node.layout = { ...existingLayout, ...layoutUpdate }
await node.save()
return { success: true, layout: node.layout }
})

View File

@@ -0,0 +1,19 @@
import { readBody } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { importATCDecisionTree } from '../../../services/decisionImportService'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await readBody<Record<string, any> | undefined>(event)
const { flow, importedStates } = await importATCDecisionTree({
slug: typeof body?.slug === 'string' ? body.slug : undefined,
name: typeof body?.name === 'string' ? body.name : undefined,
description: typeof body?.description === 'string' ? body.description : undefined,
})
return {
flow,
importedStates,
}
})