Files
openmaic/OpenMAIC/lib/makelore-course/package.ts

241 lines
7.9 KiB
TypeScript

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