chore: establish learning module baseline
This commit is contained in:
168
OpenMAIC/lib/bundle/extract.ts
Normal file
168
OpenMAIC/lib/bundle/extract.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Knowledge & quiz extraction for the frozen courseware bundle.
|
||||
//
|
||||
// At publish time the packager derives two cheap, static artifacts from the
|
||||
// classroom payload:
|
||||
//
|
||||
// - the knowledge pack (per-scene plain text + narration) — the retrieval
|
||||
// source for the L2 teaching-assistant Q&A endpoint. Retrieval over these
|
||||
// chunks costs zero LLM calls.
|
||||
// - the quiz pack — quiz questions per scene, used by the L3 player and by
|
||||
// L2 Q&A retrieval.
|
||||
//
|
||||
// Pure functions only: no storage, no network, no browser globals.
|
||||
|
||||
import type {
|
||||
InteractiveContent,
|
||||
PBLContent,
|
||||
QuizContent,
|
||||
Scene,
|
||||
SceneContent,
|
||||
SlideContent,
|
||||
} from '@/lib/types/stage';
|
||||
import type { SpeechAction } from '@/lib/types/action';
|
||||
import type { PPTElement, PPTTextElement } from '@openmaic/dsl';
|
||||
import type { KnowledgePack, KnowledgeSceneEntry, QuizPack } from './types';
|
||||
|
||||
/** Per-scene plain-text budget for the knowledge pack (chars). */
|
||||
export const KNOWLEDGE_SCENE_TEXT_MAX_CHARS = 4000;
|
||||
/** Budget for text extracted out of interactive HTML (chars). */
|
||||
export const KNOWLEDGE_INTERACTIVE_MAX_CHARS = 1500;
|
||||
|
||||
/** Collapse HTML into readable plain text (tag-strip + entity decode). */
|
||||
export function htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|h[1-6]|tr|pre)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'|'/gi, "'")
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractSlideText(content: SlideContent): string {
|
||||
const elements = content.canvas?.elements ?? [];
|
||||
const parts: string[] = [];
|
||||
for (const element of elements as PPTElement[]) {
|
||||
if (element.type === 'text' && typeof (element as PPTTextElement).content === 'string') {
|
||||
const text = htmlToPlainText((element as PPTTextElement).content);
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function extractQuizText(content: QuizContent): string {
|
||||
return content.questions
|
||||
.map((q) => {
|
||||
const options = (q.options ?? [])
|
||||
.map((o) => `${o.value}: ${o.label}`)
|
||||
.join(';');
|
||||
const parts = [`问:${q.question}`];
|
||||
if (options) parts.push(`选项:${options}`);
|
||||
if (q.analysis) parts.push(`解析:${q.analysis}`);
|
||||
return parts.join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function extractInteractiveText(content: InteractiveContent): string {
|
||||
const html = (content as { html?: unknown }).html;
|
||||
if (typeof html !== 'string' || !html) return '';
|
||||
return htmlToPlainText(html).slice(0, KNOWLEDGE_INTERACTIVE_MAX_CHARS);
|
||||
}
|
||||
|
||||
/** Best-effort string fields from a PBL project record (v1 or v2). */
|
||||
function firstString(value: unknown, keys: string[]): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
for (const key of keys) {
|
||||
const field = (value as Record<string, unknown>)[key];
|
||||
if (typeof field === 'string' && field.trim()) return field.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractPBLText(content: PBLContent): string {
|
||||
const parts: string[] = [];
|
||||
const v2 = (content as { projectV2?: unknown }).projectV2;
|
||||
const legacy = (content as { projectConfig?: unknown }).projectConfig;
|
||||
const title = firstString(v2, ['title', 'name']) ?? firstString(legacy, ['title', 'name']);
|
||||
if (title) parts.push(`项目:${title}`);
|
||||
const description =
|
||||
firstString(v2, ['description']) ?? firstString(legacy, ['description', 'overview']);
|
||||
if (description) parts.push(description);
|
||||
const tasks = firstString(v2, ['tasks']);
|
||||
if (tasks) parts.push(tasks);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** Extract the plain-text knowledge for one scene, across all four kinds. */
|
||||
export function extractSceneText(content: SceneContent): string {
|
||||
switch (content.type) {
|
||||
case 'slide':
|
||||
return extractSlideText(content);
|
||||
case 'quiz':
|
||||
return extractQuizText(content);
|
||||
case 'interactive':
|
||||
return extractInteractiveText(content);
|
||||
case 'pbl':
|
||||
return extractPBLText(content);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function extractSceneNarration(scene: Scene): string | undefined {
|
||||
const speeches = (scene.actions ?? []).filter(
|
||||
(action): action is SpeechAction => action.type === 'speech',
|
||||
);
|
||||
const text = speeches.map((s) => s.text).filter(Boolean).join('\n');
|
||||
return text ? text : undefined;
|
||||
}
|
||||
|
||||
/** Build the knowledge pack for a courseware payload. */
|
||||
export function extractKnowledgePack(
|
||||
coursewareId: string,
|
||||
stage: { languageDirective?: string },
|
||||
scenes: readonly Scene[],
|
||||
): KnowledgePack {
|
||||
const entries: KnowledgeSceneEntry[] = scenes.map((scene) => {
|
||||
const text = extractSceneText(scene.content).slice(0, KNOWLEDGE_SCENE_TEXT_MAX_CHARS);
|
||||
const narration = extractSceneNarration(scene);
|
||||
return {
|
||||
sceneId: scene.id,
|
||||
order: scene.order,
|
||||
title: scene.title,
|
||||
type: scene.type,
|
||||
text,
|
||||
...(narration ? { narration } : {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
coursewareId,
|
||||
...(stage.languageDirective ? { language: stage.languageDirective } : {}),
|
||||
scenes: entries,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the quiz pack for a courseware payload (quiz scenes only). */
|
||||
export function extractQuizPack(scenes: readonly Scene[]): QuizPack {
|
||||
return {
|
||||
scenes: scenes
|
||||
.filter((scene): scene is Scene & { content: QuizContent } => scene.content.type === 'quiz')
|
||||
.map((scene) => ({
|
||||
sceneId: scene.id,
|
||||
order: scene.order,
|
||||
title: scene.title,
|
||||
questions: scene.content.questions,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user