feat: productionize learning engine and classroom

This commit is contained in:
inman
2026-08-16 21:44:52 +08:00
parent 2d04197f3f
commit ba35adfbfa
124 changed files with 9071 additions and 796 deletions

View File

@@ -14,6 +14,8 @@ const ctl = vi.hoisted(() => ({
requirements: [] as string[],
interactiveModes: [] as boolean[],
classrooms: {} as Record<string, unknown>,
frameworkGate: Promise.resolve() as Promise<void>,
frameworkAbortSignals: [] as AbortSignal[],
}));
vi.mock('@/lib/server/resolve-model', () => ({
@@ -28,7 +30,11 @@ vi.mock('@/lib/server/resolve-model', () => ({
}));
vi.mock('@/lib/ai/llm', () => ({
callLLM: async () => ({ text: ctl.frameworkJson }),
callLLM: async (params: { abortSignal?: AbortSignal }) => {
if (params.abortSignal) ctl.frameworkAbortSignals.push(params.abortSignal);
await ctl.frameworkGate;
return { text: ctl.frameworkJson };
},
}));
vi.mock('@/lib/ai/providers', () => ({
@@ -160,13 +166,19 @@ const FRAMEWORK_WITH_FAILING_MODULE = {
let frameworksDir: string;
let jobsDir: string;
let frozenCoursesDir: string;
let frozenPackagesDir: string;
beforeEach(async () => {
frameworksDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-fw-'));
jobsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-jobs-'));
frozenCoursesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-courses-'));
frozenPackagesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-packages-'));
vi.resetModules();
vi.stubEnv('COURSE_FRAMEWORK_DIR', frameworksDir);
vi.stubEnv('CLASSROOM_JOBS_DIR', jobsDir);
vi.stubEnv('LEARNING_COURSE_STORE_DIR', frozenCoursesDir);
vi.stubEnv('LEARNING_COURSE_PACKAGE_DIR', frozenPackagesDir);
ctl.frameworkJson = JSON.stringify(FRAMEWORK);
ctl.gate = Promise.resolve();
ctl.releaseGate = () => {};
@@ -174,12 +186,16 @@ beforeEach(async () => {
ctl.requirements = [];
ctl.interactiveModes = [];
ctl.classrooms = {};
ctl.frameworkGate = Promise.resolve();
ctl.frameworkAbortSignals = [];
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(frameworksDir, { recursive: true, force: true });
await fs.rm(jobsDir, { recursive: true, force: true });
await fs.rm(frozenCoursesDir, { recursive: true, force: true });
await fs.rm(frozenPackagesDir, { recursive: true, force: true });
});
/** Wait until a condition holds (poll every 10ms, fail after 3s). */
@@ -221,14 +237,43 @@ describe('course runner — two-phase pipeline', () => {
expect(record?.modules[0]?.generationPromptSnapshot).toContain('日常预测案例');
});
test('confirmation (module phase) generates every module in order; course completes', async () => {
test('framework cancellation reaches the LLM signal and cannot later overwrite cancelled state', async () => {
let releaseFramework!: () => void;
ctl.frameworkGate = new Promise<void>((resolve) => {
releaseFramework = resolve;
});
const { readCourseRecord } = await setupCourse();
const {
cancelCourseGenerationJob,
runCourseFrameworkGeneration,
} = await import('@/lib/course-framework/runner');
const run = runCourseFrameworkGeneration('course-1', 'http://localhost');
await waitFor(() => ctl.frameworkAbortSignals.length === 1, 'framework LLM call');
expect(cancelCourseGenerationJob('course-1')).toBe(true);
expect(ctl.frameworkAbortSignals[0]?.aborted).toBe(true);
releaseFramework();
await run;
const record = await readCourseRecord();
expect(record?.status).toBe('cancelled');
expect(record?.framework).toBeUndefined();
});
test('confirmation (module phase) generates every module in order; course completes', async () => {
const { createCourse } = await import('@/lib/course-framework/runner');
const { readCourseRecord } = await import('@/lib/course-framework/store');
await createCourse(
'course-1',
{ requirement: '大型课题需求' },
{ ownerPrincipalId: 'works-owner-1' },
);
const { runCourseFrameworkGeneration, runCourseModuleGeneration } =
await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
const record = await readCourseRecord();
const record = await readCourseRecord('course-1');
expect(record?.status).toBe('completed');
expect(record?.modules.map((m) => m.status)).toEqual([
'succeeded',
@@ -254,6 +299,10 @@ describe('course runner — two-phase pipeline', () => {
expect(ctl.requirements[1]).toContain('可假定的入门知识:基础概念');
expect(ctl.requirements[1]).toContain('前序模块的实际生成产出');
expect(ctl.requirements[1]).toContain('实际覆盖知识 1');
const { listClassroomGenerationJobs } = await import('@/lib/server/classroom-job-store');
const moduleJobs = await listClassroomGenerationJobs(10);
expect(moduleJobs).toHaveLength(4);
expect(moduleJobs.every((job) => job.ownerPrincipalId === 'works-owner-1')).toBe(true);
});
test('enforces one active generation run per course', async () => {
@@ -471,6 +520,52 @@ describe('course runner — two-phase pipeline', () => {
expect(record?.modules.every((m) => m.status === 'pending' && !m.classroomId)).toBe(true);
});
test('rejects every source generation entry after the course id is frozen', async () => {
const { readCourseRecord } = await setupCourse();
const {
runCourseFrameworkGeneration,
runCourseModuleGeneration,
runCourseGenerationJob,
regenerateCourseFramework,
} = await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
const before = await readCourseRecord();
expect(before?.status).toBe('completed');
const { persistFrozenLearningPackage } = await import('@/lib/makelore-course/package');
await persistFrozenLearningPackage({
schemaVersion: 2,
courseId: 'course-1',
mode: 'large',
sourceJobId: 'course-1',
sourceDigest: 'c'.repeat(64),
contentHash: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
archiveBytes: 4,
formatVersion: 2,
minPlayerVersion: '2.0.0',
title: '机器学习入门',
sceneCount: 4,
moduleCount: 4,
capabilities: { modular: true },
createdAt: '2026-08-16T00:00:00.000Z',
}, new Uint8Array([0x50, 0x4b, 0x03, 0x04]));
const mutations = [
() => runCourseFrameworkGeneration('course-1', 'http://localhost'),
() => runCourseModuleGeneration('course-1', 'http://localhost'),
() => runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 }),
() => regenerateCourseFramework('course-1', 'http://localhost'),
];
for (const mutate of mutations) {
await expect(mutate()).rejects.toMatchObject({
name: 'FrozenLearningCourseMutationError',
});
}
expect(await readCourseRecord()).toEqual(before);
});
test('cancel aborts the current module and keeps the course cancellable', async () => {
let release!: () => void;
ctl.gate = new Promise<void>((r) => {