377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
// 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');
|
||
}
|