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

@@ -0,0 +1,411 @@
import { createHash, createHmac } from 'node:crypto';
import JSZip from 'jszip';
import type { CourseRecord } from '@/lib/course-framework/types';
import { readCourseRecord } from '@/lib/course-framework/store';
import { runCoursePublishExclusive } from '@/lib/course-framework/publish-state';
import { FrozenLearningCourseMutationError } from '@/lib/makelore-course/immutability';
import {
persistFrozenLearningPackage,
readFrozenLearningPackageRecord,
readUniqueMakeloreCoursePackage,
type FrozenLearningPackageRecord,
} from '@/lib/makelore-course/package';
import { packagePersistedClassroom } from '@/lib/server/classroom-courseware-publish';
import { readClassroom, type PersistedClassroomData } from '@/lib/server/classroom-storage';
const finalizations = new Map<string, Promise<FrozenLearningPackageRecord>>();
function sha256(bytes: Uint8Array | string): string {
return createHash('sha256').update(bytes).digest('hex');
}
function canonicalJson(_key: string, value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
);
}
function stableSourceDigest(value: unknown): string {
const serialized = JSON.stringify(value, canonicalJson);
if (serialized === undefined) throw new Error('Learning course source cannot be serialized');
return sha256(serialized);
}
function assertSafeBundleEntry(name: string): void {
if (
!name ||
name.startsWith('/') ||
name.includes('\\') ||
name.includes('\0') ||
name.split('/').some((part) => part === '.' || part === '..')
) {
throw new Error(`Unsafe frozen bundle entry: ${name}`);
}
}
function archiveSignature(bytes: Uint8Array): Record<string, string> | undefined {
const key = process.env.LEARNING_PACKAGE_SIGNING_KEY;
if (!key) return undefined;
return {
algorithm: 'hmac-sha256',
keyId: process.env.LEARNING_PACKAGE_SIGNING_KEY_ID?.trim() || 'default',
value: createHmac('sha256', key).update(bytes).digest('base64url'),
};
}
function sceneKinds(classroom: PersistedClassroomData) {
return [...new Set(classroom.scenes.map((scene) => scene.type))].filter((kind) =>
['slide', 'interactive', 'quiz', 'pbl'].includes(kind),
);
}
function classroomCapabilities(
classroom: PersistedClassroomData,
runtimeCapabilities: unknown,
): Record<string, unknown> {
return {
sceneKinds: sceneKinds(classroom),
hasAudio: classroom.scenes.some((scene) =>
scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)),
),
hasWhiteboard: Boolean(
classroom.stage.whiteboard?.length ||
classroom.scenes.some((scene) => Boolean(scene.whiteboards?.length)),
),
hasAgent: Boolean(classroom.stage.generatedAgentConfigs?.length),
runtime: runtimeCapabilities,
};
}
async function existingFinalization(
courseId: string,
sourceJobId: string,
sourceDigest: string,
mode: FrozenLearningPackageRecord['mode'],
) {
const record = await readFrozenLearningPackageRecord(courseId);
if (!record) return null;
const bytes = await readUniqueMakeloreCoursePackage(courseId);
if (
!bytes ||
bytes.byteLength !== record.archiveBytes ||
sha256(bytes) !== record.archiveSha256 ||
record.sourceJobId !== sourceJobId ||
record.mode !== mode ||
record.sourceDigest !== sourceDigest
) {
throw new FrozenLearningCourseMutationError(courseId);
}
return record;
}
async function finalizeSerially(
key: string,
factory: () => Promise<FrozenLearningPackageRecord>,
): Promise<FrozenLearningPackageRecord> {
// Different source revisions of the same course must never share an
// in-flight promise. Serialize them, then re-evaluate the persisted cache
// against each call's sourceDigest inside the factory.
const previous = finalizations.get(key);
const ready =
previous?.then(
() => undefined,
() => undefined,
) ?? Promise.resolve();
const promise = ready.then(factory);
finalizations.set(key, promise);
const cleanup = () => {
if (finalizations.get(key) === promise) finalizations.delete(key);
};
void promise.then(cleanup, cleanup);
return promise;
}
export async function finalizeSingleLearningCourse(input: {
jobId: string;
classroom: PersistedClassroomData;
baseUrl: string;
requireInteractiveHtml: boolean;
requireNarrationAudio: boolean;
}): Promise<FrozenLearningPackageRecord> {
return finalizeSerially(`single:${input.classroom.id}`, async () => {
const sourceDigest = stableSourceDigest({
sourceDigestVersion: 1,
mode: 'single',
courseId: input.classroom.id,
classroom: {
stage: input.classroom.stage,
scenes: input.classroom.scenes,
},
requireInteractiveHtml: input.requireInteractiveHtml,
requireNarrationAudio: input.requireNarrationAudio,
});
const existing = await existingFinalization(
input.classroom.id,
input.jobId,
sourceDigest,
'single',
);
if (existing) return existing;
const packaged = await packagePersistedClassroom(input.classroom, {
coursewareId: input.classroom.id,
version: 1,
baseUrl: input.baseUrl,
requireInteractiveHtml: input.requireInteractiveHtml,
requireNarrationAudio: input.requireNarrationAudio,
});
const bytes = new Uint8Array(await packaged.zip.arrayBuffer());
const signature = archiveSignature(bytes);
const record: FrozenLearningPackageRecord = {
// Schema v1 keeps the existing runtime Q&A record reader compatible;
// mode and exact frozen-archive metadata are additive.
schemaVersion: 1,
courseId: input.classroom.id,
mode: 'single',
sourceJobId: input.jobId,
sourceDigest,
contentHash: packaged.contentHash,
archiveSha256: sha256(bytes),
archiveBytes: bytes.byteLength,
formatVersion: packaged.meta.formatVersion,
minPlayerVersion: '2.0.0',
title: input.classroom.stage.name,
language: input.classroom.stage.languageDirective,
sceneCount: input.classroom.scenes.length,
moduleCount: 1,
capabilities: {
...classroomCapabilities(input.classroom, packaged.meta.runtimeCapabilities),
...(signature ? { archiveSignature: signature } : {}),
},
classroom: { stage: input.classroom.stage, scenes: input.classroom.scenes },
createdAt: packaged.meta.publishedAt,
};
await persistFrozenLearningPackage(record, bytes);
return record;
});
}
export async function finalizeLargeLearningCourse(input: {
jobId: string;
course: CourseRecord;
baseUrl: string;
}): Promise<FrozenLearningPackageRecord> {
return finalizeSerially(`large:${input.course.id}`, () =>
runCoursePublishExclusive(input.course.id, async () => {
// The persisted record is authoritative in production. The fallback keeps
// the pure finalizer usable for isolated packaging tests.
const course = (await readCourseRecord(input.course.id)) ?? input.course;
if (course.status !== 'completed' || !course.framework || course.modules.length === 0) {
throw new Error('Large course is not complete');
}
const moduleSources = [] as Array<{
moduleRecord: CourseRecord['modules'][number];
classroom: PersistedClassroomData;
}>;
for (const moduleRecord of [...course.modules].sort((a, b) => a.index - b.index)) {
if (moduleRecord.status !== 'succeeded' || !moduleRecord.classroomId) {
throw new Error(`Large course module ${moduleRecord.index} is not complete`);
}
const classroom = await readClassroom(moduleRecord.classroomId);
if (!classroom) throw new Error(`Classroom ${moduleRecord.classroomId} is unavailable`);
moduleSources.push({ moduleRecord, classroom });
}
const sourceDigest = stableSourceDigest({
sourceDigestVersion: 1,
mode: 'large',
courseId: course.id,
framework: course.framework,
requireInteractiveHtml: course.interactiveMode ?? true,
requireNarrationAudio: course.enableTTS ?? true,
modules: moduleSources.map(({ moduleRecord, classroom }) => ({
index: moduleRecord.index,
title: moduleRecord.title,
description: moduleRecord.description,
classroomId: moduleRecord.classroomId,
classroom: { stage: classroom.stage, scenes: classroom.scenes },
})),
});
const existing = await existingFinalization(course.id, input.jobId, sourceDigest, 'large');
if (existing) return existing;
const modulePackages = [] as Array<{
index: number;
title: string;
description: string;
coursewareId: string;
classroomId: string;
contentHash: string;
archiveSha256: string;
archiveBytes: number;
archivePath: string;
sceneCount: number;
formatVersion: number;
bytes: Uint8Array;
capabilities: unknown;
}>;
for (const { moduleRecord, classroom } of moduleSources) {
const coursewareId = `${course.id}_m${moduleRecord.index}`;
const packaged = await packagePersistedClassroom(classroom, {
coursewareId,
version: 1,
baseUrl: input.baseUrl,
requireInteractiveHtml: course.interactiveMode ?? true,
requireNarrationAudio: course.enableTTS ?? true,
});
const bytes = new Uint8Array(await packaged.zip.arrayBuffer());
modulePackages.push({
index: moduleRecord.index,
title: moduleRecord.title,
description: moduleRecord.description,
coursewareId,
classroomId: classroom.id,
contentHash: packaged.contentHash,
archiveSha256: sha256(bytes),
archiveBytes: bytes.byteLength,
archivePath: `modules/${coursewareId}/`,
sceneCount: classroom.scenes.length,
formatVersion: packaged.meta.formatVersion,
bytes,
capabilities: packaged.meta.runtimeCapabilities,
});
}
const structureModules = modulePackages.map(({ bytes: _bytes, capabilities, ...module }) => ({
...module,
capabilities,
}));
const contentHash = sha256(
JSON.stringify([
'learning-course-aggregate-v1',
course.id,
structureModules.map((module) => [
module.index,
module.coursewareId,
module.contentHash,
module.archiveSha256,
]),
]),
);
const courseStructure = {
schemaVersion: 1,
kind: 'learning-course-aggregate',
courseId: course.id,
title: course.framework.courseTitle,
summary: course.framework.summary,
language: course.framework.languageDirective,
targetAudience: course.framework.targetAudience,
contentHash,
moduleCount: structureModules.length,
modules: structureModules,
runtimeBoundary: {
offline: [
'playback',
'media',
'audio',
'interactive-html',
'objective-quiz',
'pbl-template',
],
hostBridge: ['agent', 'asr', 'pbl-evaluation', 'subjective-quiz-grading'],
htmlNetworkAccess: false,
},
};
const courseDocument = {
schemaVersion: 1,
kind: 'learning-course-aggregate',
courseId: course.id,
title: course.framework.courseTitle,
summary: course.framework.summary,
language: course.framework.languageDirective,
targetAudience: course.framework.targetAudience,
contentHash,
moduleCount: structureModules.length,
modules: structureModules.map((module) => ({
moduleId: module.coursewareId,
index: module.index,
title: module.title,
summary: module.description,
sceneCount: module.sceneCount,
contentHash: module.contentHash,
path: module.archivePath,
})),
runtimeBoundary: courseStructure.runtimeBoundary,
};
const zip = new JSZip();
zip.file('course.json', JSON.stringify(courseDocument, null, 2));
for (const modulePackage of modulePackages) {
const source = await JSZip.loadAsync(modulePackage.bytes);
for (const required of [
'manifest.json',
'bundle.json',
'quiz/quiz.json',
'knowledge/knowledge.json',
]) {
if (!source.file(required)) {
throw new Error(`Module ${modulePackage.coursewareId} is missing ${required}`);
}
}
for (const [name, entry] of Object.entries(source.files)) {
if (entry.dir) continue;
assertSafeBundleEntry(name);
zip.file(`${modulePackage.archivePath}${name}`, await entry.async('uint8array'), {
binary: true,
});
}
}
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
const signature = archiveSignature(bytes);
const record: FrozenLearningPackageRecord = {
schemaVersion: 2,
courseId: course.id,
mode: 'large',
sourceJobId: input.jobId,
sourceDigest,
contentHash,
archiveSha256: sha256(bytes),
archiveBytes: bytes.byteLength,
formatVersion: 2,
// Aggregate module playback ships in the current Makelore 2.0.0
// consumer; do not advertise a nonexistent 3.x compatibility floor.
minPlayerVersion: '2.0.0',
title: course.framework.courseTitle,
language: course.framework.languageDirective,
sceneCount: modulePackages.reduce((total, module) => total + module.sceneCount, 0),
moduleCount: modulePackages.length,
capabilities: {
modular: true,
orderedModules: true,
productionStagePerModule: true,
...(signature ? { archiveSignature: signature } : {}),
},
courseStructure,
createdAt: new Date().toISOString(),
};
await persistFrozenLearningPackage(record, bytes);
return record;
}),
);
}
export function frozenLearningFinalizeResponse(record: FrozenLearningPackageRecord) {
return {
contractVersion: 2,
mode: record.mode,
courseId: record.courseId,
title: record.title,
language: record.language,
contentHash: record.contentHash,
archiveSha256: record.archiveSha256,
archiveBytes: record.archiveBytes,
formatVersion: record.formatVersion,
minPlayerVersion: record.minPlayerVersion,
sceneCount: record.sceneCount,
moduleCount: record.moduleCount,
capabilities: record.capabilities,
courseStructure: record.courseStructure,
};
}

