Files
openmaic/OpenMAIC/lib/bundle/learner-load.ts
2026-08-16 14:58:47 +08:00

245 lines
8.6 KiB
TypeScript

'use client';
// Learner player loader — the L3 side of the frozen-bundle contract.
//
// Flow (per courseware version):
// 1. resolve the published record from the registry (GET /api/coursewares/:id),
// 2. if the local document for `learn_<id>_v<version>` already exists, reuse
// it (offline-capable: the bundle is fetched once, then cached in the
// document store + asset pool / IndexedDB),
// 3. otherwise download the bundle, materialize audio/media, build the
// playable document (NO generation outlines — see
// `import-classroom-core`), and commit it under the learner stage id.
//
// The classroom page then loads the committed course document without running
// outline, scene, or media generation. The original teacher/assistant, dynamic
// grading, and PBL teaching runtime remain available and may make their normal
// teaching-time model calls. Progress/answers are persisted through the
// runtime store (HTTP backend when NEXT_PUBLIC_PERSISTENCE=1).
import { nanoid } from 'nanoid';
import { accessDocument, mutateDocument } from '@/lib/document-store';
import { db } from '@/lib/utils/database';
import { removeAsset } from '@/lib/media/asset-pool';
import { createLogger } from '@/lib/logger';
import {
buildImportedDocument,
materializeImportedAudio,
materializeImportedMedia,
} from '@/lib/import/import-classroom-core';
import type { ClassroomManifest } from '@/lib/export/classroom-zip-types';
import { computeBundleContentHash, readFrozenBundleDocuments } from '@/lib/bundle/packager';
const log = createLogger('LearnerLoad');
export interface LearnerCoursewareDetail {
coursewareId: string;
version: number;
title: string;
language?: string;
bundleUrl: string;
sceneCount: number;
quizSceneCount: number;
contentHash: string;
}
export interface LearnerLoadResult {
/** The classroom document id the player should open. */
stageId: string;
/** True when an already-materialized copy was reused (no network). */
cached: boolean;
detail: LearnerCoursewareDetail;
}
/** Deterministic learner stage id per courseware version. */
export function learnerStageId(
coursewareId: string,
version: number,
contentHash?: string,
): string {
return `learn_${coursewareId}_v${version}${contentHash ? `_${contentHash}` : ''}`;
}
/** Resolve the published record for a courseware from the registry. */
export async function fetchCoursewareDetail(
coursewareId: string,
fetchImpl: typeof fetch = fetch,
version?: number,
): Promise<LearnerCoursewareDetail | null> {
const versionQuery =
version === undefined ? '' : `?version=${encodeURIComponent(String(version))}`;
const response = await fetchImpl(
`/api/coursewares/${encodeURIComponent(coursewareId)}${versionQuery}`,
);
if (!response.ok) return null;
const body = (await response.json()) as {
success?: boolean;
courseware?: LearnerCoursewareDetail;
};
if (!body.success || !body.courseware) return null;
return body.courseware;
}
/**
* Load (or reuse) the local playable copy of a published courseware. Throws
* with a user-presentable message on registry/network/materialization failure.
* The returned stage id opens via `/classroom/{stageId}?learner=1&courseware={id}`.
*/
export async function loadLearnerCourseware(
coursewareId: string,
options: {
fetchImpl?: typeof fetch;
version?: number;
expectedContentHash?: string;
} = {},
): Promise<LearnerLoadResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const hasVersion = options.version !== undefined;
const hasHash = options.expectedContentHash !== undefined;
if (hasVersion !== hasHash) {
throw new Error('Pinned courseware requires both version and content hash');
}
if (hasVersion && (!Number.isInteger(options.version) || (options.version ?? 0) < 1)) {
throw new Error('Invalid pinned courseware version');
}
if (hasHash && !/^[0-9a-f]{64}$/.test(options.expectedContentHash ?? '')) {
throw new Error('Invalid pinned courseware content hash');
}
const detail = await fetchCoursewareDetail(coursewareId, fetchImpl, options.version);
if (!detail) {
throw new Error(`Courseware not found: ${coursewareId}`);
}
if (
detail.coursewareId !== coursewareId ||
!Number.isInteger(detail.version) ||
!/^[0-9a-f]{64}$/.test(detail.contentHash)
) {
throw new Error(`Courseware registry returned an invalid immutable identity: ${coursewareId}`);
}
if (options.version !== undefined && detail.version !== options.version) {
throw new Error(
`Courseware version mismatch: expected v${options.version}, received v${detail.version}`,
);
}
if (
options.expectedContentHash !== undefined &&
detail.contentHash !== options.expectedContentHash
) {
throw new Error('Courseware content hash changed before download');
}
const stageId = learnerStageId(coursewareId, detail.version, detail.contentHash);
// Already materialized → straight to playback, no network.
const existing = await accessDocument(stageId);
if (existing.document) {
log.info('Learner courseware reused from local cache:', stageId);
return { stageId, cached: true, detail };
}
// Download + materialize the frozen bundle.
const bundleResponse = await fetchImpl(detail.bundleUrl);
if (!bundleResponse.ok) {
throw new Error(`Bundle download failed (HTTP ${bundleResponse.status})`);
}
const zipBlob = await bundleResponse.blob();
const now = Date.now();
const importedPoolIds: string[] = [];
let committed = false;
try {
const documents = await readFrozenBundleDocuments(zipBlob);
const computedHash = await computeBundleContentHash(zipBlob);
const legacyBrowserVersionTemplate =
documents.meta.contentHashVersion === undefined &&
documents.meta.version === 1 &&
detail.version > 1;
if (
documents.meta.coursewareId !== coursewareId ||
(documents.meta.version !== detail.version && !legacyBrowserVersionTemplate) ||
documents.meta.contentHash !== detail.contentHash ||
computedHash !== detail.contentHash ||
!documents.completeness.complete
) {
throw new Error(`Frozen bundle identity or completeness check failed: ${coursewareId}`);
}
const manifest = documents.manifest as ClassroomManifest;
// The bundle is complete by publish gate; materialize every audio/media
// entry into the pool + IndexedDB mirror (the playback engine's resolvers).
const zip = await loadZip(zipBlob);
const audioRefToNewId = await materializeImportedAudio(
zip,
manifest,
stageId,
now,
importedPoolIds,
);
const mediaMappings = await materializeImportedMedia(
zip,
manifest,
stageId,
now,
importedPoolIds,
);
const newAgentIds: string[] = (manifest.agents ?? []).map(() => nanoid());
const studentAgentIndex = manifest.agents?.findIndex((agent) => agent.role === 'student') ?? -1;
const nonTeacherAgentIndex =
manifest.agents?.findIndex((agent) => agent.role !== 'teacher') ?? -1;
const fallbackDiscussionAgentIndex =
studentAgentIndex >= 0
? studentAgentIndex
: nonTeacherAgentIndex >= 0
? nonTeacherAgentIndex
: undefined;
const document = buildImportedDocument(manifest, {
stageId,
now,
newAgentIds,
audioRefToNewId,
mediaMappings,
fallbackDiscussionAgentIndex,
});
// Commit point: one aggregate write under the per-stage lock.
await mutateDocument(stageId, async (_existing, store) => store.saveDocument(document));
committed = true;
log.info(`Learner courseware materialized: ${stageId}`);
return { stageId, cached: false, detail };
} catch (error) {
// Compensate partial materialization: media cannot join the document
// transaction, so roll back rows/allocations individually.
log.error('Learner courseware materialization failed:', error);
if (!committed) {
await cleanupFailedLoad(stageId, importedPoolIds);
}
throw error;
}
}
async function loadZip(zipBlob: Blob) {
const JSZip = (await import('jszip')).default;
const data = await zipBlob.arrayBuffer();
return JSZip.loadAsync(data);
}
async function cleanupFailedLoad(stageId: string, poolIds: string[]): Promise<void> {
try {
await mutateDocument(stageId, async (_document, store) => store.deleteDocument(stageId));
await db.mediaFiles.where('stageId').equals(stageId).delete();
await db.audioFiles.where('stageId').equals(stageId).delete();
for (const id of poolIds) {
try {
await removeAsset(id);
} catch {
// best effort
}
}
} catch (cleanupError) {
log.error('Learner cleanup failed:', cleanupError);
}
}