chore: establish learning module baseline

This commit is contained in:
inman
2026-08-16 14:58:47 +08:00
commit 2d04197f3f
2409 changed files with 533050 additions and 0 deletions

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