View File

@@ -0,0 +1,34 @@
import type { CourseRecord } from '@/lib/course-framework/types';
import { readCourseRecord } from '@/lib/course-framework/store';
import { readFrozenLearningPackageRecord } from '@/lib/makelore-course/package';
type CoursePublicationState = Pick<CourseRecord, 'publication' | 'externalPublication'>;
/** A frozen course id is a one-shot immutable product identity. */
export class FrozenLearningCourseMutationError extends Error {
constructor(public readonly courseId: string) {
super(`课程 ${courseId} 已冻结,不能继续生成或重新生成;如需修改,请新建课程`);
this.name = 'FrozenLearningCourseMutationError';
}
}
/**
* Enforce the single immutable-course rule at every source mutation boundary.
*
* The frozen record is authoritative: Works can successfully finalize an
* archive and then fail before writing the later external-publication receipt.
* Legacy publication receipts remain additional evidence for courses created
* before aggregate finalization was introduced.
*/
export async function assertLearningCourseMutable(
courseId: string,
knownRecord?: CoursePublicationState | null,
): Promise<void> {
const [frozen, record] = await Promise.all([
readFrozenLearningPackageRecord(courseId),
knownRecord === undefined ? readCourseRecord(courseId) : Promise.resolve(knownRecord),
]);
if (frozen || record?.publication || record?.externalPublication) {
throw new FrozenLearningCourseMutationError(courseId);
}
}

