feat: productionize learning engine and classroom
This commit is contained in:
1 parent
2d04197f3f
commit
ba35adfbfa
124 files changed
+9071
-796
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user