552 lines
18 KiB
TypeScript
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();
|
||
});
|
||
});
|