206 lines
8.5 KiB
TypeScript
206 lines
8.5 KiB
TypeScript
import { db } from "../db/index";
|
|
import { courses, uploads, topics, lessons } from "../db/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { askAI } from "./openrouter";
|
|
import { generateTTSToPath } from "./generateTTS";
|
|
|
|
function log(lessonId: string, msg: string) {
|
|
console.log(`[branches:${lessonId.slice(0, 8)}] ${msg}`);
|
|
}
|
|
|
|
function parseJSON<T>(raw: string): T {
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
const cleaned = raw.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
|
return JSON.parse(cleaned);
|
|
}
|
|
}
|
|
|
|
export async function generateBranches(topicId: string, lessonId: string): Promise<void> {
|
|
try {
|
|
await db.update(lessons).set({ branchStatus: "generating" }).where(eq(lessons.id, lessonId));
|
|
|
|
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.id, lessonId) });
|
|
if (!lesson) throw new Error(`Lesson ${lessonId} not found`);
|
|
|
|
const topic = await db.query.topics.findFirst({ where: eq(topics.id, topicId) });
|
|
if (!topic) throw new Error(`Topic ${topicId} not found`);
|
|
|
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, topic.courseId) });
|
|
if (!course) throw new Error(`Course ${topic.courseId} not found`);
|
|
|
|
const lessonContent: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] } = JSON.parse(lesson.content);
|
|
const courseSubject = course.subject;
|
|
|
|
// load source material for branch prompts
|
|
const topicRelevantFiles: string[] = (() => {
|
|
try { return JSON.parse(topic.relevantFiles ?? "[]"); } catch { return []; }
|
|
})();
|
|
|
|
const uploadRows = await db.query.uploads.findMany({
|
|
where: eq(uploads.courseId, topic.courseId),
|
|
});
|
|
|
|
const relevantUploads = topicRelevantFiles.length > 0
|
|
? uploadRows.filter((u) => topicRelevantFiles.includes(u.filename) && u.extractedText)
|
|
: uploadRows.filter((u) => (u.type === "past_paper" || u.type === "lab_worksheet") && u.extractedText);
|
|
|
|
const primaryTextForLesson = relevantUploads
|
|
.map((u) => `--- ${u.filename} ---\n${u.extractedText}`)
|
|
.join("\n\n");
|
|
|
|
let costBranchAI = 0;
|
|
let costBranchAudio = 0;
|
|
|
|
let branchesChanged = false;
|
|
|
|
for (let si = 0; si < lessonContent.steps.length; si++) {
|
|
const step = lessonContent.steps[si] as any;
|
|
if (step.type !== "question") continue;
|
|
|
|
const OPTION_LABELS = ["A", "B", "C", "D"];
|
|
const optionsText = (step.options as string[])
|
|
.map((o: string, idx: number) => `${OPTION_LABELS[idx]}: ${o}`)
|
|
.join("\n");
|
|
|
|
const branchPrompt = `You are writing remediation branches for a question in a lesson about ${courseSubject}.
|
|
|
|
SOURCE MATERIAL FOR THIS TOPIC:
|
|
${primaryTextForLesson || "(none)"}
|
|
|
|
THE QUESTION:
|
|
${step.body}
|
|
|
|
THE OPTIONS:
|
|
${optionsText}
|
|
|
|
THE CORRECT ANSWER: ${step.answer}
|
|
|
|
YOUR TASK:
|
|
For each wrong answer option, write a branch that:
|
|
1. Opens by acknowledging the specific thinking behind that wrong answer — not generically ("that's wrong") but specifically ("It makes sense to think that, because X — but here is where that reasoning breaks down...")
|
|
2. Contains 1-2 short teaching steps that directly address the specific misconception behind that wrong answer
|
|
3. Ends with a confirming question that tests whether the student now understands the correct concept
|
|
4. Includes a warm, honest hard-stop message for if they fail the confirming question too
|
|
|
|
BRANCH TEACHING RULES:
|
|
- Each branch must feel like a patient teacher who has seen this exact mistake before and knows exactly how to fix it
|
|
- The opening must name the misconception specifically — never say "incorrect" or "wrong" — say "here is what that answer is actually describing..." or "that reasoning would be right if... but in this case..."
|
|
- Steps must be 2-3 sentences maximum — these are micro-lessons, not full explanations
|
|
- The confirming question must be simpler than the original question — it tests the core concept only
|
|
- The confirming question options must be short and scannable — under 12 words each
|
|
- The hard stop message must be warm, not discouraging — acknowledge that some concepts need more time, tell them specifically what to look up, and invite them back
|
|
- Never use technical terms that haven't been taught yet
|
|
|
|
Return only valid JSON, no markdown. Structure:
|
|
{
|
|
"branches": {
|
|
"{exact text of wrong option}": {
|
|
"steps": [
|
|
{ "type": "concept", "title": "...", "body": "..." }
|
|
],
|
|
"confirmQuestion": {
|
|
"body": "...",
|
|
"options": ["...", "...", "...", "..."],
|
|
"answer": "full correct answer text",
|
|
"explanation": "..."
|
|
},
|
|
"hardStop": "..."
|
|
}
|
|
}
|
|
}
|
|
|
|
Only generate branches for the 3 wrong options. Do not generate a branch for the correct answer.`;
|
|
|
|
try {
|
|
const branchResult = await askAI([{ role: "user", content: branchPrompt }]);
|
|
costBranchAI += branchResult.cost;
|
|
|
|
const parsed = parseJSON<{ branches: Record<string, any> }>(branchResult.text);
|
|
step.branches = parsed.branches ?? {};
|
|
branchesChanged = true;
|
|
|
|
log(lessonId, ` step ${si} branches generated — ${Object.keys(step.branches).length} wrong options`);
|
|
|
|
const wrongOptions = (step.options as string[]).filter((o: string) => o !== step.answer);
|
|
for (let bi = 0; bi < wrongOptions.length; bi++) {
|
|
const wrongOpt = wrongOptions[bi];
|
|
const branch = step.branches[wrongOpt];
|
|
if (!branch) continue;
|
|
|
|
for (let bsi = 0; bsi < (branch.steps ?? []).length; bsi++) {
|
|
const bStep = branch.steps[bsi];
|
|
const text = [bStep.body, bStep.callout].filter(Boolean).join(" ");
|
|
if (!text.trim()) continue;
|
|
const r = await generateTTSToPath(text, lessonId, `branch_${si}_${bi}_step_${bsi}.mp3`);
|
|
if (r) {
|
|
bStep.audioPath = r.audioPath;
|
|
bStep.audioChunks = r.audioChunks;
|
|
costBranchAudio += r.cost;
|
|
}
|
|
}
|
|
|
|
if (branch.confirmQuestion?.body?.trim()) {
|
|
const r = await generateTTSToPath(branch.confirmQuestion.body, lessonId, `branch_${si}_${bi}_confirm_q.mp3`);
|
|
if (r) {
|
|
branch.confirmQuestion.questionAudioPath = r.audioPath;
|
|
branch.confirmQuestion.questionAudioChunks = r.audioChunks;
|
|
costBranchAudio += r.cost;
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(branch.confirmQuestion?.options)) {
|
|
branch.confirmQuestion.optionAudioPaths = [];
|
|
for (let oi = 0; oi < branch.confirmQuestion.options.length; oi++) {
|
|
const optText = branch.confirmQuestion.options[oi];
|
|
if (optText?.trim()) {
|
|
const r = await generateTTSToPath(optText, lessonId, `branch_${si}_${bi}_confirm_opt_${oi}.mp3`);
|
|
branch.confirmQuestion.optionAudioPaths[oi] = r ? r.audioPath : null;
|
|
if (r) costBranchAudio += r.cost;
|
|
} else {
|
|
branch.confirmQuestion.optionAudioPaths[oi] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (branch.hardStop?.trim()) {
|
|
const r = await generateTTSToPath(branch.hardStop, lessonId, `branch_${si}_${bi}_hardstop.mp3`);
|
|
if (r) {
|
|
branch.hardStopAudioPath = r.audioPath;
|
|
costBranchAudio += r.cost;
|
|
}
|
|
}
|
|
|
|
log(lessonId, ` step ${si} branch ${bi} TTS done`);
|
|
}
|
|
} catch (err: any) {
|
|
console.error(`[branches] step ${si} failed for lesson ${lessonId}: ${err?.message ?? err}`);
|
|
}
|
|
}
|
|
|
|
if (branchesChanged) {
|
|
const existing = await db.query.lessons.findFirst({ where: eq(lessons.id, lessonId) });
|
|
const prevCostAI = existing?.costAI ?? 0;
|
|
const prevCostAudio = existing?.costAudio ?? 0;
|
|
|
|
await db.update(lessons)
|
|
.set({
|
|
content: JSON.stringify(lessonContent),
|
|
costBranchAI,
|
|
costBranchAudio,
|
|
costTotal: prevCostAI + prevCostAudio + costBranchAI + costBranchAudio,
|
|
branchStatus: "ready",
|
|
})
|
|
.where(eq(lessons.id, lessonId));
|
|
|
|
log(lessonId, `✓ branches ready — AI $${costBranchAI.toFixed(4)}, audio $${costBranchAudio.toFixed(4)}`);
|
|
} else {
|
|
await db.update(lessons).set({ branchStatus: "ready" }).where(eq(lessons.id, lessonId));
|
|
log(lessonId, "no question steps found, branch_status set to ready");
|
|
}
|
|
} catch (err: any) {
|
|
console.error(`[branches:${lessonId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
|
|
await db.update(lessons).set({ branchStatus: "error" }).where(eq(lessons.id, lessonId));
|
|
}
|
|
}
|