Files
thu-maic--openmaic/tests/generation/task-engine-outline-route.test.ts
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

552 lines
18 KiB
TypeScript

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();
});
});