initialize project with basic structure and dependencies

This commit is contained in:
ImBenji 2026-04-27 23:59:15 +01:00
parent 83f2837ce6
commit f6f45500f8
13 changed files with 1443 additions and 579 deletions

View file

@ -10,7 +10,7 @@ const { data: course, error, refresh } = await useAsyncData(
let pollTimer: ReturnType<typeof setInterval> | null = null;
function hasUnreadyTopics(): boolean {
return course.value?.topics?.some((t: any) => !t.hasLesson) ?? false;
return course.value?.topics?.some((t: any) => t.status === "generating") ?? false;
}
onMounted(() => {
@ -333,60 +333,73 @@ async function saveTitle() {
<!-- topic list -->
<div class="topic-list">
<TransitionGroup name="topic-appear" tag="div" class="topic-list-inner">
<template v-for="(topic, idx) in course.topics" :key="topic.id">
<NuxtLink
v-if="topic.hasLesson"
:to="`/learn/${topic.id}`"
class="topic-row topic-row--unlocked"
:class="topic.progress?.lessonComplete ? 'topic-row--complete' : 'topic-row--available'"
>
<span class="topic-index">{{ String(idx + 1).padStart(2, "0") }}</span>
<NuxtLink
v-for="(topic, idx) in course.topics"
:key="topic.id"
:to="`/learn/${topic.id}`"
class="topic-row topic-row--unlocked"
:class="{
'topic-row--complete': topic.progress?.lessonComplete,
'topic-row--available': !topic.progress?.lessonComplete && topic.status === 'ready',
'topic-row--pending': topic.status === 'pending' || topic.status === 'generating',
}"
>
<span class="topic-index" :style="topic.status === 'pending' ? 'opacity:0.4' : ''">
{{ String(idx + 1).padStart(2, "0") }}
</span>
<div class="topic-info">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span class="topic-name">{{ topic.title }}</span>
<span v-if="topic.progress?.lessonComplete" class="done-badge">done</span>
<div class="topic-info">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span class="topic-name" :style="topic.status === 'pending' ? 'opacity:0.55' : ''">{{ topic.title }}</span>
<span v-if="topic.progress?.lessonComplete" class="done-badge">done</span>
<span
v-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
class="branch-dot"
title="You needed a little extra help here — that's completely normal."
/>
<span v-if="topic.status === 'pending'" class="topic-status-label topic-status-label--pending">Not yet generated</span>
<span v-else-if="topic.status === 'generating'" class="topic-status-label topic-status-label--generating">
<span class="generating-dot" />Generating...
</span>
<span v-else-if="topic.status === 'error'" class="topic-status-label topic-status-label--error">Error</span>
</div>
<div class="topic-meta">
<div class="diff-dots">
<span
v-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
class="branch-dot"
title="You needed a little extra help here — that's completely normal."
v-for="d in 5"
:key="d"
class="diff-dot"
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
/>
</div>
<div class="topic-meta">
<div class="diff-dots">
<span
v-for="d in 5"
:key="d"
class="diff-dot"
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
/>
</div>
<span class="diff-label">
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
</span>
<span v-if="topic.progress?.quizScore != null" class="diff-label">
· quiz {{ topic.progress.quizScore }}/4
</span>
</div>
<span class="diff-label">
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
</span>
<span v-if="topic.progress?.quizScore != null" class="diff-label">
· quiz {{ topic.progress.quizScore }}/4
</span>
<span
v-if="topic.progress?.lessonComplete && topic.lessonCost != null"
class="lesson-cost-badge"
title="AI generation cost for this lesson"
>
~${{ topic.lessonCost.toFixed(2) }}
</span>
</div>
<svg class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
</svg>
</NuxtLink>
<div v-else class="topic-row topic-row--generating">
<span class="topic-index" style="opacity: 0.4;">{{ String(idx + 1).padStart(2, "0") }}</span>
<div class="topic-info">
<span class="topic-name" style="opacity: 0.5;">{{ topic.title }}</span>
</div>
<span class="generating-label">
<span class="generating-dot" />
Generating...
</span>
</div>
</template>
<svg v-if="topic.status !== 'error'" class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
</svg>
<button
v-else
class="topic-retry-btn"
@click.prevent="$fetch(`/api/topics/${topic.id}/generate`, { method: 'POST' }).then(() => refresh())"
>
Retry
</button>
</NuxtLink>
</TransitionGroup>
</div>
@ -827,4 +840,42 @@ async function saveTitle() {
cursor: pointer; transition: border-color 0.15s, color 0.15s;
}
.audit-show-btn:hover { border-color: var(--border-2); color: var(--text); }
/* topic status labels */
.topic-status-label {
font-size: 10px; letter-spacing: 0.08em;
padding: 1px 7px; border-radius: 20px; font-weight: 500;
}
.topic-status-label--pending {
color: var(--text-3); background: var(--surface-2);
border: 1px solid var(--border);
}
.topic-status-label--generating {
color: var(--accent); background: var(--surface-2);
border: 1px solid var(--accent-dim);
display: inline-flex; align-items: center; gap: 4px;
}
.topic-status-label--error {
color: oklch(50% 0.18 25); background: oklch(95% 0.03 15);
border: 1px solid oklch(80% 0.06 15);
}
/* topic row states */
.topic-row--pending { border-left-color: var(--border); }
/* lesson cost badge */
.lesson-cost-badge {
font-family: "IBM Plex Mono", monospace;
font-size: 10px; color: var(--text-3); letter-spacing: 0.04em;
}
/* retry button on error topics */
.topic-retry-btn {
font-size: 11px; letter-spacing: 0.06em;
background: none; border: 1px solid oklch(80% 0.06 15);
color: oklch(50% 0.18 25); border-radius: 20px;
padding: 3px 10px; cursor: pointer; flex-shrink: 0;
transition: background 0.15s;
}
.topic-retry-btn:hover { background: oklch(95% 0.03 15); }
</style>

View file

@ -77,6 +77,40 @@ async function retry(courseId: string) {
}
}
const showDeleteModal = ref(false);
const deleteLicenseKey = ref("");
const deleteLicenseError = ref<string | null>(null);
const pendingDeleteId = ref<string | null>(null);
const deleting = ref(false);
function requestDelete(courseId: string) {
pendingDeleteId.value = courseId;
deleteLicenseKey.value = "";
deleteLicenseError.value = null;
showDeleteModal.value = true;
}
async function confirmDelete() {
if (!deleteLicenseKey.value.trim()) {
deleteLicenseError.value = "Enter a license key.";
return;
}
deleting.value = true;
try {
await $fetch(`/api/courses/${pendingDeleteId.value}`, {
method: "DELETE",
headers: { "x-license-key": deleteLicenseKey.value.trim() },
});
showDeleteModal.value = false;
pendingDeleteId.value = null;
await refresh();
} catch (err: any) {
deleteLicenseError.value = err?.data?.message ?? "Delete failed";
} finally {
deleting.value = false;
}
}
function progressPct(course: CourseSummary): number {
if (!course.topicCount) return 0;
return Math.round((course.completedCount / course.topicCount) * 100);
@ -150,10 +184,17 @@ function subjectColor(subject: string): string {
</div>
</div>
<span class="status-badge" :class="`status-badge--${course.status}`">
<span v-if="course.status === 'processing'" class="badge-pulse" />
{{ course.status === 'ready' ? 'Ready' : course.status === 'error' ? 'Error' : 'Processing' }}
</span>
<div class="card-top-right">
<span class="status-badge" :class="`status-badge--${course.status}`">
<span v-if="course.status === 'processing'" class="badge-pulse" />
{{ course.status === 'ready' ? 'Ready' : course.status === 'error' ? 'Error' : 'Processing' }}
</span>
<button class="delete-btn" title="Delete course" @click.stop.prevent="requestDelete(course.id)">
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5h14M8 5V3h4v2M6 5l1 12h6l1-12"/>
</svg>
</button>
</div>
</div>
<!-- bottom -->
@ -212,6 +253,36 @@ function subjectColor(subject: string): string {
</div>
</div>
<Teleport to="body">
<Transition name="modal">
<div v-if="showDeleteModal" class="modal-backdrop" @click.self="showDeleteModal = false">
<div class="modal-box">
<p class="modal-title">Delete course?</p>
<p class="modal-sub">This is permanent. Enter your license key to confirm.</p>
<input
v-model="deleteLicenseKey"
class="modal-input"
type="text"
placeholder="paste key here…"
autofocus
@keydown.enter="confirmDelete"
@keydown.esc="showDeleteModal = false"
/>
<p v-if="deleteLicenseError" class="modal-error">{{ deleteLicenseError }}</p>
<div class="modal-actions">
<button class="modal-cancel" @click="showDeleteModal = false">Cancel</button>
<button class="modal-confirm modal-confirm--danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting…' : 'Delete' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
@ -570,4 +641,118 @@ function subjectColor(subject: string): string {
font-size: 14px;
font-weight: 500;
}
.card-top-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.delete-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-3);
padding: 2px;
opacity: 0;
transition: color 0.15s, opacity 0.15s;
line-height: 0;
}
.course-card:hover .delete-btn { opacity: 1; }
.delete-btn:hover { color: oklch(42% 0.12 15); }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-box {
background: var(--bg);
border: 1px solid var(--border-2);
border-radius: var(--r-surface);
padding: 28px 28px 24px;
width: 100%;
max-width: 420px;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.modal-sub {
font-size: 13px;
color: var(--text-3);
margin-bottom: 18px;
}
.modal-input {
width: 100%;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
color: var(--text);
font-size: 13px;
font-family: monospace;
padding: 10px 12px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.modal-input:focus { border-color: var(--accent); }
.modal-error {
font-size: 12px;
color: oklch(42% 0.12 15);
margin-top: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.modal-cancel {
font-size: 14px;
color: var(--text-3);
background: none;
border: 1px solid var(--border-2);
border-radius: var(--r-btn);
padding: 8px 16px;
cursor: pointer;
transition: color 0.15s;
}
.modal-cancel:hover { color: var(--text); }
.modal-confirm {
font-size: 14px;
font-weight: 500;
background: var(--accent);
color: white;
border: none;
border-radius: var(--r-btn);
padding: 8px 20px;
cursor: pointer;
transition: opacity 0.15s;
}
.modal-confirm:hover:not(:disabled) { opacity: 0.88; }
.modal-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
.modal-confirm--danger {
background: oklch(48% 0.14 15);
}
.modal-enter-active, .modal-leave-active { transition: opacity 0.18s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View file

@ -5,10 +5,124 @@ import confetti from "canvas-confetti";
const route = useRoute();
const topicId = route.params.id as string;
const { data: lesson, pending: lessonPending, error: lessonError } = useAsyncData(
`lesson-${topicId}`,
() => $fetch<any>(`/api/topics/${topicId}/lesson`)
);
// JIT generation state
type GenState = "loading" | "ready" | "error";
const lesson = ref<any>(null);
const lessonPending = ref(false);
const lessonError = ref<any>(null);
const genState = ref<GenState | null>(null);
const GEN_TIPS = [
"Reading through your past papers...",
"Crafting analogies just for this topic...",
"Writing your lesson from scratch...",
"Recording your audio narration...",
"Almost there...",
];
const GEN_STAGES = [
"Understanding the topic",
"Writing your lesson",
"Recording narration",
"Finishing up",
];
const genTipIndex = ref(0);
const genTipVisible = ref(true);
const genStageIndex = ref(0);
let genTipTimer: ReturnType<typeof setInterval> | null = null;
let genStageTimer: ReturnType<typeof setInterval> | null = null;
function startGenAnimations() {
genTipTimer = setInterval(() => {
genTipVisible.value = false;
setTimeout(() => {
genTipIndex.value = (genTipIndex.value + 1) % GEN_TIPS.length;
genTipVisible.value = true;
}, 300);
}, 3000);
genStageTimer = setInterval(() => {
genStageIndex.value = (genStageIndex.value + 1) % GEN_STAGES.length;
}, 4500);
}
function stopGenAnimations() {
if (genTipTimer) { clearInterval(genTipTimer); genTipTimer = null; }
if (genStageTimer) { clearInterval(genStageTimer); genStageTimer = null; }
}
async function loadLesson() {
lessonPending.value = true;
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = data;
genState.value = "ready";
} catch (err: any) {
if (err?.statusCode === 404 || err?.status === 404) {
// lesson not generated yet trigger JIT generation
await triggerGeneration();
} else {
lessonError.value = err;
}
} finally {
lessonPending.value = false;
}
}
async function triggerGeneration() {
genState.value = "loading";
startGenAnimations();
try {
const result = await $fetch<{ status: string }>(`/api/topics/${topicId}/generate`, { method: "POST" });
stopGenAnimations();
if (result.status === "ready") {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = data;
genState.value = "ready";
} else {
genState.value = "error";
}
} catch {
stopGenAnimations();
genState.value = "error";
}
}
onMounted(loadLesson);
onUnmounted(() => {
stopGenAnimations();
});
// branch loading overlay
const branchOverlayVisible = ref(false);
const branchOverlayError = ref(false);
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
function startBranchPoll() {
branchPollTimer = setInterval(async () => {
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
if (data.branchStatus === "ready") {
lesson.value = data;
stopBranchPoll();
branchOverlayVisible.value = false;
} else if (data.branchStatus === "error") {
stopBranchPoll();
branchOverlayError.value = true;
}
} catch { /* ignore poll errors */ }
}, 2000);
}
function stopBranchPoll() {
if (branchPollTimer) { clearInterval(branchPollTimer); branchPollTimer = null; }
}
// lesson state machine
@ -203,11 +317,36 @@ const LABELS = ["A", "B", "C", "D"];
// branch tracking
const pendingBranchStep = ref<number | null>(null);
const pendingBranchOption = ref<string>("");
// when branch overlay dismisses after poll completes, fire the deferred branch
watch(branchOverlayVisible, (visible) => {
if (!visible && pendingBranchStep.value !== null && !branchOverlayError.value) {
const step = pendingBranchStep.value;
const opt = pendingBranchOption.value;
pendingBranchStep.value = null;
pendingBranchOption.value = "";
enterBranchMode(step, opt);
}
});
const branchesEntered = ref(0);
let transitionTimeout: ReturnType<typeof setTimeout> | null = null;
let confirmAdvanceTimeout: ReturnType<typeof setTimeout> | null = null;
function enterBranchMode(stepIndex: number, wrongOption: string) {
// if branches arent ready yet, show overlay and wait
if (lesson.value?.branchStatus !== "ready") {
branchOverlayVisible.value = true;
branchOverlayError.value = false;
startBranchPoll();
// store where we want to go when branches become available
pendingBranchStep.value = stepIndex;
pendingBranchOption.value = wrongOption;
return;
}
branchesEntered.value++;
lessonState.mode = "transition";
lessonState.stepIndex = stepIndex;
@ -619,13 +758,72 @@ function continueAfterQuestion() {
onBeforeUnmount(() => {
stopAllAudio();
stopBranchPoll();
if (transitionTimeout) clearTimeout(transitionTimeout);
if (confirmAdvanceTimeout) clearTimeout(confirmAdvanceTimeout);
});
</script>
<template>
<!-- JIT GENERATION LOADING SCREEN -->
<Transition name="fullscreen-fade">
<div v-if="genState === 'loading'" class="gen-loading-screen">
<div class="gen-inner">
<div class="gen-logo">Revisi<span>.one</span></div>
<div class="gen-pulse-ring">
<div class="gen-pulse-circle" />
<div class="gen-pulse-dot">
<svg width="28" height="28" fill="none" viewBox="0 0 24 24" stroke="oklch(54% 0.140 44)">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
</div>
<Transition name="tip">
<p v-if="genTipVisible" :key="genTipIndex" class="gen-tip">{{ GEN_TIPS[genTipIndex] }}</p>
</Transition>
<div class="gen-stepper">
<div
v-for="(stage, i) in GEN_STAGES"
:key="i"
class="gen-step"
:class="i === genStageIndex ? 'gen-step--active' : i < genStageIndex ? 'gen-step--done' : 'gen-step--pending'"
>
<div class="gen-step-node">
<div v-if="i < genStageIndex" class="gen-node-done">
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div v-else-if="i === genStageIndex" class="gen-node-active">
<div class="gen-node-ring" />
<div class="gen-node-dot" />
</div>
<div v-else class="gen-node-pending" />
</div>
<span class="gen-step-label">{{ stage }}</span>
</div>
</div>
</div>
</div>
</Transition>
<!-- JIT GENERATION ERROR -->
<Transition name="fullscreen-fade">
<div v-if="genState === 'error'" class="gen-loading-screen">
<div class="gen-inner">
<div class="gen-logo">Revisi<span>.one</span></div>
<p class="gen-error-msg">We couldn't generate this lesson. Please try again.</p>
<button class="gen-retry-btn" @click="triggerGeneration">Try again</button>
</div>
</div>
</Transition>
<div
v-if="genState === 'ready' || genState === null"
class="lesson-shell"
:style="{ '--step-bg': stepBg, '--accent': accentColor }"
>
@ -899,6 +1097,32 @@ onBeforeUnmount(() => {
</template>
<!-- BRANCH LOADING OVERLAY -->
<Transition name="fullscreen-fade">
<div v-if="branchOverlayVisible" class="branch-loading-overlay">
<div class="branch-loading-inner">
<template v-if="!branchOverlayError">
<div class="branch-loading-pulse">
<div class="branch-loading-ring" />
<div class="branch-loading-icon">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="#92400E">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
</div>
<p class="branch-loading-text">Preparing personalised feedback</p>
</template>
<template v-else>
<p class="branch-loading-error">We couldn't prepare feedback for this answer.</p>
<button class="branch-loading-skip" @click="() => { branchOverlayVisible = false; pendingBranchStep = null; }">
Continue
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
@ -1505,4 +1729,255 @@ onBeforeUnmount(() => {
.step-body { font-size: 1rem; }
.focus-label { display: none; }
}
/* ── JIT generation loading screen ──────────────────────────────────────── */
.gen-loading-screen {
position: fixed;
inset: 0;
z-index: 100;
background: #FAFAF8;
display: flex;
align-items: center;
justify-content: center;
}
.gen-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
padding: 2rem;
max-width: 380px;
width: 100%;
}
.gen-logo {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.5rem;
color: oklch(35% 0.018 58);
letter-spacing: -0.02em;
}
.gen-logo span {
color: oklch(54% 0.140 44);
}
.gen-pulse-ring {
position: relative;
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-pulse-circle {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid oklch(82% 0.055 76);
animation: gen-pulse 2s ease-in-out infinite;
}
@keyframes gen-pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.15); opacity: 0.3; }
}
.gen-pulse-dot {
width: 52px;
height: 52px;
border-radius: 50%;
background: oklch(95% 0.030 76);
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid oklch(85% 0.045 72);
}
.gen-tip {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: oklch(50% 0.016 65);
font-style: italic;
text-align: center;
line-height: 1.6;
min-height: 2.5rem;
}
.gen-stepper {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.gen-step {
display: flex;
align-items: center;
gap: 0.875rem;
}
.gen-step-node {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-done {
width: 20px;
height: 20px;
border-radius: 50%;
background: oklch(54% 0.140 44);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-active {
position: relative;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid oklch(54% 0.140 44);
animation: gen-pulse 1.5s ease-in-out infinite;
}
.gen-node-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: oklch(54% 0.140 44);
}
.gen-node-pending {
width: 8px;
height: 8px;
border-radius: 50%;
background: oklch(82% 0.022 73);
}
.gen-step-label {
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
letter-spacing: 0.04em;
transition: color 0.3s;
}
.gen-step--done .gen-step-label { color: oklch(54% 0.140 44); }
.gen-step--active .gen-step-label { color: oklch(35% 0.018 58); font-weight: 600; }
.gen-step--pending .gen-step-label { color: oklch(72% 0.016 65); }
.gen-error-msg {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: oklch(45% 0.18 25);
text-align: center;
line-height: 1.6;
}
.gen-retry-btn {
background: oklch(54% 0.140 44);
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.75rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s;
}
.gen-retry-btn:hover { opacity: 0.85; }
.tip-enter-active { transition: opacity 0.3s ease; }
.tip-leave-active { transition: opacity 0.2s ease; }
.tip-enter-from, .tip-leave-to { opacity: 0; }
/* ── branch loading overlay ──────────────────────────────────────────────── */
.branch-loading-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(255, 251, 235, 0.88);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
}
.branch-loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem;
text-align: center;
}
.branch-loading-pulse {
position: relative;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.branch-loading-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid #F59E0B;
animation: gen-pulse 2s ease-in-out infinite;
}
.branch-loading-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: #FEF3C7;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid #FCD34D;
}
.branch-loading-text {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.0625rem;
color: #92400E;
line-height: 1.5;
}
.branch-loading-error {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: #92400E;
line-height: 1.6;
}
.branch-loading-skip {
background: #F59E0B;
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.75rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s;
}
.branch-loading-skip:hover { opacity: 0.85; }
</style>

View file

@ -0,0 +1,7 @@
ALTER TABLE `topics` ADD `status` text DEFAULT 'pending' NOT NULL;
ALTER TABLE `lessons` ADD `cost_ai` real DEFAULT 0;
ALTER TABLE `lessons` ADD `cost_audio` real DEFAULT 0;
ALTER TABLE `lessons` ADD `cost_branch_ai` real DEFAULT 0;
ALTER TABLE `lessons` ADD `cost_branch_audio` real DEFAULT 0;
ALTER TABLE `lessons` ADD `cost_total` real DEFAULT 0;
ALTER TABLE `lessons` ADD `branch_status` text DEFAULT 'pending' NOT NULL;

View file

@ -99,6 +99,13 @@
"when": 1777200000010,
"tag": "0013_relevant_files",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1777200000011,
"tag": "0014_jit_lessons",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,42 @@
import { db } from "../../../db/index";
import { courses, uploads, topics, lessons, quizQuestions, userProgress } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { verifyLicenseKey } from "../../../utils/license";
import { rm } from "fs/promises";
import { resolve } from "path";
export default defineEventHandler(async (event) => {
const key = getHeader(event, "x-license-key") ?? "";
if (!verifyLicenseKey(key)) {
throw createError({ statusCode: 401, message: "Invalid or expired license key" });
}
const id = getRouterParam(event, "id")!;
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
// delete child records in order
const courseTopics = await db.query.topics.findMany({ where: eq(topics.courseId, id) });
for (const topic of courseTopics) {
await db.delete(lessons).where(eq(lessons.topicId, topic.id));
await db.delete(quizQuestions).where(eq(quizQuestions.topicId, topic.id));
await db.delete(userProgress).where(eq(userProgress.topicId, topic.id));
}
await db.delete(topics).where(eq(topics.courseId, id));
await db.delete(uploads).where(eq(uploads.courseId, id));
await db.delete(userProgress).where(eq(userProgress.courseId, id));
await db.delete(courses).where(eq(courses.id, id));
// clean up uploaded files on disk
try {
const uploadDir = resolve(process.cwd(), "uploads", id);
await rm(uploadDir, { recursive: true, force: true });
} catch {
// non-fatal
}
return { ok: true };
});

View file

@ -29,7 +29,8 @@ export default defineEventHandler(async (event) => {
? await db.query.lessons.findMany({ where: inArray(lessons.topicId, topicIds) })
: [];
const lessonTopicIds = new Set(lessonRows.map((l) => l.topicId));
const lessonMap: Record<string, typeof lessonRows[0]> = {};
for (const l of lessonRows) lessonMap[l.topicId] = l;
return {
...course,
@ -37,7 +38,8 @@ export default defineEventHandler(async (event) => {
...t,
prerequisiteTopicIds: JSON.parse(t.prerequisiteTopicIds ?? "[]"),
progress: progressMap[t.id] ?? null,
hasLesson: lessonTopicIds.has(t.id),
hasLesson: !!lessonMap[t.id],
lessonCost: lessonMap[t.id]?.costTotal ?? null,
})),
};
});

View file

@ -0,0 +1,27 @@
import { db } from "../../../db/index";
import { topics, lessons } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { generateLesson } from "../../../utils/generateLesson";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id")!;
const topic = await db.query.topics.findFirst({ where: eq(topics.id, id) });
if (!topic) throw createError({ statusCode: 404, message: "Topic not found" });
if (topic.status === "ready") {
return { status: "ready" };
}
if (topic.status === "generating") {
return { status: "generating" };
}
try {
await generateLesson(id);
return { status: "ready" };
} catch (err: any) {
console.error(`[generate.post] topic ${id} failed: ${err?.message ?? err}`);
return { status: "error" };
}
});

View file

@ -14,5 +14,6 @@ export default defineEventHandler(async (event) => {
return {
...lesson,
content: JSON.parse(lesson.content),
branchStatus: lesson.branchStatus,
};
});

View file

@ -46,6 +46,7 @@ export const topics = sqliteTable("topics", {
prerequisiteTopicIds: text("prerequisite_topic_ids").notNull().default("[]"),
difficulty: integer("difficulty").notNull().default(1),
relevantFiles: text("relevant_files"),
status: text("status", { enum: ["pending", "generating", "ready", "error"] }).notNull().default("pending"),
});
export const lessons = sqliteTable("lessons", {
@ -55,6 +56,12 @@ export const lessons = sqliteTable("lessons", {
.references(() => topics.id),
content: text("content").notNull(),
ttsProvider: text("tts_provider"),
costAI: real("cost_ai").default(0),
costAudio: real("cost_audio").default(0),
costBranchAI: real("cost_branch_ai").default(0),
costBranchAudio: real("cost_branch_audio").default(0),
costTotal: real("cost_total").default(0),
branchStatus: text("branch_status", { enum: ["pending", "generating", "ready", "error"] }).notNull().default("pending"),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),

View file

@ -0,0 +1,206 @@
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));
}
}

View file

@ -1,25 +1,12 @@
import { db } from "../db/index";
import { courses, uploads, topics, lessons, quizQuestions } from "../db/schema";
import { courses, uploads, topics } 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}`);
@ -39,80 +26,12 @@ function parseJSON<T>(raw: string): T {
}
}
// 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 };
let costs = { ai: 0 };
log(courseId, `starting generation for "${course.title}"`);
@ -147,7 +66,7 @@ export async function generateCourseInBackground(courseId: string) {
log(courseId, `source split — primary: ${primaryParts.length}, secondary: ${secondaryParts.length}`);
// ── STEP 1b — infer title, subject, and topic count ────────────────────
// ── STEP 1b — infer title, subject, organisation ───────────────────────
await setStage(courseId, "analysing_sources");
const allExtracted = [
@ -274,10 +193,10 @@ The description must be specific about what the student will be able to DO after
difficulty: Math.min(5, Math.max(1, t.difficulty ?? 1)),
prerequisiteTopicIds: "[]",
relevantFiles: JSON.stringify(t.relevantFiles ?? []),
status: "pending",
});
}
// 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),
@ -286,444 +205,14 @@ The description must be specific about what the student will be able to DO after
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 ─────────────────────────────────────────────────
// ── STEP 3 — mark course ready immediately after topics are saved ────────
await db.update(courses)
.set({ status: "ready", stage: "ready", costAI: costs.ai, costAudio: costs.audio })
.set({ status: "ready", stage: "ready", costAI: costs.ai })
.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) ───────────────────────
log(courseId, `✓ curriculum ready — ${savedTopics.length} topics | AI cost: $${costs.ai.toFixed(4)}`);
// ── STEP 4 — post-generation audit (non-blocking) ───────────────────────
await auditCourse(courseId);
} catch (err: any) {
console.error(`[revisione:${courseId.slice(0, 8)}] ✗ generation failed: ${err?.message ?? err}`);

View file

@ -0,0 +1,365 @@
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 { generateStepTTS, generateQuestionTTS, generateOptionTTS } from "./generateTTS";
import { generateBranches } from "./generateBranches";
function log(topicId: string, msg: string) {
console.log(`[lesson:${topicId.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);
}
}
async function generateLessonAudio(
steps: any[],
lessonId: string,
topicId: 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") {
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;
}
}
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(topicId, ` step ${si} (${step.type}) TTS done`);
}
return { steps, cost };
}
export async function generateLesson(topicId: string): Promise<void> {
try {
// ── Step 1 — mark topic as generating ──────────────────────────────────
await db.update(topics).set({ status: "generating" }).where(eq(topics.id, topicId));
// ── Step 2 — load context ───────────────────────────────────────────────
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`);
// completed lessons for this course (for prior knowledge context)
const allTopics = await db.query.topics.findMany({
where: eq(topics.courseId, topic.courseId),
orderBy: (t, { asc }) => asc(t.order),
});
const completedLessons: { order: number; title: string; keyConcepts: string[]; analogiesUsed: string[] }[] = [];
for (const t of allTopics) {
if (t.status !== "ready" || t.order >= topic.order) continue;
const l = await db.query.lessons.findFirst({ where: eq(lessons.topicId, t.id) });
if (!l) continue;
try {
const parsed = JSON.parse(l.content) as { keyConcepts?: string[]; analogiesUsed?: string[] };
completedLessons.push({
order: t.order,
title: t.title,
keyConcepts: parsed.keyConcepts ?? [],
analogiesUsed: parsed.analogiesUsed ?? [],
});
} catch { /* skip malformed */ }
}
// load relevant source files
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");
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")
: uploadRows
.filter((u) => u.type === "slides" && u.extractedText)
.map((u) => `--- ${u.filename} ---\n${u.extractedText}`)
.join("\n\n");
const topicListText = allTopics.map((t) => `${t.order + 1}. ${t.title}`).join("\n");
const isFirst = topic.order === 0;
const courseSubject = course.subject;
// ── Step 3 — generate lesson ────────────────────────────────────────────
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.` : 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 ${allTopics.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(topicId, `generating lesson for "${topic.title}"…`);
const lessonResult = await askAI([{ role: "user", content: lessonPrompt }]);
let costAI = lessonResult.cost;
let lessonContent: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] } = parseJSON(lessonResult.text);
// ── Step 4 — generate quiz ──────────────────────────────────────────────
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 ${topic.order + 1} of ${allTopics.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(topicId, "generating quiz…");
const quizResult = await askAI([{ role: "user", content: quizPrompt }]);
costAI += quizResult.cost;
const questions = parseJSON<{
question: string;
type: string;
options: string[] | null;
answer: string;
explanation: string;
}[]>(quizResult.text);
// ── Step 5 — generate TTS for all lesson steps ──────────────────────────
const lessonId = randomUUID();
const ttsProvider = (useRuntimeConfig().ttsProvider as string | undefined)?.toLowerCase() ?? "elevenlabs";
// save lesson first so audio dir has an id to write to
await db.insert(lessons).values({
id: lessonId,
topicId: topic.id,
content: JSON.stringify(lessonContent),
ttsProvider,
costAI,
costAudio: 0,
costBranchAI: 0,
costBranchAudio: 0,
costTotal: costAI,
branchStatus: "pending",
});
log(topicId, `lesson saved (${lessonId}), generating TTS…`);
let costAudio = 0;
try {
const { steps: stepsWithAudio, cost: audioCost } = await generateLessonAudio(
lessonContent.steps as any[],
lessonId,
topicId
);
lessonContent.steps = stepsWithAudio;
costAudio = audioCost;
} catch (err: any) {
console.error(`[lesson] TTS failed for ${lessonId}: ${err?.message ?? err}`);
}
// ── Step 6 — save to DB ─────────────────────────────────────────────────
await db.update(lessons)
.set({
content: JSON.stringify(lessonContent),
costAI,
costAudio,
costTotal: costAI + costAudio,
branchStatus: "pending",
})
.where(eq(lessons.id, lessonId));
// save quiz questions
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,
});
}
await db.update(topics).set({ status: "ready" }).where(eq(topics.id, topicId));
log(topicId, `✓ lesson ready — cost AI $${costAI.toFixed(4)}, audio $${costAudio.toFixed(4)}`);
// ── Step 7 — fire and forget branch generation ──────────────────────────
generateBranches(topicId, lessonId).catch((err: any) => {
console.error(`[lesson] branch generation failed for ${lessonId}: ${err?.message ?? err}`);
});
} catch (err: any) {
console.error(`[lesson:${topicId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
await db.update(topics).set({ status: "error" }).where(eq(topics.id, topicId));
}
}