200 lines
8.6 KiB
TypeScript
200 lines
8.6 KiB
TypeScript
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);
|
||
});
|
||
});
|