File size: 13,562 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | // Convert a SCERT Chemistry blueprint (math-blueprints/chem-*.json) into an
// ordered lesson draft of teaching scenes β the render pipeline's front-end,
// mirroring the Physics scene shape so the same rendering/TTS/HLS stages can
// consume it. This is content ONLY (narration + on-screen + intent + source
// lineage); timing and audio are added by later stages.
//
// CHAPTER_ID=math-p1-c1 npx tsx scripts/video-engine/build-math-lesson-draft.ts
// (no CHAPTER_ID => all seven chapters)
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = process.cwd();
const BLUEPRINT_DIR = join(ROOT, "data/curriculum/kerala-sslc/math-blueprints");
const OUT_DIR = join(ROOT, "outputs/video/math-course-2025/lessons");
const CHAPTERS = [
"math-p1-c1", "math-p1-c2", "math-p1-c3", "math-p1-c4", "math-p1-c5",
"math-p1-c6", "math-p1-c7", "math-p1-c8", "math-p1-c9", "math-p1-c10", "math-p1-c11", "math-p1-c12", "math-p1-c13",
];
type Block = { heading?: string; explanation?: string; key_terms?: string[]; teacher?: string };
type BoardFrame = { title?: string; teacher_action?: string; board_text?: string[]; diagram_description?: string; student_focus?: string; source_page?: number };
type Exam = { marks?: number; question?: string; answer_points?: string[]; keywords?: string[]; common_mistakes?: string[] };
type Quick = { question?: string; answer?: string; explanation?: string };
type Mission = {
mission_id: string; title: string; source_pages?: number[]; hook?: string; teacher_intro?: string;
explanation_blocks?: Block[]; analogy_or_observation?: string; board_frames?: BoardFrame[];
exam_practice?: Exam[]; quick_check?: Quick; misconception_check?: string; recap?: string | string[];
// Alternate ("checkpoint") schema used by some pre-authored chapters.
checkpoint_question?: string; checkpoint_options?: string[]; checkpoint_answer?: string;
};
type Blueprint = {
chapter_id: string; subject: string; chapter_title: string; source_book: string;
source_pdf_pages?: number[]; learning_objectives?: string[]; missions: Mission[];
chapter_recap?: string[]; render_guidance?: Record<string, unknown>;
};
type VisualFamily =
| "MOLECULAR_STRUCTURE" | "REACTION_EQUATION" | "FUNCTIONAL_GROUP" | "ISOMERISM"
| "PERIODIC_TREND" | "ELECTROLYSIS_CELL" | "METALLURGY_PROCESS" | "GAS_LAW"
| "MOLE_CALCULATION" | "LAB_EXPERIMENT" | "BOARD_ANSWER" | "RECAP" | "CONCEPT_TEXT";
const clean = (s: string | undefined) =>
(s ?? "")
.replace(/\bnamaste,?\s*/gi, "") // drop the repeated robotic opener anywhere
.replace(/\bmalayalam_rescue\b/gi, "")
.replace(/\bpause\b\.?/gi, "")
.replace(/\s+/g, " ")
.replace(/^\s*([a-z])/, (_, c) => c.toUpperCase())
.trim();
// Route a scene to a deterministic math visual family from its topic text.
function routeVisual(text: string, kind: string): VisualFamily {
const t = text.toLowerCase();
if (kind === "board_answer") return "BOARD_ANSWER";
if (kind === "recap_revision") return "RECAP";
if (/isomer/.test(t)) return "ISOMERISM";
if (/functional group|hydroxyl|carboxyl|aldehyde|keto|amine|ester/.test(t)) return "FUNCTIONAL_GROUP";
if (/electrolysis|electrode|anode|cathode|electroplat|galvanic|electrochem/.test(t)) return "ELECTROLYSIS_CELL";
if (/periodic|group|period|valenc|electron configuration|atomic (size|radius)/.test(t)) return "PERIODIC_TREND";
if (/ore|roast|calcin|smelt|reduction|metallurg|refin|reactivity series|corros|alloy/.test(t)) return "METALLURGY_PROCESS";
if (/gas law|boyle|charles|pressure|volume|temperature of a gas|avogadro/.test(t)) return "GAS_LAW";
if (/mole|molar mass|number of moles|percentage composition|stoichiometr/.test(t)) return "MOLE_CALCULATION";
if (/reaction|equation|β|->|combustion|displacement|precipit|neutralis|oxidation|reduction/.test(t)) return "REACTION_EQUATION";
if (/structure|structural formula|chain|bond|molecule|alkane|alkene|alkyne|hydrocarbon/.test(t)) return "MOLECULAR_STRUCTURE";
if (/experiment|apparatus|test tube|observe|beaker|heat|flame/.test(t)) return "LAB_EXPERIMENT";
return "CONCEPT_TEXT";
}
type Scene = {
sceneId: string; missionId: string; segmentKind: string; title: string;
narration: string; onScreenText: string[]; keyTerms: string[];
visual: { family: VisualFamily; topicKey: string; boardText?: string[]; diagramHint?: string };
examAnswer?: { question: string; marks: number; answerPoints: string[]; keywords: string[]; commonMistakes: string[] };
sourcePage: number;
};
function buildChapter(bp: Blueprint) {
const scenes: Scene[] = [];
const firstPage = bp.source_pdf_pages?.[0] ?? 0;
let n = 0;
const id = (kind: string) => `${kind}-${String(++n).padStart(3, "0")}`;
// Chapter opening / roadmap.
scenes.push({
sceneId: id("opening"), missionId: "chapter", segmentKind: "opening",
title: clean(bp.chapter_title),
narration: `Welcome to ${clean(bp.chapter_title)}. By the end of today you will: ${(bp.learning_objectives ?? []).map((o) => o.toLowerCase()).join("; ")}.`,
onScreenText: (bp.missions ?? []).map((m) => clean(m.title)),
keyTerms: [], visual: { family: "CONCEPT_TEXT", topicKey: "roadmap" }, sourcePage: firstPage,
});
for (const m of bp.missions ?? []) {
const page = m.source_pages?.[0] ?? firstPage;
// Mission intro.
const intro = clean([m.hook, m.teacher_intro].filter(Boolean).join(" "));
if (intro) {
scenes.push({
sceneId: id("concept"), missionId: m.mission_id, segmentKind: "concept",
title: clean(m.title), narration: intro, onScreenText: [clean(m.title)], keyTerms: [],
visual: { family: routeVisual(m.title, "concept"), topicKey: m.title }, sourcePage: page,
});
}
// Explanation blocks. A block whose explanation spans multiple lines (a
// formula/worked-steps block, common in the "checkpoint" schema) becomes a
// visual_board scene so those exact lines render as real board_text β never
// dropped as plain narration. "teacher" (spoken-style elaboration) is
// folded into the narration alongside "explanation".
for (const b of m.explanation_blocks ?? []) {
if (!clean(b.explanation) && !clean(b.teacher)) continue;
const lines = (b.explanation ?? "").split(/\n+/).map((l) => l.trim()).filter(Boolean);
const narration = clean([b.explanation && lines.length <= 1 ? b.explanation : "", b.teacher].filter(Boolean).join(" ")) || clean(b.explanation);
if (lines.length > 1) {
scenes.push({
sceneId: id("visual_board"), missionId: m.mission_id, segmentKind: "visual_board",
title: clean(b.heading) || clean(m.title), narration,
onScreenText: lines.map(clean), keyTerms: b.key_terms ?? [],
visual: { family: routeVisual(`${b.heading} ${lines.join(" ")}`, "visual_board"), topicKey: b.heading || m.title, boardText: lines },
sourcePage: page,
});
} else {
scenes.push({
sceneId: id("concept"), missionId: m.mission_id, segmentKind: "concept",
title: clean(b.heading) || clean(m.title), narration,
onScreenText: [clean(b.heading)].filter(Boolean), keyTerms: b.key_terms ?? [],
visual: { family: routeVisual(`${b.heading} ${b.explanation}`, "concept"), topicKey: b.heading || m.title },
sourcePage: page,
});
}
}
// Analogy / observation as a short concept beat.
if (clean(m.analogy_or_observation)) {
scenes.push({
sceneId: id("concept"), missionId: m.mission_id, segmentKind: "concept",
title: "Think about it", narration: clean(m.analogy_or_observation), onScreenText: [], keyTerms: [],
visual: { family: "CONCEPT_TEXT", topicKey: "analogy" }, sourcePage: page,
});
}
// Board frames (structures, equations, observations from the textbook).
for (const bf of m.board_frames ?? []) {
scenes.push({
sceneId: id("visual_board"), missionId: m.mission_id, segmentKind: "visual_board",
title: clean(bf.title) || clean(m.title),
narration: clean([bf.teacher_action, bf.student_focus].filter(Boolean).join(" ")),
onScreenText: (bf.board_text ?? []).map(clean).filter(Boolean), keyTerms: [],
visual: { family: routeVisual(`${bf.title} ${bf.diagram_description} ${(bf.board_text ?? []).join(" ")}`, "visual_board"), topicKey: bf.title || m.title, boardText: bf.board_text, diagramHint: bf.diagram_description },
sourcePage: bf.source_page ?? page,
});
}
// Misconception check.
if (clean(m.misconception_check)) {
scenes.push({
sceneId: id("common_mistakes"), missionId: m.mission_id, segmentKind: "common_mistakes",
title: "Common mistake", narration: clean(m.misconception_check),
onScreenText: [clean(m.misconception_check)], keyTerms: [],
visual: { family: "CONCEPT_TEXT", topicKey: "misconception" }, sourcePage: page,
});
}
// Exam practice β board-answer scenes.
for (const ex of m.exam_practice ?? []) {
if (!clean(ex.question)) continue;
scenes.push({
sceneId: id("board_answer"), missionId: m.mission_id, segmentKind: "board_answer",
title: clean(ex.question), narration: `Let us write the ${ex.marks ?? 2}-mark answer. ${clean(ex.question)}`,
onScreenText: [], keyTerms: ex.keywords ?? [],
visual: { family: "BOARD_ANSWER", topicKey: ex.question || m.title },
examAnswer: { question: clean(ex.question), marks: ex.marks ?? 2, answerPoints: (ex.answer_points ?? []).map(clean), keywords: ex.keywords ?? [], commonMistakes: ex.common_mistakes ?? [] },
sourcePage: page,
});
}
// Quick check (active recall).
if (clean(m.quick_check?.question)) {
scenes.push({
sceneId: id("recall"), missionId: m.mission_id, segmentKind: "recall",
title: "Quick check", narration: `${clean(m.quick_check?.question)} β¦ ${clean(m.quick_check?.answer)}.`,
onScreenText: [clean(m.quick_check?.question), clean(m.quick_check?.answer)].filter(Boolean), keyTerms: [],
visual: { family: "CONCEPT_TEXT", topicKey: "quick_check" }, sourcePage: page,
});
}
// Checkpoint question (alternate schema) β a worked board-answer scene, so
// it gets the same exam-Q&A treatment as exam_practice, not just a text card.
if (clean(m.checkpoint_question)) {
const q = clean(m.checkpoint_question);
const ans = clean(m.checkpoint_answer);
scenes.push({
sceneId: id("board_answer"), missionId: m.mission_id, segmentKind: "board_answer",
title: q, narration: `Check yourself. ${q} β¦ The answer is ${ans}.`,
onScreenText: [], keyTerms: [],
visual: { family: "BOARD_ANSWER", topicKey: q },
examAnswer: { question: q, marks: 2, answerPoints: [ans].filter(Boolean), keywords: [], commonMistakes: [] },
sourcePage: page,
});
}
}
// Chapter recap. Fall back to mission titles when no explicit chapter_recap
// is authored (the "checkpoint" schema chapters don't have one).
const recapPoints = ((bp.chapter_recap?.length ? bp.chapter_recap : (bp.missions ?? []).map((m) => m.title)) ?? [])
.map(clean).filter(Boolean).slice(0, 8);
if (recapPoints.length) {
scenes.push({
sceneId: id("recap_revision"), missionId: "chapter", segmentKind: "recap_revision",
title: `${clean(bp.chapter_title)} β recap`, narration: `Let us revise the key points. ${recapPoints.join(". ")}.`,
onScreenText: recapPoints, keyTerms: [], visual: { family: "RECAP", topicKey: "recap" }, sourcePage: firstPage,
});
}
// Chapter-scoped homework (2-4 tasks from this chapter's own exam questions,
// falling back to checkpoint questions when exam_practice is absent).
const examQs = (bp.missions ?? []).flatMap((m) => (m.exam_practice ?? []).map((e) => clean(e.question)).filter(Boolean));
const checkpointQs = (bp.missions ?? []).map((m) => clean(m.checkpoint_question)).filter(Boolean);
const homework = (examQs.length ? examQs : checkpointQs).slice(0, 3).map((q, i) => `Task ${i + 1}: ${q}`);
if (homework.length) {
scenes.push({
sceneId: id("homework"), missionId: "chapter", segmentKind: "homework",
title: "Homework and next step", narration: `For homework: ${homework.join(" ")}`,
onScreenText: homework, keyTerms: [], visual: { family: "CONCEPT_TEXT", topicKey: "homework" }, sourcePage: firstPage,
});
}
return {
chapterId: bp.chapter_id, chapterTitle: bp.chapter_title, subject: "Mathematics",
sourceBook: bp.source_book, sourcePages: bp.source_pdf_pages, renderGuidance: bp.render_guidance,
sceneCount: scenes.length, scenes, homework,
};
}
const requested = process.env.CHAPTER_ID ? [process.env.CHAPTER_ID] : CHAPTERS;
mkdirSync(OUT_DIR, { recursive: true });
for (const chapterId of requested) {
const bpPath = join(BLUEPRINT_DIR, `${chapterId}.json`);
if (!existsSync(bpPath)) { console.error(`missing blueprint: ${bpPath}`); continue; }
const bp = JSON.parse(readFileSync(bpPath, "utf8")) as Blueprint;
const draft = buildChapter(bp);
const outPath = join(OUT_DIR, `${chapterId}-lesson-draft.json`);
writeFileSync(outPath, `${JSON.stringify(draft, null, 2)}\n`);
const kinds = draft.scenes.reduce<Record<string, number>>((a, s) => { a[s.segmentKind] = (a[s.segmentKind] ?? 0) + 1; return a; }, {});
console.log(`${chapterId}: ${draft.sceneCount} scenes ${JSON.stringify(kinds)}`);
}
|