feat: productionize learning engine and classroom
This commit is contained in:
411
OpenMAIC/lib/makelore-course/finalize.ts
Normal file
411
OpenMAIC/lib/makelore-course/finalize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user