mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Cleanup old unused code and add id sessionId to /api/atc/ptt
This commit is contained in:
@@ -1105,37 +1105,7 @@ const endpointSections: EndpointSection[] = [
|
||||
"controller_say_tpl": "Lufthansa 478 contact departure 120.8"
|
||||
}
|
||||
}`,
|
||||
notes: 'Uses FFmpeg for format conversion when available and logs transmissions together with LLM traces.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/llm/decide',
|
||||
summary: 'Run the LLM router against the provided decision graph state.',
|
||||
category: 'Decision engine',
|
||||
auth: 'protected',
|
||||
body: [
|
||||
{ name: 'state_id', type: 'string', required: true, description: 'Identifier of the current node in the flow.' },
|
||||
{ name: 'candidates', type: 'Array', required: true, description: 'Candidate states for the router to choose from.' },
|
||||
{ name: 'pilot_utterance', type: 'string', description: 'Recent transcription forwarded to the model.' },
|
||||
{ name: 'variables', type: 'object', description: 'Arbitrary variables passed to the router.' },
|
||||
{ name: 'flags', type: 'object', description: 'Boolean flags controlling heuristics.' },
|
||||
],
|
||||
sampleRequest: `curl -X POST https://opensquawk.de/api/llm/decide \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"state_id": "vector-entry",
|
||||
"pilot_utterance": "ready for departure",
|
||||
"candidates": [ { "id": "handoff", "state": { "type": "handoff" } } ],
|
||||
"variables": { "runway": "25C" }
|
||||
}'`,
|
||||
sampleResponse: `{
|
||||
"next_state": "handoff",
|
||||
"controller_say_tpl": "Contact departure 120.8",
|
||||
"off_schema": false,
|
||||
"radio_check": false
|
||||
}`,
|
||||
notes: 'Returns HTTP 500 when the router or downstream LLM fails.',
|
||||
notes: 'Uses FFmpeg for format conversion when available and logs transmissions.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2126,17 +2126,10 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
|
||||
if (isIntercom) {
|
||||
const result = await api.post('/api/atc/ptt', {
|
||||
audio: base64Audio,
|
||||
context: {
|
||||
state_id: currentState.value?.id || 'INTERCOM',
|
||||
state: {},
|
||||
candidates: [],
|
||||
variables: { callsign: vars.value.callsign },
|
||||
flags: {}
|
||||
},
|
||||
moduleId: 'pilot-monitoring-intercom',
|
||||
lessonId: 'intercom',
|
||||
format: 'webm',
|
||||
autoDecide: false
|
||||
sessionId: backendSessionId.value || undefined,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -2152,15 +2145,12 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const ctx = buildLLMContext('')
|
||||
|
||||
const result = await api.post('/api/atc/ptt', {
|
||||
audio: base64Audio,
|
||||
context: ctx,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
format: 'webm',
|
||||
autoDecide: false
|
||||
sessionId: backendSessionId.value || undefined,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { getOpenAIClient, routeDecision } from "../../utils/openai";
|
||||
import type { LLMDecisionResult } from "~~/shared/types/llm";
|
||||
import { getOpenAIClient } from "../../utils/openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { TransmissionLog } from "../../models/TransmissionLog";
|
||||
import { getUserFromEvent } from "../../utils/auth";
|
||||
@@ -15,26 +14,20 @@ type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm'
|
||||
|
||||
interface PTTRequest {
|
||||
audio: string; // Base64 encoded audio
|
||||
context: {
|
||||
state_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;
|
||||
format?: AudioFormat;
|
||||
autoDecide?: boolean;
|
||||
sessionId?: string; // Python backend session ID — used for TransmissionLog correlation
|
||||
context?: { // Legacy field; kept for backwards compat but not used for routing
|
||||
state_id?: string;
|
||||
flags?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface PTTResponse {
|
||||
success: boolean;
|
||||
transcription: string;
|
||||
decision?: LLMDecisionResult['decision'];
|
||||
trace?: LLMDecisionResult['trace'];
|
||||
active_nodes?: LLMDecisionResult['active_nodes'];
|
||||
}
|
||||
|
||||
async function sh(cmd: string, args: string[]) {
|
||||
@@ -46,14 +39,11 @@ async function sh(cmd: string, args: string[]) {
|
||||
}
|
||||
|
||||
const BASE64_AUDIO_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 Sekunden 16kHz Mono
|
||||
const ALLOWED_AUDIO_FORMATS: AudioFormat[] = ['wav', 'mp3', 'ogg', 'webm'];
|
||||
const AUDIO_FORMAT_SET = new Set<AudioFormat>(ALLOWED_AUDIO_FORMATS);
|
||||
const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 seconds 16kHz mono
|
||||
const AUDIO_FORMAT_SET = new Set<AudioFormat>(['wav', 'mp3', 'ogg', 'webm']);
|
||||
|
||||
function resolveAudioFormat(format?: string | null): AudioFormat {
|
||||
if (!format) {
|
||||
return 'wav';
|
||||
}
|
||||
if (!format) return 'wav';
|
||||
const normalized = format.trim().toLowerCase() as AudioFormat;
|
||||
return AUDIO_FORMAT_SET.has(normalized) ? normalized : 'wav';
|
||||
}
|
||||
@@ -76,37 +66,23 @@ function decodeAudioPayload(encoded: string): Buffer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Convert audio to WAV for better Whisper compatibility
|
||||
async function convertToWav(inputPath: string, outputPath: string) {
|
||||
await sh("ffmpeg", [
|
||||
"-y", "-i", inputPath,
|
||||
"-ar", "16000", // 16 kHz for Whisper
|
||||
"-ac", "1", // Mono
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-f", "wav",
|
||||
outputPath
|
||||
]);
|
||||
}
|
||||
|
||||
function safeClone<T>(value: T): T | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (err) {
|
||||
console.warn("Failed to clone value for transmission metadata", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<PTTRequest>(event);
|
||||
|
||||
if (!body.audio || !body.context || !body.moduleId || !body.lessonId) {
|
||||
if (!body.audio || !body.moduleId || !body.lessonId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "audio, context, moduleId, and lessonId are required"
|
||||
statusMessage: "audio, moduleId, and lessonId are required"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,11 +92,9 @@ export default defineEventHandler(async (event) => {
|
||||
const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`);
|
||||
|
||||
try {
|
||||
// 1. Decode audio from base64 and save
|
||||
const audioBuffer = decodeAudioPayload(body.audio);
|
||||
await writeFile(tmpAudioInput, audioBuffer);
|
||||
|
||||
// 2. Convert to WAV if needed (only when FFmpeg is available)
|
||||
let audioFileForWhisper = tmpAudioInput;
|
||||
if (format !== 'wav') {
|
||||
try {
|
||||
@@ -131,7 +105,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. OpenAI Whisper for transcription
|
||||
const openai = getOpenAIClient();
|
||||
const transcription = await openai.audio.transcriptions.create({
|
||||
file: createReadStream(audioFileForWhisper),
|
||||
@@ -143,98 +116,20 @@ export default defineEventHandler(async (event) => {
|
||||
const transcribedText = transcription.text.trim();
|
||||
|
||||
if (!transcribedText) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "No speech detected in audio"
|
||||
});
|
||||
throw createError({ statusCode: 400, statusMessage: "No speech detected in audio" });
|
||||
}
|
||||
|
||||
const shouldAutoDecide = body.autoDecide !== false;
|
||||
|
||||
let decisionResult: LLMDecisionResult | null = null;
|
||||
let decision: PTTResponse['decision'];
|
||||
|
||||
if (shouldAutoDecide) {
|
||||
// 4. Call the LLM decision directly with the transcribed text
|
||||
const decisionInput = {
|
||||
...body.context,
|
||||
pilot_utterance: transcribedText
|
||||
};
|
||||
|
||||
decisionResult = await routeDecision(decisionInput);
|
||||
decision = decisionResult.decision;
|
||||
}
|
||||
|
||||
// 5. Cleanup
|
||||
await rm(tmpAudioInput).catch(() => {});
|
||||
if (audioFileForWhisper !== tmpAudioInput) {
|
||||
await rm(tmpAudioWav).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUserFromEvent(event)
|
||||
|
||||
const llmCallCount = decisionResult?.trace?.calls?.length || 0;
|
||||
const fallbackUsed = Boolean(decisionResult?.trace?.fallback?.used);
|
||||
|
||||
let llmStrategy: 'manual' | 'openai' | 'heuristic' | 'fallback' = 'manual';
|
||||
if (shouldAutoDecide) {
|
||||
if (llmCallCount > 0) {
|
||||
llmStrategy = 'openai';
|
||||
} else if (fallbackUsed) {
|
||||
llmStrategy = 'fallback';
|
||||
} else {
|
||||
llmStrategy = 'heuristic';
|
||||
}
|
||||
}
|
||||
|
||||
const llmUsage = {
|
||||
autoDecide: shouldAutoDecide,
|
||||
openaiUsed: llmStrategy === 'openai',
|
||||
callCount: llmCallCount,
|
||||
fallbackUsed,
|
||||
strategy: llmStrategy,
|
||||
reason:
|
||||
llmStrategy === 'manual'
|
||||
? 'Automatic decision disabled in request.'
|
||||
: llmStrategy === 'openai'
|
||||
? `Decision derived from OpenAI with ${llmCallCount} call(s).`
|
||||
: llmStrategy === 'fallback'
|
||||
? (decisionResult?.trace?.fallback?.reason || 'Fallback triggered after OpenAI failure.')
|
||||
: 'Decision resolved locally without calling OpenAI.'
|
||||
};
|
||||
|
||||
const contextState = safeClone(body.context.state);
|
||||
if (contextState && typeof contextState === 'object' && contextState !== null) {
|
||||
const stateRecord = contextState as Record<string, any>;
|
||||
if (!('id' in stateRecord)) {
|
||||
stateRecord.id = body.context.state_id;
|
||||
}
|
||||
}
|
||||
|
||||
const contextCandidates = Array.isArray(body.context.candidates)
|
||||
? body.context.candidates.map(candidate => {
|
||||
const candidateState = safeClone(candidate.state);
|
||||
if (candidateState && typeof candidateState === 'object' && candidateState !== null) {
|
||||
const candidateRecord = candidateState as Record<string, any>;
|
||||
if (!('id' in candidateRecord)) {
|
||||
candidateRecord.id = candidate.id;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: candidate.id,
|
||||
flow: candidate.flow || undefined,
|
||||
state: candidateState
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
|
||||
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;
|
||||
const user = await getUserFromEvent(event);
|
||||
// Prefer the explicit top-level sessionId (Python backend session).
|
||||
// Fall back to the legacy context.flags.session_id for older clients.
|
||||
const sessionId = body.sessionId
|
||||
?? (typeof body.context?.flags?.session_id === 'string' ? body.context.flags.session_id : undefined);
|
||||
|
||||
await TransmissionLog.create({
|
||||
user: user?._id,
|
||||
@@ -246,49 +141,19 @@ export default defineEventHandler(async (event) => {
|
||||
metadata: {
|
||||
moduleId: body.moduleId,
|
||||
lessonId: body.lessonId,
|
||||
decision,
|
||||
decisionTrace: decisionResult?.trace,
|
||||
autoDecide: shouldAutoDecide,
|
||||
llm: llmUsage,
|
||||
context: {
|
||||
stateId: body.context.state_id,
|
||||
state: contextState,
|
||||
candidates: contextCandidates,
|
||||
selectedCandidate,
|
||||
variables: safeClone(body.context.variables),
|
||||
flags: safeClone(body.context.flags)
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
} catch (logError) {
|
||||
console.warn("Transmission logging failed", logError)
|
||||
console.warn("Transmission logging failed", logError);
|
||||
}
|
||||
|
||||
const result: PTTResponse = {
|
||||
success: true,
|
||||
transcription: transcribedText
|
||||
};
|
||||
|
||||
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;
|
||||
return { success: true, transcription: transcribedText } satisfies PTTResponse;
|
||||
|
||||
} catch (error: any) {
|
||||
// Cleanup on error
|
||||
await rm(tmpAudioInput).catch(() => {});
|
||||
await rm(tmpAudioWav).catch(() => {});
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
if (error.statusCode) throw error;
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { buildRuntimeDecisionSystem } from '../../services/decisionFlowService'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const system = await buildRuntimeDecisionSystem()
|
||||
return system
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
// server/api/llm/decide.post.ts
|
||||
import { readBody, createError } from 'h3'
|
||||
import type { LLMDecisionInput } from '~~/shared/types/llm'
|
||||
import { routeDecision } from '../../utils/openai'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LLMDecisionInput | undefined>(event)
|
||||
if (!body) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing body' })
|
||||
}
|
||||
if (!body.state_id || !Array.isArray(body.candidates)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid shape' })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await routeDecision(body)
|
||||
const { decision, trace } = result
|
||||
|
||||
if (decision.off_schema) {
|
||||
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
|
||||
}
|
||||
if (decision.radio_check) {
|
||||
console.log(`[ATC] Radio check processed: "${body.pilot_utterance}"`)
|
||||
}
|
||||
|
||||
if (trace?.calls?.length) {
|
||||
console.log('[ATC] Decision trace captured with', trace.calls.length, 'call(s)')
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (err: any) {
|
||||
console.error('Router failed:', err)
|
||||
throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' })
|
||||
}
|
||||
})
|
||||
@@ -18,9 +18,6 @@ export default defineEventHandler(async (event) => {
|
||||
if (url.pathname.startsWith('/api/copilot/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/decision-flows/runtime') {
|
||||
return
|
||||
}
|
||||
if (event.node.req.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, it, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { RuntimeDecisionState, RuntimeDecisionSystem } from '~~/shared/types/decision'
|
||||
import type { LLMDecisionInput } from '~~/shared/types/llm'
|
||||
import { __setRuntimeDecisionSystemForTests, routeDecision } from './openai'
|
||||
|
||||
const createState = (overrides: Partial<RuntimeDecisionState>): RuntimeDecisionState => ({
|
||||
role: 'pilot',
|
||||
phase: 'ground',
|
||||
name: 'State',
|
||||
summary: 'Generic state',
|
||||
say_tpl: undefined,
|
||||
utterance_tpl: undefined,
|
||||
else_say_tpl: undefined,
|
||||
next: [],
|
||||
ok_next: [],
|
||||
bad_next: [],
|
||||
timer_next: [],
|
||||
auto: null,
|
||||
readback_required: undefined,
|
||||
actions: undefined,
|
||||
handoff: undefined,
|
||||
guard: undefined,
|
||||
trigger: undefined,
|
||||
frequency: undefined,
|
||||
frequencyName: undefined,
|
||||
auto_transitions: [],
|
||||
triggers: [],
|
||||
conditions: [],
|
||||
metadata: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const START = createState({
|
||||
name: 'Start',
|
||||
summary: 'Start of flow',
|
||||
role: 'atc',
|
||||
})
|
||||
|
||||
const ACK = createState({
|
||||
name: 'Acknowledge',
|
||||
summary: 'Acknowledge pilot readback',
|
||||
triggers: [
|
||||
{ id: 'ack-regex', type: 'regex', pattern: 'roger', patternFlags: 'i' },
|
||||
],
|
||||
})
|
||||
|
||||
const TAXI = createState({
|
||||
name: 'Taxi clearance',
|
||||
summary: 'Pilot requesting taxi clearance',
|
||||
triggers: [
|
||||
{ id: 'taxi-regex', type: 'regex', pattern: 'request', patternFlags: 'i' },
|
||||
],
|
||||
})
|
||||
|
||||
const HOLD = createState({
|
||||
name: 'Hold position',
|
||||
summary: 'Pilot requesting hold position',
|
||||
triggers: [
|
||||
{ id: 'hold-regex', type: 'regex', pattern: 'request', patternFlags: 'i' },
|
||||
],
|
||||
})
|
||||
|
||||
START.next = [
|
||||
{ to: 'ACK' },
|
||||
{ to: 'TAXI' },
|
||||
{ to: 'HOLD' },
|
||||
]
|
||||
|
||||
const runtimeSystem: RuntimeDecisionSystem = {
|
||||
main: 'main',
|
||||
order: ['main'],
|
||||
flows: {
|
||||
main: {
|
||||
slug: 'main',
|
||||
schema_version: '1.0',
|
||||
name: 'Main Flow',
|
||||
start_state: 'START',
|
||||
end_states: [],
|
||||
variables: {},
|
||||
flags: {},
|
||||
policies: {},
|
||||
hooks: {},
|
||||
roles: ['pilot', 'atc', 'system'],
|
||||
phases: ['ground'],
|
||||
entry_mode: 'main',
|
||||
states: {
|
||||
START,
|
||||
ACK,
|
||||
TAXI,
|
||||
HOLD,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const baseInput: Omit<LLMDecisionInput, 'candidates'> = {
|
||||
state_id: 'START',
|
||||
state: START,
|
||||
variables: { callsign: 'TEST123' },
|
||||
flags: { current_unit: 'TWR', in_air: false },
|
||||
pilot_utterance: '',
|
||||
}
|
||||
|
||||
describe('routeDecision', () => {
|
||||
beforeEach(() => {
|
||||
__setRuntimeDecisionSystemForTests(runtimeSystem)
|
||||
})
|
||||
|
||||
it('returns heuristic decision when exactly one candidate matches', async () => {
|
||||
const input: LLMDecisionInput = {
|
||||
...baseInput,
|
||||
pilot_utterance: 'Roger that',
|
||||
candidates: [
|
||||
{ id: 'ACK', state: ACK },
|
||||
],
|
||||
}
|
||||
|
||||
const result = await routeDecision(input)
|
||||
|
||||
assert.equal(result.decision.next_state, 'ACK')
|
||||
assert.equal(result.trace?.calls.length ?? 0, 0)
|
||||
assert.equal(result.trace?.autoSelection?.id, 'ACK')
|
||||
assert.equal(result.pilot_intent ?? null, null)
|
||||
})
|
||||
|
||||
it('falls back to heuristic selection when OpenAI call fails', async () => {
|
||||
const input: LLMDecisionInput = {
|
||||
...baseInput,
|
||||
pilot_utterance: 'Request taxi instructions',
|
||||
candidates: [
|
||||
{ id: 'TAXI', state: TAXI },
|
||||
{ id: 'HOLD', state: HOLD },
|
||||
],
|
||||
}
|
||||
|
||||
const previousApiKey = process.env.OPENAI_API_KEY
|
||||
process.env.OPENAI_API_KEY = ''
|
||||
let result: Awaited<ReturnType<typeof routeDecision>>
|
||||
try {
|
||||
result = await routeDecision(input)
|
||||
} finally {
|
||||
if (previousApiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousApiKey
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(result.trace?.calls.length, 1)
|
||||
assert.ok(result.trace?.calls[0]?.error)
|
||||
assert.equal(result.trace?.fallback?.used, true)
|
||||
assert.equal(result.decision.next_state, 'TAXI')
|
||||
assert.equal(result.pilot_intent ?? null, null)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user