feat: productionize learning engine and classroom
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user