Files
openmaic/OpenMAIC/tests/course-framework/framework.test.ts
2026-08-16 14:58:47 +08:00

200 lines
8.6 KiB
TypeScript
Raw 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 { describe, expect, test, vi } from 'vitest';
import {
generateCourseFramework,
validateCourseFramework,
} from '@/lib/course-framework/generate-framework';
import { buildModuleRequirement } from '@/lib/course-framework/types';
const VALID_RAW = {
courseTitle: '机器学习入门',
languageDirective: '用中文教学,术语保留英文。',
targetAudience: '没有机器学习经验、具备高中数学基础的成人学习者。',
summary: '零基础机器学习导论课程。',
courseGoals: ['建立机器学习问题框架', '能够训练并评估基础模型', '能够解释模型结果'],
continuityContract: {
terminology: ['首次出现时使用中文术语English term后续使用中文术语'],
teachingStyle: '先用真实问题引入,再通过可视化和练习建立概念。',
difficultyProgression: '从直觉理解递进到模型训练、评估和综合应用。',
assessmentStrategy: '每模块用形成性练习检查,最后以综合案例串联能力。',
},
modules: [
{
title: '什么是机器学习',
description: '机器学习的定义、类型与典型应用。',
learningObjectives: ['能区分监督/无监督学习', '能举例机器学习应用'],
generationPrompt:
'从生活中的预测问题出发,建立机器学习的基本分类框架,并用分类练习检查理解。',
prerequisites: '',
incomingKnowledge: [],
outgoingKnowledge: ['监督学习与无监督学习的区别', '机器学习问题的基本识别方法'],
excludedTopics: ['具体算法的数学推导'],
estimatedMinutes: 20,
},
{
title: '线性回归',
description: '一元与多元线性回归、最小二乘。',
learningObjectives: ['能解释损失函数', '能读懂回归结果'],
generationPrompt:
'用房价预测案例讲授线性回归,连接损失函数、拟合过程和结果解释,并安排参数调整练习。',
prerequisites: '模块 1',
incomingKnowledge: ['监督学习的定义', '特征与标签的概念'],
outgoingKnowledge: ['损失函数的作用', '线性回归结果的解释方法'],
excludedTopics: ['分类模型', '交叉验证'],
estimatedMinutes: 25,
},
{
title: '逻辑回归与分类',
description: '二分类、sigmoid 与决策边界。',
learningObjectives: ['能解释 sigmoid 的作用', '能描述决策边界'],
generationPrompt:
'承接回归建模思路,用互动决策边界展示逻辑回归,并通过分类判断练习形成迁移。',
prerequisites: '模块 2',
incomingKnowledge: ['损失函数的作用', '线性模型的基本结构'],
outgoingKnowledge: ['sigmoid 的作用', '分类决策边界的解释方法'],
excludedTopics: ['模型调优方法'],
estimatedMinutes: 20,
},
{
title: '模型评估与调优',
description: '训练/测试划分、过拟合、交叉验证。',
learningObjectives: ['能识别过拟合', '能使用交叉验证'],
generationPrompt:
'综合前述回归与分类案例,比较训练和测试表现,设计识别过拟合与选择评估方案的任务。',
prerequisites: '模块 3',
incomingKnowledge: ['回归与分类模型的基本训练流程'],
outgoingKnowledge: ['过拟合识别方法', '交叉验证的使用条件'],
excludedTopics: [],
estimatedMinutes: 20,
},
],
};
describe('validateCourseFramework', () => {
test('accepts a valid framework and normalizes module indices', () => {
const result = validateCourseFramework(VALID_RAW);
expect(result).not.toBeNull();
expect(result!.courseTitle).toBe('机器学习入门');
expect(result!.continuityContract.terminology).toHaveLength(1);
expect(result!.modules).toHaveLength(4);
expect(result!.modules.map((m) => m.index)).toEqual([1, 2, 3, 4]);
expect(result!.modules[0]?.generationPrompt).toContain('分类框架');
});
test('rejects fewer than 4 modules', () => {
const raw = {
...VALID_RAW,
modules: VALID_RAW.modules.slice(0, 2),
};
expect(validateCourseFramework(raw)).toBeNull();
});
test('rejects more than 15 modules', () => {
const modules = Array.from({ length: 16 }, (_, i) => ({
...VALID_RAW.modules[0],
title: `模块 ${i + 1}`,
}));
expect(validateCourseFramework({ ...VALID_RAW, modules })).toBeNull();
});
test('rejects modules missing objectives or description', () => {
const raw = {
...VALID_RAW,
modules: VALID_RAW.modules.map((m, i) => (i === 1 ? { ...m, description: '' } : m)),
};
expect(validateCourseFramework(raw)).toBeNull();
});
test('rejects missing title or empty module list', () => {
expect(validateCourseFramework({ ...VALID_RAW, courseTitle: '' })).toBeNull();
expect(validateCourseFramework({ ...VALID_RAW, modules: [] })).toBeNull();
expect(validateCourseFramework(null)).toBeNull();
expect(
validateCourseFramework({ ...VALID_RAW, modules: [null, ...VALID_RAW.modules] }),
).toBeNull();
});
test('rejects a missing continuity contract or module generation contract', () => {
expect(validateCourseFramework({ ...VALID_RAW, continuityContract: undefined })).toBeNull();
expect(
validateCourseFramework({
...VALID_RAW,
modules: VALID_RAW.modules.map((module, index) =>
index === 0 ? { ...module, generationPrompt: '' } : module,
),
}),
).toBeNull();
expect(
validateCourseFramework({
...VALID_RAW,
modules: VALID_RAW.modules.map((module, index) =>
index === 0 ? { ...module, incomingKnowledge: undefined } : module,
),
}),
).toBeNull();
});
test('builds a Layer-2 requirement with language, duration, prompt, and knowledge boundaries', () => {
const framework = validateCourseFramework(VALID_RAW);
expect(framework).not.toBeNull();
const requirement = buildModuleRequirement(
framework!,
framework!.modules[1]!,
framework!.modules.length,
'为零基础学习者设计完整机器学习课程',
'模块 1《基础》\n- 实际覆盖:监督学习;特征与标签',
);
expect(requirement).toContain('用中文教学,术语保留英文');
expect(requirement).toContain('约 25 分钟');
expect(requirement).toContain('主 Agent 模块生成提示词');
expect(requirement).toContain('用房价预测案例讲授线性回归');
expect(requirement).toContain('可假定的入门知识:监督学习的定义');
expect(requirement).toContain('避免在本模块深入重复:分类模型;交叉验证');
expect(requirement).toContain('前序模块的实际生成产出(不是原计划');
expect(requirement).toContain('实际覆盖:监督学习;特征与标签');
});
});
describe('generateCourseFramework', () => {
test('returns the framework on a valid first response', async () => {
const aiCall = vi.fn(async (_systemPrompt: string, _userPrompt: string) =>
JSON.stringify(VALID_RAW),
);
const result = await generateCourseFramework({ requirement: 'test' }, aiCall);
expect(result.success).toBe(true);
expect(result.data?.modules).toHaveLength(4);
expect(aiCall).toHaveBeenCalledTimes(1);
expect(String(aiCall.mock.calls[0]?.[0])).toContain('generationPrompt');
expect(String(aiCall.mock.calls[0]?.[0])).toContain('continuityContract');
expect(String(aiCall.mock.calls[0]?.[0])).toContain('incomingKnowledge');
});
test('retries once when the first response is not valid JSON', async () => {
const aiCall = vi
.fn()
.mockResolvedValueOnce('```json\nnot json at all')
.mockResolvedValueOnce(JSON.stringify(VALID_RAW));
const result = await generateCourseFramework({ requirement: 'test' }, aiCall);
expect(result.success).toBe(true);
expect(aiCall).toHaveBeenCalledTimes(2);
// The retry prompt carries the failure hint.
expect(String(aiCall.mock.calls[1][1])).toContain('无法解析');
});
test('retries once on validation failure, then reports the error', async () => {
const aiCall = vi.fn(async () =>
JSON.stringify({ ...VALID_RAW, modules: VALID_RAW.modules.slice(0, 2) }),
);
const result = await generateCourseFramework({ requirement: 'test' }, aiCall);
expect(result.success).toBe(false);
expect(result.error).toContain('validation');
expect(aiCall).toHaveBeenCalledTimes(2);
});
test('fails on an empty response', async () => {
const aiCall = vi.fn(async () => '');
const result = await generateCourseFramework({ requirement: 'test' }, aiCall);
expect(result.success).toBe(false);
});
});