Files
openmaic/OpenMAIC/lib/makelore-runtime/course-store.ts

374 lines
12 KiB
TypeScript

import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import JSZip from 'jszip';
import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract';
import type { FrozenBundleDocument, KnowledgePack, QuizPack } from '@/lib/bundle/types';
import type { ClassroomManifest } from '@/lib/export/classroom-zip-types';
import {
readFrozenLearningPackageRecord,
readUniqueMakeloreCoursePackage,
type FrozenLearningPackageRecord,
} from '@/lib/makelore-course/package';
import type { CoursewareKnowledge } from '@/lib/qa/knowledge';
import type { Scene, Stage } from '@/lib/types/stage';
const SAFE_ID = /^[A-Za-z0-9_-]{1,96}$/;
const SAFE_COURSE_ID = /^[A-Za-z0-9_-]{1,64}$/;
const SHA256 = /^[0-9a-f]{64}$/;
const MAX_JSON_CHARS = 64 * 1024 * 1024;
export interface RuntimeCourseModuleDescriptor {
moduleId: string;
index: number;
title: string;
summary: string;
contentHash: string;
sceneCount: number;
root: string;
}
export interface MakeloreCourseRecord {
schemaVersion: 1 | 2;
courseId: string;
contentHash: string;
title: string;
language?: string;
mode: 'single' | 'large';
sceneCount: number;
moduleCount: number;
modules: RuntimeCourseModuleDescriptor[];
/** Only retained for pre-frozen compatibility records. */
classroom?: { stage: Stage; scenes: Scene[] };
frozenRecord?: FrozenLearningPackageRecord;
archive?: JSZip;
}
export interface RuntimeCourseModule {
course: MakeloreCourseRecord;
descriptor: RuntimeCourseModuleDescriptor;
manifest?: ClassroomManifest;
knowledge: KnowledgePack;
quiz: QuizPack;
bundle?: FrozenBundleDocument;
/** Compatibility path for legacy classroom.json packages. */
classroom?: { stage: Stage; scenes: Scene[] };
}
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 sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function positiveInteger(value: unknown): number | null {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null;
}
function safeRoot(value: unknown, moduleId: string): string | null {
if (typeof value !== 'string') return null;
const normalized = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized || normalized.split('/').some((part) => part === '.' || part === '..')) return null;
const expected = `modules/${moduleId}`;
return normalized === expected ? `${expected}/` : null;
}
async function zipJson<T>(zip: JSZip, path: string): Promise<T> {
const entry = zip.file(path);
if (!entry) throw new Error(`Frozen course is missing ${path}`);
const text = await entry.async('string');
if (text.length > MAX_JSON_CHARS) throw new Error(`Frozen course document is too large: ${path}`);
return JSON.parse(text) as T;
}
function singleDescriptor(
courseId: string,
contentHash: string,
title: string,
sceneCount: number,
moduleId = courseId,
): RuntimeCourseModuleDescriptor {
return { moduleId, index: 1, title, summary: '', contentHash, sceneCount, root: '' };
}
async function loadFrozenCourse(
courseId: string,
contentHash: string,
): Promise<MakeloreCourseRecord | null> {
const frozen = await readFrozenLearningPackageRecord(courseId);
if (!frozen || frozen.contentHash !== contentHash) return null;
const bytes = await readUniqueMakeloreCoursePackage(courseId);
if (
!bytes ||
bytes.byteLength !== frozen.archiveBytes ||
sha256(bytes) !== frozen.archiveSha256
) {
return null;
}
let archive: JSZip;
try {
archive = await JSZip.loadAsync(bytes, { checkCRC32: true });
} catch {
return null;
}
if (frozen.mode === 'single') {
let bundle: FrozenBundleDocument;
try {
bundle = await zipJson<FrozenBundleDocument>(archive, 'bundle.json');
} catch {
return null;
}
const moduleId = bundle.meta?.coursewareId;
if (
typeof moduleId !== 'string' ||
!SAFE_ID.test(moduleId) ||
bundle.meta.contentHash !== contentHash ||
!archive.file('manifest.json') ||
!archive.file('knowledge/knowledge.json') ||
!archive.file('quiz/quiz.json')
) {
return null;
}
return {
schemaVersion: frozen.schemaVersion,
courseId,
contentHash,
title: frozen.title,
language: frozen.language,
mode: 'single',
sceneCount: frozen.sceneCount,
moduleCount: 1,
modules: [singleDescriptor(courseId, contentHash, frozen.title, frozen.sceneCount, moduleId)],
frozenRecord: frozen,
archive,
};
}
const structure = record(frozen.courseStructure);
const structureModules = Array.isArray(structure?.modules)
? structure.modules.map(record).filter((item): item is Record<string, unknown> => item !== null)
: [];
let courseDocument: Record<string, unknown>;
try {
courseDocument = record(await zipJson<unknown>(archive, 'course.json')) ?? {};
} catch {
return null;
}
const courseModules = Array.isArray(courseDocument.modules)
? courseDocument.modules.map(record).filter((item): item is Record<string, unknown> => item !== null)
: [];
if (
structure?.kind !== 'learning-course-aggregate' ||
structure.contentHash !== contentHash ||
courseDocument.kind !== 'learning-course-aggregate' ||
courseDocument.courseId !== courseId ||
courseDocument.contentHash !== contentHash ||
structureModules.length === 0 ||
courseModules.length !== structureModules.length
) {
return null;
}
const modules: RuntimeCourseModuleDescriptor[] = [];
for (const [position, source] of structureModules.entries()) {
const moduleId = source.coursewareId;
const moduleHash = source.contentHash;
const index = positiveInteger(source.index);
const declared = courseModules.find((candidate) => candidate.moduleId === moduleId);
const root = safeRoot(declared?.path, typeof moduleId === 'string' ? moduleId : '');
if (
typeof moduleId !== 'string' ||
!SAFE_ID.test(moduleId) ||
typeof moduleHash !== 'string' ||
!SHA256.test(moduleHash) ||
index === null ||
!declared ||
declared.contentHash !== moduleHash ||
declared.index !== index ||
!root ||
!archive.file(`${root}manifest.json`) ||
!archive.file(`${root}bundle.json`) ||
!archive.file(`${root}knowledge/knowledge.json`) ||
!archive.file(`${root}quiz/quiz.json`)
) {
return null;
}
const sceneCount = positiveInteger(source.sceneCount);
modules.push({
moduleId,
index,
title: typeof source.title === 'string' ? source.title : `Module ${position + 1}`,
summary: typeof source.description === 'string' ? source.description : '',
contentHash: moduleHash,
sceneCount: sceneCount ?? 0,
root,
});
}
if (
new Set(modules.map((module) => module.moduleId)).size !== modules.length ||
modules.some((module, index) => index > 0 && module.index <= modules[index - 1]!.index)
) {
return null;
}
return {
schemaVersion: frozen.schemaVersion,
courseId,
contentHash,
title: frozen.title,
language: frozen.language,
mode: 'large',
sceneCount: frozen.sceneCount,
moduleCount: modules.length,
modules,
frozenRecord: frozen,
archive,
};
}
async function loadLegacyCourse(
courseId: string,
contentHash: string,
): Promise<MakeloreCourseRecord | null> {
let value: unknown;
try {
value = JSON.parse(await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8'));
} catch {
return null;
}
const legacy = record(value);
const classroom = record(legacy?.classroom);
if (
legacy?.schemaVersion !== 1 ||
legacy.courseId !== courseId ||
legacy.contentHash !== contentHash ||
typeof legacy.title !== 'string' ||
!classroom ||
!record(classroom.stage) ||
!Array.isArray(classroom.scenes)
) {
return null;
}
const typedClassroom = classroom as unknown as { stage: Stage; scenes: Scene[] };
return {
schemaVersion: 1,
courseId,
contentHash,
title: legacy.title,
...(typeof legacy.language === 'string' ? { language: legacy.language } : {}),
mode: 'single',
sceneCount: typedClassroom.scenes.length,
moduleCount: 1,
modules: [singleDescriptor(courseId, contentHash, legacy.title, typedClassroom.scenes.length)],
classroom: typedClassroom,
};
}
export function parseLearningBinding(key: string): { courseId: string; contentHash: string } | null {
const split = key.lastIndexOf(':');
if (split <= 0) return null;
const courseId = key.slice(0, split);
const contentHash = key.slice(split + 1);
return SAFE_COURSE_ID.test(courseId) && SHA256.test(contentHash)
? { courseId, contentHash }
: null;
}
/** Resolve only an exact aggregate course ID + content hash. */
export async function loadMakeloreCourse(
courseId: string,
contentHash: string,
): Promise<MakeloreCourseRecord | null> {
if (!SAFE_COURSE_ID.test(courseId) || !SHA256.test(contentHash)) return null;
return (await loadFrozenCourse(courseId, contentHash)) ?? loadLegacyCourse(courseId, contentHash);
}
export async function resolveMakeloreCourseModule(
course: MakeloreCourseRecord,
selection: { moduleId?: string | null; moduleContentHash?: string | null } = {},
): Promise<RuntimeCourseModule | null> {
let descriptor: RuntimeCourseModuleDescriptor | undefined;
if (course.mode === 'large') {
if (!selection.moduleId || !selection.moduleContentHash) return null;
descriptor = course.modules.find((module) => module.moduleId === selection.moduleId);
if (!descriptor || descriptor.contentHash !== selection.moduleContentHash) return null;
} else {
descriptor = course.modules[0];
if (!descriptor) return null;
if (
selection.moduleId &&
selection.moduleId !== descriptor.moduleId &&
selection.moduleId !== course.courseId
) {
return null;
}
if (selection.moduleContentHash && selection.moduleContentHash !== course.contentHash) return null;
}
if (!course.archive) {
const classroom = course.classroom;
if (!classroom) return null;
return {
course,
descriptor,
classroom,
knowledge: extractKnowledgePack(descriptor.moduleId, classroom.stage, classroom.scenes),
quiz: extractQuizPack(classroom.scenes),
};
}
try {
const [bundle, manifest, knowledge, quiz] = await Promise.all([
zipJson<FrozenBundleDocument>(course.archive, `${descriptor.root}bundle.json`),
zipJson<ClassroomManifest>(course.archive, `${descriptor.root}manifest.json`),
zipJson<KnowledgePack>(course.archive, `${descriptor.root}knowledge/knowledge.json`),
zipJson<QuizPack>(course.archive, `${descriptor.root}quiz/quiz.json`),
]);
if (
bundle.meta?.coursewareId !== descriptor.moduleId ||
bundle.meta.contentHash !== descriptor.contentHash ||
!Array.isArray(manifest.scenes) ||
!Array.isArray(knowledge.scenes) ||
!Array.isArray(quiz.scenes) ||
manifest.scenes.length !== descriptor.sceneCount
) {
return null;
}
return { course, descriptor, manifest, knowledge, quiz, bundle };
} catch {
return null;
}
}
export function courseKnowledge(module: RuntimeCourseModule): CoursewareKnowledge {
return {
coursewareId: module.descriptor.moduleId,
title: module.descriptor.title,
language: module.bundle?.meta.language ?? module.course.language,
knowledge: module.knowledge,
quiz: module.quiz,
};
}
/** Synthetic scene identity used by Makelore when materializing a frozen manifest. */
export function runtimeSceneId(
course: MakeloreCourseRecord,
module: RuntimeCourseModuleDescriptor,
sceneIndex: number,
): string {
return `learning_${course.courseId}_${module.moduleId}_s${sceneIndex}`;
}