chore: establish learning module baseline

This commit is contained in:
inman
2026-08-16 14:58:47 +08:00
commit 2d04197f3f
2409 changed files with 533050 additions and 0 deletions

View 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(/&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,
})),
};
}