// 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).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 | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function decodeHtmlEntities(value: string): string { const named: Record = { 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(/]*>[\s\S]*?<\/script>/gi, ' ') .replace(/]*>[\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 = { 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 & { 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'); }