Files
openmaic/OpenMAIC/lib/course-framework/module-digest.ts
2026-08-16 14:58:47 +08:00

377 lines
12 KiB
TypeScript
Raw 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.

// Actual-output continuity for large courses.
//
// The digest is deliberately deterministic and bounded: it reads the persisted
// classroom produced by the existing single-courseware pipeline and extracts
// enough semantic evidence for later modules without copying full slides, HTML,
// actions, or learner runtime state into the next model prompt.
import { createHash } from 'node:crypto';
import type { PersistedClassroomData } from '@/lib/server/classroom-storage';
import type { Scene } from '@/lib/types/stage';
import type {
CourseModuleOutputDigest,
CourseModuleRecord,
CourseModuleSceneDigest,
CourseModuleSceneType,
} from './types';
const MAX_TEXT_LENGTH = 280;
const MAX_SCENE_KEY_CONTENT = 6;
const MAX_COVERED_CONCEPTS = 30;
const MAX_ASSESSMENTS = 12;
const MAX_INTERACTIONS = 12;
const MAX_CONTEXT_CHARS = 12_000;
function canonicalJson(_key: string, value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
);
}
/**
* Full persisted classroom revision for publication integrity. Unlike the
* bounded semantic hash used in prompts, this includes HTML scripts/styles,
* actions, layout, media references and every other persisted source field.
*/
export function computeClassroomSourceRevisionHash(classroom: PersistedClassroomData): string {
return `sha256:${createHash('sha256')
.update(JSON.stringify(classroom, canonicalJson))
.digest('hex')}`;
}
const SEMANTIC_KEYS = new Set([
'title',
'description',
'text',
'content',
'question',
'analysis',
'commentPrompt',
'label',
'prompt',
'topic',
'concept',
'challenge',
'learningObjective',
'learningObjectives',
'objective',
'objectives',
'gains',
'tags',
'successCriteria',
'targetSkills',
'task',
'steps',
'code',
'latex',
'briefing',
'completionCriteria',
'successWhen',
'skillFocus',
'learnerBrief',
'hints',
'generated_questions',
'labels',
'legends',
'starterCode',
'expected',
]);
const SKIP_KEYS = new Set([
'threads',
'messages',
'submissions',
'evaluations',
'engagementEvents',
'runtimeState',
'attempts',
]);
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function decodeHtmlEntities(value: string): string {
const named: Record<string, string> = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
nbsp: ' ',
quot: '"',
};
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity: string) => {
if (entity.startsWith('#x')) {
const codePoint = Number.parseInt(entity.slice(2), 16);
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
}
if (entity.startsWith('#')) {
const codePoint = Number.parseInt(entity.slice(1), 10);
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
}
return named[entity.toLowerCase()] ?? match;
});
}
function cleanText(value: string): string {
const plain = decodeHtmlEntities(
value
.replace(/data:[^\s"']+/gi, '[embedded asset]')
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' '),
)
.replace(/\s+/g, ' ')
.trim();
return plain.length > MAX_TEXT_LENGTH ? `${plain.slice(0, MAX_TEXT_LENGTH - 1)}` : plain;
}
function pushUnique(output: string[], value: unknown, limit: number): void {
if (typeof value !== 'string' || output.length >= limit) return;
const cleaned = cleanText(value);
if (!cleaned) return;
const key = cleaned.toLocaleLowerCase();
if (output.some((item) => item.toLocaleLowerCase() === key)) return;
output.push(cleaned);
}
function collectSemanticStrings(
value: unknown,
output: string[],
options: { inheritedKey?: string; depth?: number; limit?: number } = {},
): void {
const { inheritedKey, depth = 0, limit = MAX_SCENE_KEY_CONTENT } = options;
if (output.length >= limit || depth > 6 || value == null) return;
if (typeof value === 'string') {
if (inheritedKey && SEMANTIC_KEYS.has(inheritedKey)) pushUnique(output, value, limit);
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectSemanticStrings(item, output, { inheritedKey, depth: depth + 1, limit });
if (output.length >= limit) break;
}
return;
}
const object = asObject(value);
if (!object) return;
for (const [key, child] of Object.entries(object)) {
if (SKIP_KEYS.has(key)) continue;
if (key === 'html' && typeof child === 'string') {
pushUnique(output, child, limit);
} else {
collectSemanticStrings(child, output, { inheritedKey: key, depth: depth + 1, limit });
}
if (output.length >= limit) break;
}
}
function extractSceneKeyContent(scene: Scene): string[] {
const output: string[] = [];
const content = asObject(scene.content);
if (scene.type === 'slide') {
const canvas = asObject(content?.canvas);
pushUnique(output, canvas?.script, MAX_SCENE_KEY_CONTENT);
collectSemanticStrings(canvas?.elements, output);
} else if (scene.type === 'quiz') {
collectSemanticStrings(content?.questions, output);
} else if (scene.type === 'interactive') {
collectSemanticStrings(content?.widgetConfig, output);
pushUnique(output, content?.html, MAX_SCENE_KEY_CONTENT);
} else if (scene.type === 'pbl') {
collectSemanticStrings(content?.projectV2 ?? content?.projectConfig, output);
}
collectSemanticStrings(scene.actions, output);
return output.slice(0, MAX_SCENE_KEY_CONTENT);
}
function extractAssessments(scene: Scene): string[] {
const output: string[] = [];
const content = asObject(scene.content);
if (scene.type === 'quiz') {
const questions = Array.isArray(content?.questions) ? content.questions : [];
for (const question of questions) {
pushUnique(output, asObject(question)?.question, MAX_ASSESSMENTS);
}
}
if (scene.type === 'pbl') {
const project = asObject(content?.projectV2 ?? content?.projectConfig);
const milestones = Array.isArray(project?.milestones) ? project.milestones : [];
for (const milestone of milestones) {
const item = asObject(milestone);
pushUnique(output, item?.title ?? item?.description, MAX_ASSESSMENTS);
}
}
return output;
}
function extractInteraction(scene: Scene): string | null {
const content = asObject(scene.content);
if (scene.type === 'interactive') {
const widgetConfig = asObject(content?.widgetConfig);
const widgetType =
(typeof content?.widgetType === 'string' && content.widgetType) ||
(typeof widgetConfig?.type === 'string' && widgetConfig.type) ||
'html';
const delivery =
typeof content?.html === 'string' && content.html.trim()
? 'interactive-html'
: 'interactive-url';
return `${scene.title}${widgetType}, ${delivery}`;
}
if (scene.type === 'pbl') return `${scene.title}PBL`;
return null;
}
export function buildCourseModuleOutputDigest(
classroom: PersistedClassroomData,
): CourseModuleOutputDigest {
const orderedScenes = [...classroom.scenes].sort((a, b) => a.order - b.order);
const sceneTypeCounts: Record<CourseModuleSceneType, number> = {
slide: 0,
quiz: 0,
interactive: 0,
pbl: 0,
};
const scenes: CourseModuleSceneDigest[] = orderedScenes.map((scene) => {
const type = scene.type as CourseModuleSceneType;
sceneTypeCounts[type] += 1;
return {
sceneId: scene.id,
order: scene.order,
title: cleanText(scene.title),
type,
keyContent: extractSceneKeyContent(scene),
};
});
const coveredConcepts: string[] = [];
for (const scene of scenes) {
pushUnique(coveredConcepts, scene.title, MAX_COVERED_CONCEPTS);
for (const item of scene.keyContent) {
pushUnique(coveredConcepts, item, MAX_COVERED_CONCEPTS);
}
}
const assessments: string[] = [];
const interactiveExperiences: string[] = [];
for (const scene of orderedScenes) {
for (const item of extractAssessments(scene)) {
pushUnique(assessments, item, MAX_ASSESSMENTS);
}
pushUnique(interactiveExperiences, extractInteraction(scene), MAX_INTERACTIONS);
}
const scenePath = scenes
.map((scene) => `${scene.order}. ${scene.title} [${scene.type}]`)
.join('');
const coveragePreview = coveredConcepts.slice(0, 8).join('');
const summary = cleanText(
`实际生成 ${scenes.length} 个场景:${scenePath}${coveragePreview ? `。主要覆盖:${coveragePreview}` : ''}`,
);
const stageTitle = cleanText(classroom.stage.name);
const stageDescription = classroom.stage.description
? cleanText(classroom.stage.description)
: undefined;
const languageDirective = classroom.stage.languageDirective
? cleanText(classroom.stage.languageDirective)
: undefined;
const semanticHash = `sha256:${createHash('sha256')
.update(
JSON.stringify({
stageTitle,
stageDescription,
languageDirective,
scenes,
assessments,
interactiveExperiences,
}),
)
.digest('hex')}`;
return {
digestVersion: 2,
classroomId: classroom.id,
classroomCreatedAt: classroom.createdAt,
sourceRevisionHash: computeClassroomSourceRevisionHash(classroom),
semanticHash,
stageTitle,
...(stageDescription ? { stageDescription } : {}),
...(languageDirective ? { languageDirective } : {}),
sceneCount: scenes.length,
sceneTypeCounts,
summary,
coveredConcepts,
assessments,
interactiveExperiences,
scenes,
};
}
export function formatPreviousModuleContext(
modules: Array<
Pick<CourseModuleRecord, 'index' | 'title' | 'outputDigest'> & {
outputDigest: CourseModuleOutputDigest;
}
>,
maxChars = MAX_CONTEXT_CHARS,
): string {
const sortedModules = [...modules].sort((a, b) => a.index - b.index);
const header = `已完成的前序模块(实际语义哈希):${sortedModules
.map(
(module) =>
`${module.index}${module.title}》#${module.outputDigest.semanticHash.slice(-12)}`,
)
.join('')}`;
const boundedHeader =
header.length > maxChars ? `${header.slice(0, Math.max(0, maxChars - 1))}` : header;
if (boundedHeader.length >= maxChars) return boundedHeader;
const sections = sortedModules.map((module) => {
const digest = module.outputDigest;
const lines = [
`模块 ${module.index}${module.title}`,
`- 实际摘要:${digest.summary}`,
digest.coveredConcepts.length
? `- 实际覆盖:${digest.coveredConcepts
.slice(0, 8)
.map((item) => (item.length > 100 ? `${item.slice(0, 99)}` : item))
.join('')}`
: '',
digest.interactiveExperiences.length
? `- 已有互动:${digest.interactiveExperiences.slice(0, 4).join('')}`
: '',
digest.assessments.length
? `- 已有考核:${digest.assessments
.slice(0, 4)
.map((item) => (item.length > 120 ? `${item.slice(0, 119)}` : item))
.join('')}`
: '',
];
const section = lines.filter(Boolean).join('\n');
return section.length > 900 ? `${section.slice(0, 899)}` : section;
});
const selected: string[] = [];
let used = boundedHeader.length + 2;
for (let index = sections.length - 1; index >= 0; index -= 1) {
const section = sections[index]!;
const remaining = maxChars - used;
if (remaining <= 0) break;
const bounded =
section.length > remaining ? `${section.slice(0, Math.max(0, remaining - 1))}` : section;
selected.unshift(bounded);
used += bounded.length + 2;
}
return [boundedHeader, ...selected].join('\n\n');
}