View File

@@ -0,0 +1,240 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import JSZip from 'jszip';
import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract';
import { inlineHtmlAssets } from '@/lib/export/inline-assets';
import type { Scene, Stage } from '@/lib/types/stage';
export type MakeloreCoursePackage = {
bytes: Uint8Array;
contentHash: string;
archiveSha256: string;
archiveBytes: number;
sceneCount: number;
title: string;
language?: string;
capabilities: {
sceneKinds: Array<'slide' | 'interactive' | 'quiz' | 'pbl'>;
hasAudio: boolean;
hasWhiteboard: boolean;
hasAgent: boolean;
};
classroom: { stage: Stage; scenes: Scene[] };
};
const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
export async function packageUniqueMakeloreCourse(input: {
courseId: string;
stage: Stage;
scenes: Scene[];
fetchImpl?: typeof fetch;
}): Promise<MakeloreCoursePackage> {
const scenes = await Promise.all(
input.scenes.map(async (scene) => {
if (scene.content.type !== 'interactive' || !scene.content.html) return scene;
const inlined = await inlineHtmlAssets(scene.content.html, {
fetchImpl: input.fetchImpl,
keepImportmapFallbacks: false,
});
if (inlined.report.failed.length || inlined.unresolved.length) {
throw new Error(`Interactive scene ${scene.id} is not offline-complete`);
}
return { ...scene, content: { ...scene.content, html: inlined.html } } as Scene;
}),
);
const classroom = { stage: input.stage, scenes };
const knowledge = extractKnowledgePack(input.courseId, input.stage, scenes);
const quiz = extractQuizPack(scenes);
const classroomJson = JSON.stringify(classroom);
const knowledgeJson = JSON.stringify(knowledge);
const quizJson = JSON.stringify(quiz);
const contentHash = sha256(
JSON.stringify([
['classroom.json', sha256(classroomJson)],
['knowledge/knowledge.json', sha256(knowledgeJson)],
['quiz/quiz.json', sha256(quizJson)],
]),
);
const sceneKinds = [...new Set(scenes.map((scene) => scene.type))].filter(
(kind): kind is 'slide' | 'interactive' | 'quiz' | 'pbl' =>
['slide', 'interactive', 'quiz', 'pbl'].includes(kind),
);
const capabilities = {
sceneKinds,
hasAudio: scenes.some((scene) =>
scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)),
),
hasWhiteboard: Boolean(
input.stage.whiteboard?.length || scenes.some((scene) => Boolean(scene.whiteboards?.length)),
),
hasAgent: Boolean(input.stage.generatedAgentConfigs?.length),
};
const descriptor = {
schemaVersion: 1,
kind: 'makelore-course-package',
courseId: input.courseId,
contentHash,
formatVersion: 1,
minPlayerVersion: '2.0.0',
title: input.stage.name,
language: input.stage.languageDirective,
sceneCount: scenes.length,
capabilities,
createdAt: new Date().toISOString(),
};
const zip = new JSZip();
zip.file('descriptor.json', JSON.stringify(descriptor, null, 2));
zip.file('classroom.json', classroomJson);
zip.file('knowledge/knowledge.json', knowledgeJson);
zip.file('quiz/quiz.json', quizJson);
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
return {
bytes,
contentHash,
archiveSha256: sha256(bytes),
archiveBytes: bytes.byteLength,
sceneCount: scenes.length,
title: input.stage.name,
language: input.stage.languageDirective,
capabilities,
classroom,
};
}
function packageRoot(): string {
const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data');
return resolve(
process.env.LEARNING_COURSE_PACKAGE_DIR ||
process.env.MAKELORE_COURSE_PACKAGE_DIR ||
resolve(dataRoot, 'makelore-packages'),
);
}
function courseStoreRoot(): string {
const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data');
return resolve(
process.env.LEARNING_COURSE_STORE_DIR ||
process.env.MAKELORE_COURSE_STORE_DIR ||
resolve(dataRoot, 'makelore-courses'),
);
}
function assertLearningCourseId(courseId: string): void {
if (!/^[A-Za-z0-9_-]{1,64}$/.test(courseId)) throw new Error('Invalid learning course id');
}
export async function persistUniqueMakeloreCourse(
courseId: string,
course: MakeloreCoursePackage,
): Promise<string> {
const packages = packageRoot();
const records = courseStoreRoot();
await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]);
const packagePath = resolve(packages, `${courseId}.zip`);
const packageTemp = `${packagePath}.${process.pid}.tmp`;
const recordPath = resolve(records, `${courseId}.json`);
const recordTemp = `${recordPath}.${process.pid}.tmp`;
await writeFile(packageTemp, course.bytes);
await writeFile(
recordTemp,
JSON.stringify(
{
schemaVersion: 1,
courseId,
contentHash: course.contentHash,
title: course.title,
language: course.language,
classroom: course.classroom,
},
null,
2,
),
);
await rename(packageTemp, packagePath);
await rename(recordTemp, recordPath);
return packagePath;
}
export async function readUniqueMakeloreCoursePackage(
courseId: string,
): Promise<Uint8Array | null> {
assertLearningCourseId(courseId);
try {
return new Uint8Array(await readFile(resolve(packageRoot(), `${courseId}.zip`)));
} catch {
return null;
}
}
export interface FrozenLearningPackageRecord {
schemaVersion: 1 | 2;
courseId: string;
mode: 'single' | 'large';
sourceJobId: string;
/**
* Stable digest of the exact mutable source snapshot used to build this
* archive. Legacy records omit it and must never be reused solely by job id.
*/
sourceDigest?: string;
contentHash: string;
archiveSha256: string;
archiveBytes: number;
formatVersion: number;
minPlayerVersion: string;
title: string;
language?: string;
sceneCount: number;
moduleCount: number;
capabilities: Record<string, unknown>;
courseStructure?: Record<string, unknown>;
classroom?: { stage: Stage; scenes: Scene[] };
createdAt: string;
}
/** Persist exact frozen bytes plus the metadata Works uses for idempotent finalize. */
export async function persistFrozenLearningPackage(
record: FrozenLearningPackageRecord,
bytes: Uint8Array,
): Promise<void> {
assertLearningCourseId(record.courseId);
const packages = packageRoot();
const records = courseStoreRoot();
await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]);
const packagePath = resolve(packages, `${record.courseId}.zip`);
const recordPath = resolve(records, `${record.courseId}.json`);
const suffix = `${process.pid}.${Date.now()}.tmp`;
const packageTemp = `${packagePath}.${suffix}`;
const recordTemp = `${recordPath}.${suffix}`;
await writeFile(packageTemp, bytes);
await writeFile(recordTemp, JSON.stringify(record, null, 2));
await rename(packageTemp, packagePath);
await rename(recordTemp, recordPath);
}
export async function readFrozenLearningPackageRecord(
courseId: string,
): Promise<FrozenLearningPackageRecord | null> {
assertLearningCourseId(courseId);
try {
const value = JSON.parse(
await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8'),
) as Partial<FrozenLearningPackageRecord>;
if (
value.courseId !== courseId ||
(value.mode !== 'single' && value.mode !== 'large') ||
(value.sourceDigest !== undefined && !/^[0-9a-f]{64}$/.test(value.sourceDigest)) ||
typeof value.contentHash !== 'string' ||
!/^[0-9a-f]{64}$/.test(value.contentHash) ||
typeof value.archiveSha256 !== 'string' ||
!/^[0-9a-f]{64}$/.test(value.archiveSha256) ||
typeof value.archiveBytes !== 'number'
) {
return null;
}
return value as FrozenLearningPackageRecord;
} catch {
return null;
}
}