Files
openmaic/OpenMAIC/tests/generation/task-engine-outline-route.test.ts
2026-08-16 14:58:47 +08:00

552 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const streamLLMMock = vi.hoisted(() => vi.fn());
const resolveModelFromRequestMock = vi.hoisted(() => vi.fn());
const VOCATIONAL_FLAG = 'OPENMAIC_ENABLE_VOCATIONAL';
let originalVocationalFlag: string | undefined;
vi.mock('@/lib/ai/llm', () => ({
streamLLM: streamLLMMock,
}));
vi.mock('@/lib/server/resolve-model', () => ({
resolveModelFromRequest: resolveModelFromRequestMock,
}));
async function readStreamBody(response: Response) {
const reader = response.body?.getReader();
expect(reader).toBeDefined();
const decoder = new TextDecoder();
let text = '';
while (reader) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
return text;
}
function parseSseEvents(text: string) {
return text
.split('\n')
.filter((line) => line.startsWith('data: '))
.map((line) => JSON.parse(line.slice(6)));
}
function mockRequest(requirements: Record<string, unknown>) {
return {
json: async () => ({
requirements,
pdfText: '',
pdfImages: [],
imageMapping: {},
researchContext: '',
}),
headers: {
get: () => null,
},
};
}
describe('task-engine outline route', () => {
beforeEach(() => {
originalVocationalFlag = process.env[VOCATIONAL_FLAG];
delete process.env[VOCATIONAL_FLAG];
});
afterEach(() => {
if (originalVocationalFlag === undefined) {
delete process.env[VOCATIONAL_FLAG];
} else {
process.env[VOCATIONAL_FLAG] = originalVocationalFlag;
}
});
test('uses the task-engine prompt and preserves allowed mixed task-engine scene types', async () => {
vi.resetModules();
streamLLMMock.mockReset();
resolveModelFromRequestMock.mockReset();
process.env[VOCATIONAL_FLAG] = 'true';
resolveModelFromRequestMock.mockResolvedValue({
model: { provider: 'glm.chat', modelId: 'glm-5.1' },
modelInfo: { outputWindow: 4096, capabilities: {} },
modelString: 'glm:glm-5.1',
providerId: 'glm',
modelId: 'glm-5.1',
thinkingConfig: undefined,
});
const outlineResponse = JSON.stringify({
languageDirective: '用中文授课,保持职业教育语境。',
outlines: [
{
id: 'scene_slide',
type: 'slide',
title: '高压风险边界',
description: '解释高压安全边界。',
keyPoints: ['风险边界', '安全阈值'],
order: 1,
widgetType: 'procedural-skill',
widgetOutline: {
task: 'should be removed from slide',
},
},
{
id: 'scene_procedure',
type: 'interactive',
title: 'PPE检查',
description: '完成PPE检查。',
keyPoints: ['检查绝缘手套', '确认工具状态'],
order: 2,
widgetType: 'procedural-skill',
widgetOutline: {
steps: ['确认下电', '选择PPE'],
},
},
{
id: 'scene_game',
type: 'interactive',
title: 'GO/STOP挑战',
description: '根据检测结果做安全裁决。',
keyPoints: ['读取状态', '选择GO或STOP'],
order: 3,
widgetType: 'game',
widgetOutline: {
challenge: '判断是否可以继续作业',
},
},
{
id: 'scene_diagram',
type: 'interactive',
title: '高压风险路径',
description: '查看高压风险传播路径。',
keyPoints: ['电池包', '维修开关', '测量点'],
order: 4,
widgetType: 'diagram',
widgetOutline: {
concept: '风险路径',
},
},
{
id: 'scene_simulation',
type: 'interactive',
title: '普通概念模拟',
description: '非职教 fallback 可保留 simulation。',
keyPoints: ['变量观察'],
order: 5,
widgetType: 'simulation',
widgetOutline: {
concept: 'motion',
},
},
{
id: 'scene_code',
type: 'interactive',
title: '代码练习',
description: '非职教 fallback 可保留 code。',
keyPoints: ['代码运行'],
order: 6,
widgetType: 'code',
widgetOutline: {},
},
{
id: 'scene_3d',
type: 'interactive',
title: '三维观察',
description: '非职教 fallback 可保留 visualization3d。',
keyPoints: ['空间结构'],
order: 7,
widgetType: 'visualization3d',
widgetOutline: {},
},
{
id: 'scene_illegal',
type: 'quiz',
title: '非法场景',
description: '非法类型应兜底。',
keyPoints: ['兜底'],
order: 8,
widgetOutline: {
task: '非法类型兜底任务',
},
},
],
});
streamLLMMock.mockReturnValue({
textStream: (async function* () {
yield outlineResponse;
})(),
});
const { POST } = await import('@/app/api/generate/scene-outlines-stream/route');
const response = await POST(
mockRequest({
requirement: 'NEV-A12 新能源车动力电池包更换前安全确认',
interactiveMode: true,
taskEngineMode: true,
}) as unknown as Parameters<typeof POST>[0],
);
const promptParams = streamLLMMock.mock.calls[0][0] as { system: string; prompt: string };
expect(promptParams.system).toContain('Task Engine');
expect(promptParams.system).toContain('procedural-skill');
const events = parseSseEvents(await readStreamBody(response));
const done = events.find((event) => event.type === 'done');
expect(done).toBeDefined();
expect(done.taskEngineMode).toBe(true);
expect(done.outlines).toHaveLength(8);
expect(done.outlines[0]).toMatchObject({
type: 'slide',
title: '高压风险边界',
});
expect(done.outlines[0].widgetType).toBeUndefined();
expect(done.outlines[0].widgetOutline).toBeUndefined();
expect(done.outlines[1]).toMatchObject({
type: 'interactive',
widgetType: 'procedural-skill',
});
expect(done.outlines[1].widgetOutline).toMatchObject({
task: 'NEV-A12 新能源车动力电池包更换前安全确认',
steps: ['确认下电', '选择PPE'],
});
expect(done.outlines[1].widgetOutline.tools.length).toBeGreaterThan(0);
expect(done.outlines[1].widgetOutline.successCriteria.length).toBeGreaterThan(0);
expect(done.outlines[1].widgetOutline.errorConsequences.length).toBeGreaterThan(0);
expect(done.outlines[2]).toMatchObject({
type: 'interactive',
widgetType: 'game',
});
expect(done.outlines[2].widgetOutline).toMatchObject({
challenge: '判断是否可以继续作业',
});
expect(done.outlines[2].widgetOutline.gameType).toBeUndefined();
expect(done.outlines[2].widgetOutline.playerControls).toBeUndefined();
expect(done.outlines[3]).toMatchObject({
type: 'interactive',
widgetType: 'diagram',
});
expect(done.outlines[3].widgetOutline).toMatchObject({
concept: '风险路径',
});
expect(done.outlines[3].widgetOutline.diagramType).toBeUndefined();
expect(done.outlines[3].widgetOutline.nodeCount).toBeUndefined();
expect(done.outlines[4]).toMatchObject({
type: 'interactive',
widgetType: 'simulation',
});
expect(done.outlines[4].widgetOutline).toMatchObject({
concept: 'motion',
});
expect(done.outlines[4].widgetOutline.keyVariables).toBeUndefined();
expect(done.outlines[5]).toMatchObject({
type: 'interactive',
widgetType: 'code',
});
expect(done.outlines[5].widgetOutline.language).toBeUndefined();
expect(done.outlines[5].widgetOutline.challengeType).toBeUndefined();
expect(done.outlines[6]).toMatchObject({
type: 'interactive',
widgetType: 'visualization3d',
});
expect(done.outlines[6].widgetOutline.visualizationType).toBeUndefined();
expect(done.outlines[6].widgetOutline.objects).toBeUndefined();
expect(done.outlines[6].widgetOutline.interactions).toBeUndefined();
expect(done.outlines[7]).toMatchObject({
type: 'slide',
title: '非法场景',
});
expect(done.outlines[7].widgetType).toBeUndefined();
expect(done.outlines[7].widgetOutline).toBeUndefined();
});
test('silently falls back to the existing interactive prompt when the server flag is off', async () => {
vi.resetModules();
streamLLMMock.mockReset();
resolveModelFromRequestMock.mockReset();
resolveModelFromRequestMock.mockResolvedValue({
model: { provider: 'glm.chat', modelId: 'glm-5.1' },
modelInfo: { outputWindow: 4096, capabilities: {} },
modelString: 'glm:glm-5.1',
providerId: 'glm',
modelId: 'glm-5.1',
thinkingConfig: undefined,
});
streamLLMMock.mockReturnValue({
textStream: (async function* () {
yield JSON.stringify({
languageDirective: 'Teach in English.',
outlines: [
{
id: 'scene_1',
type: 'interactive',
title: 'Interactive Scene',
description: 'Explore a concept.',
keyPoints: ['Explore'],
order: 1,
widgetType: 'simulation',
widgetOutline: { concept: 'motion', keyVariables: ['speed'] },
},
],
});
})(),
});
const { POST } = await import('@/app/api/generate/scene-outlines-stream/route');
const response = await POST(
mockRequest({
requirement: 'Teach motion with interaction',
interactiveMode: true,
taskEngineMode: true,
}) as unknown as Parameters<typeof POST>[0],
);
const promptParams = streamLLMMock.mock.calls[0][0] as { system: string; prompt: string };
expect(promptParams.system).toContain('Interactive Mode Outline Generator');
expect(promptParams.system).not.toContain('Task Engine Outline Generator');
const events = parseSseEvents(await readStreamBody(response));
const done = events.find((event) => event.type === 'done');
expect(done).toBeDefined();
expect(done.taskEngineMode).toBe(false);
});
test('sanitizes procedural-skill outlines when taskEngineMode is disabled', async () => {
vi.resetModules();
streamLLMMock.mockReset();
resolveModelFromRequestMock.mockReset();
resolveModelFromRequestMock.mockResolvedValue({
model: { provider: 'glm.chat', modelId: 'glm-5.1' },
modelInfo: { outputWindow: 4096, capabilities: {} },
modelString: 'glm:glm-5.1',
providerId: 'glm',
modelId: 'glm-5.1',
thinkingConfig: undefined,
});
streamLLMMock.mockReturnValue({
textStream: (async function* () {
yield JSON.stringify({
languageDirective: 'Teach in English.',
outlines: [
{
id: 'scene_1',
type: 'interactive',
title: 'Operation Process',
description: 'A model mistakenly emitted the gated widget type.',
keyPoints: ['Step A', 'Step B'],
order: 1,
widgetType: 'procedural-skill',
widgetOutline: {
procedureType: 'inspection',
task: 'Inspect the device',
tools: ['checklist'],
steps: ['Check A', 'Check B'],
successCriteria: ['Complete'],
},
},
],
});
})(),
});
const { POST } = await import('@/app/api/generate/scene-outlines-stream/route');
const response = await POST(
mockRequest({
requirement: 'Teach a process interactively',
interactiveMode: true,
}) as unknown as Parameters<typeof POST>[0],
);
const events = parseSseEvents(await readStreamBody(response));
const done = events.find((event) => event.type === 'done');
expect(done).toBeDefined();
expect(done.outlines[0]).toMatchObject({
type: 'interactive',
widgetType: 'diagram',
});
expect(done.outlines[0].description).toContain('process or structure diagram');
expect(done.outlines[0].widgetOutline.diagramType).toBeUndefined();
expect(done.outlines[0].widgetOutline.nodeCount).toBeUndefined();
expect(done.outlines[0].widgetOutline.procedureType).toBeUndefined();
expect(done.outlines[0].widgetOutline.task).toBeUndefined();
expect(done.outlines[0].widgetOutline.tools).toBeUndefined();
expect(done.outlines[0].widgetOutline.steps).toBeUndefined();
expect(done.outlines[0].widgetOutline.successCriteria).toBeUndefined();
});
test('preserves model-authored scenario PBL subtype through streamed outlines', async () => {
vi.resetModules();
streamLLMMock.mockReset();
resolveModelFromRequestMock.mockReset();
resolveModelFromRequestMock.mockResolvedValue({
model: { provider: 'glm.chat', modelId: 'glm-5.1' },
modelInfo: { outputWindow: 4096, capabilities: {} },
modelString: 'glm:glm-5.1',
providerId: 'glm',
modelId: 'glm-5.1',
thinkingConfig: undefined,
});
streamLLMMock.mockReturnValue({
textStream: (async function* () {
yield JSON.stringify({
languageDirective: '用中文授课。',
outlines: [
{
id: 'scene_pbl',
type: 'pbl',
title: '同理沟通练习',
description: '练习安慰压力很大的朋友。',
keyPoints: ['倾听', '回应'],
order: 1,
pblConfig: {
projectTopic: '同理沟通练习',
projectDescription: '练习安慰压力很大的朋友。',
targetSkills: ['倾听', '回应'],
issueCount: 2,
scenarioRoleplay: true,
scenarioBrief: '朋友压力很大,学习者练习倾听和支持。',
},
},
],
});
})(),
});
const { POST } = await import('@/app/api/generate/scene-outlines-stream/route');
const response = await POST(
mockRequest({
requirement: '生成一个情景模拟 PBL,练习安慰压力很大的朋友',
}) as unknown as Parameters<typeof POST>[0],
);
const events = parseSseEvents(await readStreamBody(response));
const outline = events.find((event) => event.type === 'outline');
const done = events.find((event) => event.type === 'done');
expect(outline).toBeDefined();
expect(done).toBeDefined();
expect(outline?.data.pblConfig.scenarioRoleplay).toBe(true);
expect(done?.outlines[0].pblConfig.scenarioRoleplay).toBe(true);
expect(done?.outlines[0].pblConfig.scenarioBrief).toContain('朋友压力很大');
});
test('ensures streamed outline ids are unique', async () => {
vi.resetModules();
streamLLMMock.mockReset();
resolveModelFromRequestMock.mockReset();
resolveModelFromRequestMock.mockResolvedValue({
model: { provider: 'glm.chat', modelId: 'glm-5.1' },
modelInfo: { outputWindow: 4096, capabilities: {} },
modelString: 'glm:glm-5.1',
providerId: 'glm',
modelId: 'glm-5.1',
thinkingConfig: undefined,
});
streamLLMMock.mockReturnValue({
textStream: (async function* () {
yield JSON.stringify({
languageDirective: 'Teach in English.',
outlines: [
{
id: 'scene_4',
type: 'slide',
title: 'First Scene',
description: 'First scene.',
keyPoints: ['A'],
order: 1,
},
{
id: 'scene_4',
type: 'slide',
title: 'Second Scene',
description: 'Second scene.',
keyPoints: ['B'],
order: 2,
},
],
});
})(),
});
const { POST } = await import('@/app/api/generate/scene-outlines-stream/route');
const response = await POST(
mockRequest({
requirement: 'Teach a topic',
}) as unknown as Parameters<typeof POST>[0],
);
const events = parseSseEvents(await readStreamBody(response));
const done = events.find((event) => event.type === 'done');
expect(done).toBeDefined();
const ids = done.outlines.map((outline: { id: string }) => outline.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids[0]).toBe('scene_4');
expect(ids[1]).not.toBe('scene_4');
});
test('falls back to a slide for invalid task-engine outlines without regex promotion', async () => {
vi.resetModules();
streamLLMMock.mockReset();
resolveModelFromRequestMock.mockReset();
process.env[VOCATIONAL_FLAG] = 'true';
resolveModelFromRequestMock.mockResolvedValue({
model: { provider: 'glm.chat', modelId: 'glm-5.1' },
modelInfo: { outputWindow: 4096, capabilities: {} },
modelString: 'glm:glm-5.1',
providerId: 'glm',
modelId: 'glm-5.1',
thinkingConfig: undefined,
});
streamLLMMock.mockReturnValue({
textStream: (async function* () {
yield JSON.stringify({
languageDirective: 'Teach in English.',
outlines: [
{
id: 'scene_bad',
type: 'quiz',
title: 'Pythagorean theorem recap',
description: 'A non-vocational math topic with an invalid type.',
keyPoints: ['a squared plus b squared equals c squared'],
order: 1,
},
],
});
})(),
});
const { POST } = await import('@/app/api/generate/scene-outlines-stream/route');
const response = await POST(
mockRequest({
requirement: 'Explain the Pythagorean theorem',
interactiveMode: true,
taskEngineMode: true,
}) as unknown as Parameters<typeof POST>[0],
);
const events = parseSseEvents(await readStreamBody(response));
const done = events.find((event) => event.type === 'done');
expect(done).toBeDefined();
expect(done.outlines[0]).toMatchObject({
type: 'slide',
title: 'Pythagorean theorem recap',
});
expect(done.outlines[0].widgetType).toBeUndefined();
expect(done.outlines[0].widgetOutline).toBeUndefined();
});
});