Revisione/server/utils/generateBranches.ts

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));
}
}