DocDoeAI / scripts /video-engine /build-math-lesson-draft.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
13.6 kB
// 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)}`);
}