chore: establish learning module baseline
This commit is contained in:
199
OpenMAIC/tests/course-framework/framework.test.ts
Normal file
199
OpenMAIC/tests/course-framework/framework.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user