This commit is contained in:
itsrubberduck
2025-10-12 15:42:18 +02:00
27 changed files with 3211 additions and 2501 deletions

View 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,
},
}
})

View 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),
}
})

View File

@@ -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),

View File

@@ -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;

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
import { buildRuntimeDecisionSystem } from '../../services/decisionFlowService'
export default defineEventHandler(async () => {
const system = await buildRuntimeDecisionSystem()
return system
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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,

View File

@@ -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)

View File

@@ -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' })