feat: productionize learning engine and classroom

This commit is contained in:
inman
2026-08-16 21:44:52 +08:00
parent 2d04197f3f
commit ba35adfbfa
124 changed files with 9071 additions and 796 deletions

View File

@@ -62,6 +62,8 @@ export interface PackageSources {
appVersion?: string;
/** Fail the whole publish on any missing resource. Default false (report only). */
requireComplete?: boolean;
/** Require generated bytes for narration scripts. Disable only when TTS was intentionally off. */
requireNarrationAudio?: boolean;
/** Require at least one portable agent persona snapshot. */
requireAgentRoster?: boolean;
/** Require at least one interactive Scene with generated, non-empty HTML. */
@@ -147,6 +149,22 @@ function missing(
return { kind, ref, reason, ...(sceneId ? { sceneId } : {}) };
}
function residualExecutableNetworkDependencies(html: string): string[] {
const dependencies = new Set<string>();
for (const match of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
const script = match[1] ?? '';
for (const url of script.match(/https?:\/\/[^\s'"`<>\\)]+/gi) ?? []) {
dependencies.add(url);
}
// Inlining pyodide.js does not inline its wasm/stdlib downloads. Until the
// player ships a frozen local Pyodide distribution, treating loadPyodide
// as complete would create a package that predictably fails under the
// offline CSP (`connect-src 'none'`).
if (/\bloadPyodide\s*\(/.test(script)) dependencies.add('dynamic:loadPyodide');
}
return [...dependencies];
}
/**
* Package a classroom into a frozen bundle. See module doc for policy.
*/
@@ -167,6 +185,7 @@ export async function packageCourseware(sources: PackageSources): Promise<Packag
publishedAt = new Date().toISOString(),
appVersion = '0.0.0',
requireComplete = false,
requireNarrationAudio = true,
requireAgentRoster = false,
requireInteractiveHtml = false,
strictInteractiveAssets = false,
@@ -187,20 +206,24 @@ export async function packageCourseware(sources: PackageSources): Promise<Packag
if (action.type !== 'speech') continue;
const speech = action as SpeechAction;
if (!speech.audioId) {
missingResources.push(
missing(
'audio',
`${scene.id}:${speech.text.slice(0, 40)}`,
'speech action without audioId',
scene.id,
),
);
if (requireNarrationAudio) {
missingResources.push(
missing(
'audio',
`${scene.id}:${speech.text.slice(0, 40)}`,
'speech action without audioId',
scene.id,
),
);
}
continue;
}
if (audioIdToPath.has(speech.audioId)) continue;
const record = await resolveAudioBytes?.(speech.audioId);
if (!record) {
missingResources.push(missing('audio', speech.audioId, 'no narration bytes', scene.id));
if (requireNarrationAudio) {
missingResources.push(missing('audio', speech.audioId, 'no narration bytes', scene.id));
}
continue;
}
const ext = record.type.split('/')[1]?.replace('mpeg', 'mp3') || 'mp3';
@@ -256,7 +279,7 @@ export async function packageCourseware(sources: PackageSources): Promise<Packag
// ── 3. Inline interactive HTML ─────────────────────────────────────────
const inlineReport: InlineReport = { inlined: [], failed: [] };
const preparedScenes: Scene[] = await Promise.all(
const preparedScenes: Scene[] = (await Promise.all(
scenes.map(async (scene) => {
const content = scene.content;
if (
@@ -279,10 +302,35 @@ export async function packageCourseware(sources: PackageSources): Promise<Packag
missing('interactive', url, 'interactive asset remained external', scene.id),
);
}
for (const dependency of residualExecutableNetworkDependencies(html)) {
missingResources.push(
missing(
'interactive',
dependency,
dependency === 'dynamic:loadPyodide'
? 'Pyodide runtime is not frozen for offline playback'
: 'inline script retains an external runtime URL',
scene.id,
),
);
}
}
return { ...scene, content: { ...content, html } } as Scene;
}),
);
)).map((scene) => {
if (requireNarrationAudio || !scene.actions?.length) return scene;
const actions = scene.actions.map((action) => {
if (action.type !== 'speech' || (action.audioId && audioIdToPath.has(action.audioId))) {
return action;
}
// A deliberately silent bundle must not retain a remote audio URL. The
// player will use its narration-duration fallback and never reach the
// network for a resource the operator explicitly chose not to generate.
const { audioUrl: _audioUrl, ...silentSpeech } = action;
return silentSpeech;
});
return { ...scene, actions } as Scene;
});
for (const failure of inlineReport.failed) {
missingResources.push(
missing('interactive', failure.url, 'interactive asset could not be inlined'),
@@ -315,14 +363,16 @@ export async function packageCourseware(sources: PackageSources): Promise<Packag
prompt: media.record.prompt,
};
}
// Missing speech audio stays visible to the player (graceful degrade) and is
// reported by completeness for the publish gate.
for (const scene of scenes) {
for (const action of scene.actions ?? []) {
if (action.type === 'speech') {
const audioId = (action as SpeechAction).audioId;
if (audioId && !audioIdToPath.has(audioId)) {
mediaIndex[`audio/${audioId}.mp3`] = { type: 'audio', missing: true };
if (requireNarrationAudio) {
// Strict narration packages expose the gap to the player and to the
// completeness gate. TTS-disabled packages intentionally omit the ref.
for (const scene of scenes) {
for (const action of scene.actions ?? []) {
if (action.type === 'speech') {
const audioId = (action as SpeechAction).audioId;
if (audioId && !audioIdToPath.has(audioId)) {
mediaIndex[`audio/${audioId}.mp3`] = { type: 'audio', missing: true };
}
}
}
}
@@ -402,6 +452,23 @@ export async function packageCourseware(sources: PackageSources): Promise<Packag
contentHash,
contentHashVersion: FROZEN_BUNDLE_CONTENT_HASH_VERSION,
knowledgeVersion: 1,
runtimeCapabilities: {
offlinePlayback: true,
htmlSandbox: { externalNetwork: false, hostBridgeOnly: true },
packaged: {
audio: collectedAudio.length > 0,
media: collectedMedia.length > 0,
interactiveHtml: preparedScenes.some((scene) => scene.content?.type === 'interactive'),
pblTemplate: preparedScenes.some((scene) => scene.content?.type === 'pbl'),
objectiveQuizGrading: preparedScenes.some((scene) => scene.content?.type === 'quiz'),
},
hostBridges: {
agent: 'online-optional',
asr: 'online-optional',
pblEvaluation: 'online-optional',
subjectiveQuizGrading: 'online-optional',
},
},
};
zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify({ meta, completeness }, null, 2));

View File

@@ -101,6 +101,29 @@ export interface FrozenBundleMeta {
/** Hash algorithm marker. Missing means legacy v1. */
contentHashVersion?: number;
knowledgeVersion: number;
/** Explicit offline/host boundary consumed by Makelore's capability bridge. */
runtimeCapabilities?: FrozenBundleRuntimeCapabilities;
}
export interface FrozenBundleRuntimeCapabilities {
offlinePlayback: true;
htmlSandbox: {
externalNetwork: false;
hostBridgeOnly: true;
};
packaged: {
audio: boolean;
media: boolean;
interactiveHtml: boolean;
pblTemplate: boolean;
objectiveQuizGrading: boolean;
};
hostBridges: {
agent: 'online-optional';
asr: 'online-optional';
pblEvaluation: 'online-optional';
subjectiveQuizGrading: 'online-optional';
};
}
/** Content of `bundle.json` inside the ZIP. */