// 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(//gi, ' ') .replace(//gi, ' ') .replace(//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)[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, })), }; }