732 lines
33 KiB
TypeScript
732 lines
33 KiB
TypeScript
import { db } from "../db/index";
|
|
import { courses, uploads, topics, lessons, quizQuestions } from "../db/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { randomUUID } from "crypto";
|
|
import { askAI } from "./openrouter";
|
|
import { auditCourse } from "./auditCourse";
|
|
import { generateStepTTS, generateQuestionTTS, generateOptionTTS, generateTTSToPath } from "./generateTTS";
|
|
|
|
type Stage = "parsing_pdfs" | "analysing_sources" | "building_curriculum" | "finalising" | "ready" | "error";
|
|
|
|
interface CourseContext {
|
|
courseTitle: string;
|
|
subject: string;
|
|
topicsInOrder: { order: number; title: string; description: string }[];
|
|
completedLessons: {
|
|
order: number;
|
|
title: string;
|
|
keyConcepts: string[];
|
|
analogiesUsed: string[];
|
|
}[];
|
|
}
|
|
|
|
function log(courseId: string, msg: string) {
|
|
const short = courseId.slice(0, 8);
|
|
console.log(`[revisione:${short}] ${msg}`);
|
|
}
|
|
|
|
async function setStage(courseId: string, stage: Stage) {
|
|
await db.update(courses).set({ stage }).where(eq(courses.id, courseId));
|
|
log(courseId, `stage → ${stage}`);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// returns mutated steps array with audioPath/audioChunks embedded, and total audio cost
|
|
async function generateLessonAudio(
|
|
steps: any[],
|
|
lessonId: string,
|
|
courseId: string
|
|
): Promise<{ steps: any[]; cost: number }> {
|
|
let cost = 0;
|
|
|
|
for (let si = 0; si < steps.length; si++) {
|
|
const step = steps[si];
|
|
|
|
if (step.type === "concept" || step.type === "example") {
|
|
const text = [step.body, step.callout].filter(Boolean).join(" ");
|
|
if (!text.trim()) continue;
|
|
|
|
const result = await generateStepTTS(text, lessonId, si);
|
|
if (result) {
|
|
step.audioPath = result.audioPath;
|
|
step.audioChunks = result.audioChunks;
|
|
cost += result.cost;
|
|
}
|
|
} else if (step.type === "summary") {
|
|
const text = Array.isArray(step.bullets) ? step.bullets.join(". ") : "";
|
|
if (!text.trim()) continue;
|
|
|
|
const result = await generateStepTTS(text, lessonId, si);
|
|
if (result) {
|
|
step.audioPath = result.audioPath;
|
|
step.audioChunks = result.audioChunks;
|
|
cost += result.cost;
|
|
}
|
|
} else if (step.type === "question") {
|
|
// question narration
|
|
if (step.body?.trim()) {
|
|
const qResult = await generateQuestionTTS(step.body, lessonId, si);
|
|
if (qResult) {
|
|
step.questionAudioPath = qResult.audioPath;
|
|
step.questionAudioChunks = qResult.audioChunks;
|
|
cost += qResult.cost;
|
|
}
|
|
}
|
|
|
|
// per-option audio
|
|
if (Array.isArray(step.options)) {
|
|
step.optionAudioPaths = [];
|
|
for (let oi = 0; oi < step.options.length; oi++) {
|
|
const optText = step.options[oi];
|
|
if (optText?.trim()) {
|
|
const oResult = await generateOptionTTS(optText, lessonId, si, oi);
|
|
if (oResult) {
|
|
step.optionAudioPaths[oi] = oResult.audioPath;
|
|
cost += oResult.cost;
|
|
} else {
|
|
step.optionAudioPaths[oi] = null;
|
|
}
|
|
} else {
|
|
step.optionAudioPaths[oi] = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log(courseId, ` step ${si} (${step.type}) TTS done`);
|
|
}
|
|
|
|
return { steps, cost };
|
|
}
|
|
|
|
export async function generateCourseInBackground(courseId: string) {
|
|
try {
|
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, courseId) });
|
|
if (!course) throw new Error(`Course ${courseId} not found`);
|
|
|
|
const costs = { ai: 0, audio: 0 };
|
|
|
|
log(courseId, `starting generation for "${course.title}"`);
|
|
|
|
// ── STEP 1 — load uploads ───────────────────────────────────────────────
|
|
await setStage(courseId, "parsing_pdfs");
|
|
|
|
const uploadRows = await db.query.uploads.findMany({
|
|
where: eq(uploads.courseId, courseId),
|
|
});
|
|
|
|
if (uploadRows.length === 0) throw new Error("No uploads found for this course");
|
|
|
|
log(courseId, `found ${uploadRows.length} upload(s)`);
|
|
|
|
const primaryParts: string[] = [];
|
|
const secondaryParts: string[] = [];
|
|
|
|
for (const upload of uploadRows) {
|
|
if (!upload.extractedText) {
|
|
log(courseId, ` skipping "${upload.filename}" — no extracted text`);
|
|
continue;
|
|
}
|
|
const snippet = `--- ${upload.filename} ---\n${upload.extractedText}`;
|
|
log(courseId, ` loaded "${upload.filename}" (${upload.type}, ${upload.extractedText.length} chars)`);
|
|
|
|
if (upload.type === "past_paper" || upload.type === "lab_worksheet") {
|
|
primaryParts.push(snippet);
|
|
} else {
|
|
secondaryParts.push(snippet);
|
|
}
|
|
}
|
|
|
|
log(courseId, `source split — primary: ${primaryParts.length}, secondary: ${secondaryParts.length}`);
|
|
|
|
// ── STEP 1b — infer title, subject, and topic count ────────────────────
|
|
await setStage(courseId, "analysing_sources");
|
|
|
|
const allExtracted = [
|
|
...primaryParts.join("\n\n"),
|
|
...secondaryParts.join("\n\n"),
|
|
].join("\n\n");
|
|
|
|
log(courseId, "inferring course title and subject from documents…");
|
|
|
|
const inferenceResult = await askAI([{
|
|
role: "user",
|
|
content: `You are analysing a set of university course documents including lecture slides, past exam papers, and lab worksheets.
|
|
|
|
Based on the content, return a JSON object with:
|
|
- "title": a concise course name (e.g. "Computer Vision", "Thermodynamics", "Microeconomics")
|
|
- "subject": the broader academic discipline (e.g. "Computer Science", "Physics", "Economics")
|
|
- "organisation": the university or institution these materials are from (e.g. "University of Essex", "Imperial College London"). Infer this from headers, exam paper footers, logos described in text, or module codes. Return null if you genuinely cannot determine it.
|
|
|
|
Return only valid JSON, no markdown.
|
|
|
|
DOCUMENTS:
|
|
${allExtracted}`,
|
|
}]);
|
|
costs.ai += inferenceResult.cost;
|
|
|
|
let inferredMeta: { title: string; subject: string; organisation?: string | null } = {
|
|
title: course.title,
|
|
subject: course.subject,
|
|
};
|
|
try {
|
|
inferredMeta = parseJSON(inferenceResult.text);
|
|
} catch {
|
|
log(courseId, "inference parse failed, using defaults");
|
|
}
|
|
|
|
log(courseId, `inferred → title: "${inferredMeta.title}", subject: "${inferredMeta.subject}"`);
|
|
|
|
await db.update(courses)
|
|
.set({
|
|
title: inferredMeta.title,
|
|
subject: inferredMeta.subject,
|
|
...(inferredMeta.organisation != null ? { organisation: inferredMeta.organisation } : {}),
|
|
})
|
|
.where(eq(courses.id, courseId));
|
|
|
|
// ── STEP 2 — generate topic list (skip if topics already saved) ─────────
|
|
|
|
let savedTopics = await db.query.topics.findMany({
|
|
where: eq(topics.courseId, courseId),
|
|
orderBy: (t, { asc }) => asc(t.order),
|
|
});
|
|
|
|
if (savedTopics.length > 0) {
|
|
log(courseId, `resuming — found ${savedTopics.length} existing topic(s), skipping curriculum generation`);
|
|
} else {
|
|
const primaryText = primaryParts.join("\n\n");
|
|
const secondaryText = secondaryParts.join("\n\n");
|
|
|
|
const availableFilesBlock = uploadRows
|
|
.map((u) => `- ${u.filename} (${u.type})`)
|
|
.join("\n");
|
|
|
|
const curriculumPrompt = `You are designing a complete revision course from scratch.
|
|
|
|
Your only measure of success is this: a student who completes every lesson in this course must be able to:
|
|
- Answer every question in every past paper provided, including calculation questions, pseudocode questions, diagram questions, and scenario questions
|
|
- Perform every procedure, method, and algorithm named in the source material — not just describe them, but actually do them
|
|
- Fully understand every concept present in the source material, with no gaps
|
|
|
|
This is a non-negotiable standard. Do not summarise. Do not compress topics together if doing so would leave any gap in the student's ability to answer a past paper question. If meeting this standard requires 50 topics, generate 50 topics. If it requires 8, generate 8. There is no limit in either direction.
|
|
|
|
BEFORE generating topics, do the following analysis mentally:
|
|
1. Read every past paper question carefully. For each question, ask: what does a student need to know and be able to DO to answer this? List every distinct skill, concept, calculation method, algorithm, and procedure required.
|
|
2. Read every lab worksheet. For each task, ask: what does a student need to know and be able to DO to complete this? Add any new skills, concepts, or procedures to the list.
|
|
3. Read the lecture slides. Add any concept or topic that appears in the slides but is not yet in the list.
|
|
4. Now organise the list into topics, ordered from simplest to most complex, such that each topic assumes only the knowledge of topics before it.
|
|
|
|
TOPIC REQUIREMENTS:
|
|
- Every distinct algorithm named in the source material must have at least one dedicated topic that teaches it to implementation level — the student must be able to apply it step by step, not just name it
|
|
- Every calculation that appears in a past paper must be covered in a topic that teaches the student to perform that exact type of calculation by hand, with worked examples matching the exam style
|
|
- Every procedure that appears in a lab worksheet must be covered in a topic that teaches the student to carry out that procedure
|
|
- If a past paper asks for pseudocode, the corresponding topic must teach the student to write that pseudocode
|
|
- Conceptual understanding alone is never sufficient. Every topic must result in a student who can DO something, not just know something
|
|
|
|
AVAILABLE SOURCE FILES (you must reference these exact filenames in relevantFiles):
|
|
${availableFilesBlock}
|
|
|
|
PRIMARY SOURCES (past papers + lab worksheets — these define what the student must be able to do):
|
|
${primaryText || "(none provided)"}
|
|
|
|
SECONDARY SOURCES (lecture slides — use for additional concepts and explanations):
|
|
${secondaryText || "(none provided)"}
|
|
|
|
Return only valid JSON — an array of topics with no markdown:
|
|
[{ "title": "...", "description": "...", "difficulty": 1-5, "order": 1, "relevantFiles": ["filename.pdf"] }]
|
|
|
|
relevantFiles must list only filenames from the AVAILABLE SOURCE FILES list that directly contain content for this topic. Include at minimum the files that have past paper questions or lab tasks this topic must prepare the student for.
|
|
|
|
The description must be specific about what the student will be able to DO after completing this topic, not just what it covers.`;
|
|
|
|
await setStage(courseId, "building_curriculum");
|
|
log(courseId, "calling OpenRouter for curriculum…");
|
|
const curriculumResult = await askAI([{ role: "user", content: curriculumPrompt }]);
|
|
costs.ai += curriculumResult.cost;
|
|
const curriculum = parseJSON<{ title: string; description: string; difficulty: number; relevantFiles?: string[] }[]>(curriculumResult.text);
|
|
|
|
if (!Array.isArray(curriculum) || curriculum.length === 0) {
|
|
throw new Error("AI returned an empty curriculum");
|
|
}
|
|
|
|
log(courseId, `curriculum received — ${curriculum.length} topics:`);
|
|
curriculum.forEach((t, i) => log(courseId, ` ${i + 1}. ${t.title} (difficulty ${t.difficulty})`));
|
|
|
|
await setStage(courseId, "finalising");
|
|
|
|
for (let i = 0; i < curriculum.length; i++) {
|
|
const t = curriculum[i];
|
|
await db.insert(topics).values({
|
|
id: randomUUID(),
|
|
courseId,
|
|
title: t.title,
|
|
description: t.description,
|
|
order: i,
|
|
difficulty: Math.min(5, Math.max(1, t.difficulty ?? 1)),
|
|
prerequisiteTopicIds: "[]",
|
|
relevantFiles: JSON.stringify(t.relevantFiles ?? []),
|
|
});
|
|
}
|
|
|
|
// re-fetch so we have the real DB rows with IDs
|
|
savedTopics = await db.query.topics.findMany({
|
|
where: eq(topics.courseId, courseId),
|
|
orderBy: (t, { asc }) => asc(t.order),
|
|
});
|
|
|
|
log(courseId, `saved ${savedTopics.length} topics to DB`);
|
|
}
|
|
|
|
await setStage(courseId, "finalising");
|
|
|
|
// ── STEP 3 — build course context from what's already done ─────────────
|
|
const courseSubject = inferredMeta?.subject ?? course.subject;
|
|
const topicListText = savedTopics.map((t) => `${t.order + 1}. ${t.title}`).join("\n");
|
|
|
|
const courseContext: CourseContext = {
|
|
courseTitle: course.title,
|
|
subject: course.subject,
|
|
topicsInOrder: savedTopics.map((t) => ({
|
|
order: t.order,
|
|
title: t.title,
|
|
description: t.description,
|
|
})),
|
|
completedLessons: [],
|
|
};
|
|
|
|
// ── STEP 4 — generate lessons + quizzes, skipping completed ones ────────
|
|
for (const topic of savedTopics) {
|
|
const i = topic.order;
|
|
const isFirst = i === 0;
|
|
|
|
// check what's already generated for this topic
|
|
const existingLesson = await db.query.lessons.findFirst({
|
|
where: eq(lessons.topicId, topic.id),
|
|
});
|
|
const existingQuiz = await db.query.quizQuestions.findMany({
|
|
where: eq(quizQuestions.topicId, topic.id),
|
|
});
|
|
|
|
if (existingLesson && existingQuiz.length > 0) {
|
|
// fully done — just add to context and move on
|
|
const content = JSON.parse(existingLesson.content) as {
|
|
keyConcepts?: string[];
|
|
analogiesUsed?: string[];
|
|
};
|
|
courseContext.completedLessons.push({
|
|
order: i,
|
|
title: topic.title,
|
|
keyConcepts: content.keyConcepts ?? [],
|
|
analogiesUsed: content.analogiesUsed ?? [],
|
|
});
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] "${topic.title}" already complete — skipping`);
|
|
continue;
|
|
}
|
|
|
|
// build prior knowledge block from what's completed so far
|
|
let priorKnowledge: string;
|
|
if (courseContext.completedLessons.length === 0) {
|
|
priorKnowledge = "This is the very first lesson — assume zero prior knowledge of the subject.";
|
|
} else {
|
|
priorKnowledge = courseContext.completedLessons
|
|
.map((l) => `- ${l.title}: covered concepts [${l.keyConcepts.join(", ")}] using analogies [${l.analogiesUsed.join(", ")}]`)
|
|
.join("\n");
|
|
}
|
|
|
|
// build source context for this topic from its relevantFiles, falling back to all primary sources
|
|
const topicRelevantFiles: string[] = (() => {
|
|
try { return JSON.parse(topic.relevantFiles ?? "[]"); } catch { return []; }
|
|
})();
|
|
|
|
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");
|
|
|
|
const secondaryTextForLesson = topicRelevantFiles.length > 0
|
|
? uploadRows
|
|
.filter((u) => topicRelevantFiles.includes(u.filename) && u.extractedText && u.type === "slides")
|
|
.map((u) => `--- ${u.filename} ---\n${u.extractedText}`)
|
|
.join("\n\n")
|
|
: secondaryParts.join("\n\n");
|
|
|
|
// generate lesson if missing
|
|
let lessonContent: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] };
|
|
|
|
if (existingLesson) {
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] lesson already exists for "${topic.title}", generating quiz only`);
|
|
lessonContent = JSON.parse(existingLesson.content);
|
|
} else {
|
|
const lessonPrompt = `You are writing a lesson for a course on ${courseSubject}.
|
|
|
|
YOUR ONLY MEASURE OF SUCCESS:
|
|
A student who completes this lesson must be able to answer any past paper or lab question that requires knowledge of this topic. That means they must be able to DO the thing, not just understand it. If this topic involves a calculation, they must be able to perform it. If it involves an algorithm, they must be able to apply it step by step. If it involves pseudocode, they must be able to write it. Conceptual understanding alone is never the goal — competence is the goal.
|
|
|
|
WHAT THE STUDENT KNOWS:
|
|
- Basic English, everyday maths (arithmetic, simple algebra, fractions, proportions), and general school-level science
|
|
- Nothing domain-specific about ${courseSubject} unless it appears below
|
|
- Everything explicitly taught in previous lessons:
|
|
|
|
${isFirst ? `This is the very first lesson. The student knows nothing about this subject yet. Start from absolute zero.` : courseContext.completedLessons.map((l) => `Lesson ${l.order + 1} — ${l.title}: ${l.keyConcepts.join(", ")}`).join("\n")}
|
|
|
|
DO NOT use any technical term that does not appear in the above list or is not introduced and explained in the current lesson. This is a hard rule. It applies everywhere — questions, options, callouts, summaries.
|
|
|
|
COURSE STRUCTURE:
|
|
This course has ${savedTopics.length} lessons in this order:
|
|
${topicListText}
|
|
|
|
YOUR CURRENT LESSON: ${topic.title} — ${topic.description}
|
|
|
|
SOURCE MATERIAL:
|
|
The following are the actual source files relevant to this topic — past papers, lab worksheets, and lecture slides. Your lesson must prepare the student to answer every question in these files that relates to this topic:
|
|
${primaryTextForLesson || "(no primary sources provided)"}
|
|
${secondaryTextForLesson ? `\nLECTURE SLIDES:\n${secondaryTextForLesson}` : ""}
|
|
|
|
TEACHING PHILOSOPHY:
|
|
|
|
OPENING:
|
|
- The very first sentence must make the student curious, smile, or feel something. Never open with a definition, a recap, or a statement of what they are about to learn.
|
|
- Open with the analogy or human moment immediately.
|
|
|
|
ANALOGIES:
|
|
- Every concept step must open with a concrete real-world analogy before any technical language.
|
|
- The analogy comes first. The technical idea is revealed through it.
|
|
- Never repeat an analogy used in a previous lesson.
|
|
- Analogies must connect to everyday life, not the subject domain.
|
|
|
|
BUILDING ON PRIOR KNOWLEDGE:
|
|
- Freely use terms and concepts from completed lessons without re-explaining them.
|
|
- Reference prior concepts as bridges: "remember how X worked — this is that same idea applied to Y."
|
|
|
|
MATHEMATICS, ALGORITHMS, AND PROCEDURES:
|
|
- Before any formula, write one sentence in plain English saying what the relationship means intuitively. Vary the phrasing — never use "In plain terms" more than once per lesson.
|
|
- After introducing a formula or algorithm, immediately show a complete worked example that matches the style of the past paper questions for this topic.
|
|
- Never show more than one formula per step.
|
|
- Never introduce a variable without saying in plain English what it represents.
|
|
- If this topic requires the student to perform a procedure step by step, there must be at least one example step that walks through the complete procedure on a concrete example, showing every step explicitly.
|
|
- If past papers ask for pseudocode on this topic, there must be a concept or example step that shows the pseudocode and explains each line.
|
|
|
|
QUESTIONS:
|
|
- Every question must be answerable using only what has been explicitly taught in this lesson up to that point, plus concepts from completed lessons.
|
|
- Questions immediately after the first concept step must be the simplest — their only job is to confirm the student understood the core analogy.
|
|
- Never ask a student to perform a full calculation in a single question. Use only:
|
|
(a) PARTIAL WORKING: Show known values and partial working, ask the student to identify the correct next step.
|
|
(b) INTERPRET THE RESULT: Give the numerical answer, ask what it means in context.
|
|
(c) SPOT THE ERROR: Show a worked example with a mistake, ask the student to identify what went wrong and why.
|
|
- Answer options must be short and scannable — under 15 words each.
|
|
- Wrong answer options must represent genuine conceptual misconceptions, not arithmetic errors.
|
|
- Never use a technical term in any answer option that has not already been taught.
|
|
|
|
RHYTHM AND PACING:
|
|
- Never place two question steps consecutively without a concept or example step between them.
|
|
- The lesson must not become more question-heavy in the second half.
|
|
- A concept or example step must always appear after the final question and before the summary.
|
|
- Every concept and example step body: 3-4 sentences maximum.
|
|
- The lesson should feel like it has a rhythm: teach, check, teach, check, show, check, land.
|
|
|
|
TONE:
|
|
- Warm, clear, occasionally witty. The most engaging teacher the student has ever had.
|
|
- Never dry, never robotic, never formal for formality's sake.
|
|
- Short sentences. Active voice. Concrete over abstract.
|
|
|
|
SUMMARY:
|
|
- Bullet count must exactly match keyConcepts count.
|
|
- Each bullet must be a complete thought that makes sense without reading the lesson.
|
|
- The final bullet must gesture forward — what will this knowledge unlock?
|
|
|
|
OUTPUT FORMAT:
|
|
Return only valid JSON with no markdown fences:
|
|
{
|
|
"keyConcepts": ["..."],
|
|
"analogiesUsed": ["..."],
|
|
"steps": [
|
|
{ "type": "concept", "title": "...", "body": "..." },
|
|
{ "type": "question", "body": "...", "options": ["...", "...", "...", "..."], "answer": "full correct answer text", "explanation": "..." },
|
|
{ "type": "example", "title": "...", "body": "...", "callout": "..." },
|
|
{ "type": "question", "body": "...", "options": ["...", "...", "...", "..."], "answer": "full correct answer text", "explanation": "..." },
|
|
{ "type": "summary", "title": "Key Takeaways", "bullets": ["...", "..."] }
|
|
]
|
|
}
|
|
|
|
Steps must interleave concept/example and question types — never two questions or two concepts in a row. Minimum 6 steps, maximum 16. Use more steps when the topic requires it to achieve full competence.`;
|
|
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating lesson for "${topic.title}"…`);
|
|
const lessonResult = await askAI([{ role: "user", content: lessonPrompt }]);
|
|
costs.ai += lessonResult.cost;
|
|
lessonContent = parseJSON(lessonResult.text);
|
|
|
|
const lessonId = randomUUID();
|
|
|
|
const ttsProvider = (useRuntimeConfig().ttsProvider as string | undefined)?.toLowerCase() ?? "elevenlabs";
|
|
|
|
await db.insert(lessons).values({
|
|
id: lessonId,
|
|
topicId: topic.id,
|
|
content: JSON.stringify(lessonContent),
|
|
ttsProvider,
|
|
});
|
|
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] lesson saved — ${lessonContent.steps?.length ?? 0} steps, concepts: [${(lessonContent.keyConcepts ?? []).join(", ")}]`);
|
|
|
|
// generate per-step TTS — non-fatal, embeds audio into step objects
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating per-step TTS for lesson ${lessonId}…`);
|
|
try {
|
|
const { steps: stepsWithAudio, cost: audioCost } = await generateLessonAudio(
|
|
lessonContent.steps as any[],
|
|
lessonId,
|
|
courseId
|
|
);
|
|
lessonContent.steps = stepsWithAudio;
|
|
costs.audio += audioCost;
|
|
|
|
// update DB with embedded audio
|
|
await db.update(lessons)
|
|
.set({ content: JSON.stringify(lessonContent) })
|
|
.where(eq(lessons.id, lessonId));
|
|
} catch (err: any) {
|
|
console.error(`[revisione] TTS generation failed for lesson ${lessonId}: ${err?.message ?? err}`);
|
|
}
|
|
|
|
// ── generate branches for each question step ──────────────────────
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating branches for lesson ${lessonId}…`);
|
|
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 priorBlock = courseContext.completedLessons.length
|
|
? courseContext.completedLessons
|
|
.map((l) => `- ${l.title}: [${l.keyConcepts.join(", ")}]`)
|
|
.join("\n")
|
|
: "This is the first lesson.";
|
|
|
|
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}
|
|
|
|
WHAT THE STUDENT KNOWS SO FAR:
|
|
${priorBlock}
|
|
|
|
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 }]);
|
|
costs.ai += branchResult.cost;
|
|
|
|
const parsed = parseJSON<{ branches: Record<string, any> }>(branchResult.text);
|
|
step.branches = parsed.branches ?? {};
|
|
branchesChanged = true;
|
|
|
|
log(courseId, ` step ${si} branches generated — ${Object.keys(step.branches).length} wrong options`);
|
|
|
|
// TTS for each branch
|
|
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;
|
|
|
|
// branch concept steps
|
|
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;
|
|
costs.audio += r.cost;
|
|
}
|
|
}
|
|
|
|
// confirm question narration
|
|
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;
|
|
costs.audio += r.cost;
|
|
}
|
|
}
|
|
|
|
// confirm question options
|
|
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) costs.audio += r.cost;
|
|
} else {
|
|
branch.confirmQuestion.optionAudioPaths[oi] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// hard stop narration
|
|
if (branch.hardStop?.trim()) {
|
|
const r = await generateTTSToPath(branch.hardStop, lessonId, `branch_${si}_${bi}_hardstop.mp3`);
|
|
if (r) {
|
|
branch.hardStopAudioPath = r.audioPath;
|
|
costs.audio += r.cost;
|
|
}
|
|
}
|
|
|
|
log(courseId, ` step ${si} branch ${bi} TTS done`);
|
|
}
|
|
} catch (err: any) {
|
|
console.error(`[revisione] branch gen failed for step ${si} in lesson ${lessonId}: ${err?.message ?? err}`);
|
|
}
|
|
}
|
|
|
|
if (branchesChanged) {
|
|
await db.update(lessons)
|
|
.set({ content: JSON.stringify(lessonContent) })
|
|
.where(eq(lessons.id, lessonId));
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] branches + audio saved to DB`);
|
|
}
|
|
}
|
|
|
|
courseContext.completedLessons.push({
|
|
order: i,
|
|
title: topic.title,
|
|
keyConcepts: lessonContent.keyConcepts ?? [],
|
|
analogiesUsed: lessonContent.analogiesUsed ?? [],
|
|
});
|
|
|
|
// generate quiz if missing
|
|
if (existingQuiz.length > 0) {
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] quiz already exists for "${topic.title}" — skipping`);
|
|
} else {
|
|
const quizPrompt = `You are an exam question writer for a university course on ${courseSubject}.
|
|
|
|
COURSE CONTEXT:
|
|
The student has just completed a lesson on "${topic.title}" which covered: ${(lessonContent.keyConcepts ?? []).join(", ")}.
|
|
This is topic ${i + 1} of ${savedTopics.length} — difficulty level: ${topic.difficulty}/5.
|
|
|
|
SOURCE MATERIAL FOR THIS TOPIC (use these to match question style, difficulty, and content exactly):
|
|
${primaryTextForLesson || "(none provided)"}
|
|
|
|
Generate 4 quiz questions for this topic. Mix MCQ and short_answer types. For MCQ, provide 4 options labeled A, B, C, D.
|
|
Match the difficulty level — topic 1 should be very approachable, later topics can be more demanding.
|
|
|
|
Respond with ONLY valid JSON array, no markdown fences:
|
|
[
|
|
{
|
|
"question": "...",
|
|
"type": "mcq",
|
|
"options": ["A. ...", "B. ...", "C. ...", "D. ..."],
|
|
"answer": "A",
|
|
"explanation": "..."
|
|
},
|
|
{
|
|
"question": "...",
|
|
"type": "short_answer",
|
|
"options": null,
|
|
"answer": "...",
|
|
"explanation": "..."
|
|
}
|
|
]`;
|
|
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating quiz for "${topic.title}"…`);
|
|
const quizResult = await askAI([{ role: "user", content: quizPrompt }]);
|
|
costs.ai += quizResult.cost;
|
|
const questions = parseJSON<{
|
|
question: string;
|
|
type: string;
|
|
options: string[] | null;
|
|
answer: string;
|
|
explanation: string;
|
|
}[]>(quizResult.text);
|
|
|
|
for (const q of questions) {
|
|
await db.insert(quizQuestions).values({
|
|
id: randomUUID(),
|
|
topicId: topic.id,
|
|
question: q.question,
|
|
type: q.type as "mcq" | "short_answer" | "worked",
|
|
options: q.options ? JSON.stringify(q.options) : null,
|
|
answer: q.answer,
|
|
explanation: q.explanation,
|
|
});
|
|
}
|
|
|
|
log(courseId, ` [${i + 1}/${savedTopics.length}] quiz saved — ${questions.length} questions`);
|
|
}
|
|
}
|
|
|
|
// ── STEP 5 — mark ready ─────────────────────────────────────────────────
|
|
await db.update(courses)
|
|
.set({ status: "ready", stage: "ready", costAI: costs.ai, costAudio: costs.audio })
|
|
.where(eq(courses.id, courseId));
|
|
log(courseId, `✓ generation complete — ${savedTopics.length} topics | cost: AI $${costs.ai.toFixed(4)}, audio $${costs.audio.toFixed(4)}`);
|
|
|
|
// ── STEP 6 — post-generation audit (non-blocking) ───────────────────────
|
|
await auditCourse(courseId);
|
|
} catch (err: any) {
|
|
console.error(`[revisione:${courseId.slice(0, 8)}] ✗ generation failed: ${err?.message ?? err}`);
|
|
await db.update(courses).set({ status: "error", stage: "error" }).where(eq(courses.id, courseId));
|
|
}
|
|
}
|