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

169 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/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,
})),
};
}