169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
// 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,
|
||
})),
|
||
};
|
||
}
|