harden database interactions and improve error handling
This commit is contained in:
parent
5a4caaf1d0
commit
e1f168a302
29 changed files with 869 additions and 241 deletions
46
FIXES.md
Normal file
46
FIXES.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Hardening Fixes
|
||||||
|
|
||||||
|
## Architectural Decisions
|
||||||
|
|
||||||
|
### Canonical migration runner
|
||||||
|
`server/plugins/migrate.ts` is the canonical migration runner (Nitro plugin, runs on server start). `server/db/migrate.ts` is the standalone CLI script kept for manual use via `npm run db:migrate`, but the plugin is authoritative.
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
Appropriate for single-user/small-team. For multi-user production, evaluate PostgreSQL with Drizzle's pg adapter.
|
||||||
|
|
||||||
|
### Audio serving
|
||||||
|
Currently served from `/public/audio`. To complete the private audio migration:
|
||||||
|
1. Move audio generation output directory from `public/audio` to `private/audio`
|
||||||
|
2. Update all `audioPath` values stored in lesson content JSON — paths are stored as `/audio/...` and need to become `/api/audio/...`
|
||||||
|
3. This requires a one-time data migration script
|
||||||
|
|
||||||
|
### inFlightCourses Set
|
||||||
|
In-process only — does not survive server restarts. For multi-process deployments, use a DB flag or Redis.
|
||||||
|
|
||||||
|
### Per-topic mutex
|
||||||
|
Same limitation — in-process only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## .env git history
|
||||||
|
`git log --all -- .env` returned no output — `.env` has never been committed to this repository. No key rotation required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
Not yet implemented. Marked for manual addition via a Nitro middleware using a simple Map-based token bucket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost tracking
|
||||||
|
`costAI` and `costAudio` fields are estimated values based on API-reported costs. Reconcile against OpenRouter and TTS provider dashboards monthly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items that could NOT be fixed automatically
|
||||||
|
|
||||||
|
- **Full audio path migration** (public → private/audio + updating stored JSON paths): requires a one-time data migration script
|
||||||
|
- **Drizzle migration for ON DELETE CASCADE on foreign keys**: SQLite doesn't support `ALTER TABLE ... ADD FOREIGN KEY`, so CASCADE would require recreating all tables. Recommend doing this on next schema version if needed.
|
||||||
|
- **Full composable extraction from `learn/[id]/index.vue`** (`useLessonState`, `useFocusMode`, `useBranchPoll`): architectural refactor deferred, not a correctness issue.
|
||||||
|
- **Prompt versioning** (`server/prompts/` directory): deferred, not a correctness issue.
|
||||||
|
- **Full consola migration**: deferred tech debt.
|
||||||
|
|
@ -14,6 +14,9 @@ const lessonPending = ref(false);
|
||||||
const lessonError = ref<any>(null);
|
const lessonError = ref<any>(null);
|
||||||
const genState = ref<GenState | null>(null);
|
const genState = ref<GenState | null>(null);
|
||||||
|
|
||||||
|
const abortController = ref<AbortController | null>(null);
|
||||||
|
const generationFired = ref(false);
|
||||||
|
|
||||||
const GEN_TIPS = [
|
const GEN_TIPS = [
|
||||||
"Reading through your past papers...",
|
"Reading through your past papers...",
|
||||||
"Crafting analogies just for this topic...",
|
"Crafting analogies just for this topic...",
|
||||||
|
|
@ -36,6 +39,7 @@ let genTipTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let genStageTimer: ReturnType<typeof setInterval> | null = null;
|
let genStageTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function startGenAnimations() {
|
function startGenAnimations() {
|
||||||
|
if (genTipTimer) return;
|
||||||
genTipTimer = setInterval(() => {
|
genTipTimer = setInterval(() => {
|
||||||
genTipVisible.value = false;
|
genTipVisible.value = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -55,7 +59,12 @@ function stopGenAnimations() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLesson() {
|
async function loadLesson() {
|
||||||
lessonPending.value = true;
|
lesson.value = null;
|
||||||
|
|
||||||
|
const pendingTimer = setTimeout(() => { lessonPending.value = true; }, 100);
|
||||||
|
|
||||||
|
abortController.value = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||||
lesson.value = data;
|
lesson.value = data;
|
||||||
|
|
@ -68,11 +77,15 @@ async function loadLesson() {
|
||||||
lessonError.value = err;
|
lessonError.value = err;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(pendingTimer);
|
||||||
lessonPending.value = false;
|
lessonPending.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerGeneration() {
|
async function triggerGeneration() {
|
||||||
|
if (generationFired.value) return;
|
||||||
|
generationFired.value = true;
|
||||||
|
|
||||||
genState.value = "loading";
|
genState.value = "loading";
|
||||||
startGenAnimations();
|
startGenAnimations();
|
||||||
try {
|
try {
|
||||||
|
|
@ -95,6 +108,7 @@ onMounted(loadLesson);
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopGenAnimations();
|
stopGenAnimations();
|
||||||
|
abortController.value?.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── branch loading overlay ─────────────────────────────────────────────────
|
// ── branch loading overlay ─────────────────────────────────────────────────
|
||||||
|
|
@ -103,8 +117,13 @@ const branchOverlayVisible = ref(false);
|
||||||
const branchOverlayError = ref(false);
|
const branchOverlayError = ref(false);
|
||||||
|
|
||||||
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
|
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const branchPollStart = ref(0);
|
||||||
|
|
||||||
function startBranchPoll() {
|
function startBranchPoll() {
|
||||||
|
if (branchPollTimer) return;
|
||||||
|
|
||||||
|
branchPollStart.value = Date.now();
|
||||||
|
|
||||||
branchPollTimer = setInterval(async () => {
|
branchPollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||||
|
|
@ -117,6 +136,11 @@ function startBranchPoll() {
|
||||||
branchOverlayError.value = true;
|
branchOverlayError.value = true;
|
||||||
}
|
}
|
||||||
} catch { /* ignore poll errors */ }
|
} catch { /* ignore poll errors */ }
|
||||||
|
|
||||||
|
if (Date.now() - branchPollStart.value > 60_000) {
|
||||||
|
stopBranchPoll();
|
||||||
|
branchOverlayError.value = true;
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,10 +319,12 @@ const showMainExplanation = computed(() => {
|
||||||
|
|
||||||
const shuffledOptions = ref<string[]>([]);
|
const shuffledOptions = ref<string[]>([]);
|
||||||
|
|
||||||
function fisherYates(arr: string[]): string[] {
|
function seededShuffle(arr: string[], seed: number): string[] {
|
||||||
const a = [...arr];
|
const a = [...arr];
|
||||||
|
let s = seed;
|
||||||
for (let i = a.length - 1; i > 0; i--) {
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
const j = s % (i + 1);
|
||||||
[a[i], a[j]] = [a[j], a[i]];
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
|
|
@ -307,7 +333,8 @@ function fisherYates(arr: string[]): string[] {
|
||||||
watch([() => stepKey.value, steps], () => {
|
watch([() => stepKey.value, steps], () => {
|
||||||
const s = displayStep.value;
|
const s = displayStep.value;
|
||||||
if (s?.type === "question" && s.options) {
|
if (s?.type === "question" && s.options) {
|
||||||
shuffledOptions.value = fisherYates(s.options);
|
const seed = lessonState.stepIndex * 31 + (lessonState.branchOption ? lessonState.branchOption.charCodeAt(0) : 0);
|
||||||
|
shuffledOptions.value = seededShuffle(s.options, seed);
|
||||||
} else {
|
} else {
|
||||||
shuffledOptions.value = [];
|
shuffledOptions.value = [];
|
||||||
}
|
}
|
||||||
|
|
@ -478,19 +505,24 @@ function advance() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lessonScore = ref(0);
|
const lessonScore = ref(0);
|
||||||
watch(selectedAnswers, (opts, prev) => {
|
watch(() => ({ ...selectedAnswers.value }), (opts, prev) => {
|
||||||
const newKeys = Object.keys(opts).map(Number).filter(k => !(k in prev));
|
const newKeys = Object.keys(opts).map(Number).filter(k => !(k in prev));
|
||||||
for (const k of newKeys) {
|
for (const k of newKeys) {
|
||||||
const s = steps.value[k];
|
const s = steps.value[k];
|
||||||
if (s?.type === "question" && opts[k] === s.answer) lessonScore.value++;
|
if (s?.type === "question" && opts[k] === s.answer) lessonScore.value++;
|
||||||
}
|
}
|
||||||
}, { deep: true });
|
});
|
||||||
|
|
||||||
watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => {
|
watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => {
|
||||||
if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration();
|
if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const celebrationFired = ref(false);
|
||||||
|
|
||||||
function fireCelebration() {
|
function fireCelebration() {
|
||||||
|
if (celebrationFired.value) return;
|
||||||
|
celebrationFired.value = true;
|
||||||
|
|
||||||
const count = 160;
|
const count = 160;
|
||||||
const defaults = { startVelocity: 35, spread: 80, ticks: 70, zIndex: 200 };
|
const defaults = { startVelocity: 35, spread: 80, ticks: 70, zIndex: 200 };
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
|
|
@ -502,13 +534,20 @@ function fireCelebration() {
|
||||||
|
|
||||||
const lessonDone = ref(false);
|
const lessonDone = ref(false);
|
||||||
const completing = ref(false);
|
const completing = ref(false);
|
||||||
|
const completeError = ref(false);
|
||||||
|
|
||||||
async function completeLesson() {
|
async function completeLesson() {
|
||||||
if (completing.value || lessonDone.value) return;
|
if (completing.value || lessonDone.value) return;
|
||||||
completing.value = true;
|
completing.value = true;
|
||||||
|
completeError.value = false;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/topics/${topicId}/progress`, {
|
await $fetch(`/api/topics/${topicId}/progress`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
signal: ctrl.signal,
|
||||||
body: {
|
body: {
|
||||||
lessonComplete: true,
|
lessonComplete: true,
|
||||||
tookBranches: branchesEntered.value > 0,
|
tookBranches: branchesEntered.value > 0,
|
||||||
|
|
@ -516,12 +555,38 @@ async function completeLesson() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
lessonDone.value = true;
|
lessonDone.value = true;
|
||||||
|
} catch {
|
||||||
|
completing.value = false;
|
||||||
|
completeError.value = true;
|
||||||
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
completing.value = false;
|
completing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() { history.back(); }
|
const router = useRouter();
|
||||||
|
function goBack() {
|
||||||
|
if (history.length <= 1) { router.push("/"); return; }
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBack() {
|
||||||
|
stopAllAudio();
|
||||||
|
if (lessonState.mode === "branch" || lessonState.mode === "confirm" || lessonState.mode === "hardStop") {
|
||||||
|
// exit branch back to main question
|
||||||
|
lessonState.mode = "main";
|
||||||
|
lessonState.branchOption = "";
|
||||||
|
lessonState.branchStepIndex = 0;
|
||||||
|
confirmSelectedAnswer.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lessonState.stepIndex > 0) {
|
||||||
|
lessonState.stepIndex--;
|
||||||
|
// clear the answer for this step so they can re-answer
|
||||||
|
delete selectedAnswers.value[lessonState.stepIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── FOCUS MODE ────────────────────────────────────────────────────────────
|
// ── FOCUS MODE ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -529,6 +594,7 @@ const focusMode = ref(false);
|
||||||
const currentChunk = ref<{ text: string; start: number; end: number } | null>(null);
|
const currentChunk = ref<{ text: string; start: number; end: number } | null>(null);
|
||||||
const karaokeAudioEl = ref<HTMLAudioElement | null>(null);
|
const karaokeAudioEl = ref<HTMLAudioElement | null>(null);
|
||||||
const questionPlaying = ref(false);
|
const questionPlaying = ref(false);
|
||||||
|
const narratingOptionIndex = ref<number | null>(null);;
|
||||||
const focusEverActivated = ref(false);
|
const focusEverActivated = ref(false);
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
|
|
@ -550,6 +616,7 @@ const showKaraoke = computed(() => {
|
||||||
return !!(ds?.audioPath && ds?.audioChunks && (ds.audioChunks as any[]).length > 0);
|
return !!(ds?.audioPath && ds?.audioChunks && (ds.audioChunks as any[]).length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// kept for compat — no longer drives a full overlay, just used to gate the continue button
|
||||||
const showQuestionOverlay = computed(() =>
|
const showQuestionOverlay = computed(() =>
|
||||||
focusMode.value &&
|
focusMode.value &&
|
||||||
(lessonState.mode === "main" || lessonState.mode === "confirm") &&
|
(lessonState.mode === "main" || lessonState.mode === "confirm") &&
|
||||||
|
|
@ -656,13 +723,8 @@ async function playSequence(urls: string[]): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const questionVariants = ref<Record<number, number>>({});
|
|
||||||
|
|
||||||
function getVariant(stepIndex: number): number {
|
function getVariant(stepIndex: number): number {
|
||||||
if (!questionVariants.value[stepIndex]) {
|
return (Math.abs(stepIndex) % 4) + 1;
|
||||||
questionVariants.value[stepIndex] = Math.floor(Math.random() * 4) + 1;
|
|
||||||
}
|
|
||||||
return questionVariants.value[stepIndex];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playQuestionLike(
|
async function playQuestionLike(
|
||||||
|
|
@ -672,26 +734,31 @@ async function playQuestionLike(
|
||||||
) {
|
) {
|
||||||
stopQueue();
|
stopQueue();
|
||||||
questionPlaying.value = true;
|
questionPlaying.value = true;
|
||||||
|
narratingOptionIndex.value = null;
|
||||||
|
|
||||||
const variant = getVariant(variantKey);
|
const variant = getVariant(variantKey);
|
||||||
const playlist: string[] = [];
|
|
||||||
|
|
||||||
if (questionStep.questionAudioPath) playlist.push(questionStep.questionAudioPath);
|
|
||||||
|
|
||||||
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
|
|
||||||
const label = LABELS[oi];
|
|
||||||
playlist.push(`/audio/labels/${label}_${variant}.mp3`);
|
|
||||||
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
|
|
||||||
const optAudio = questionStep.optionAudioPaths?.[origIdx];
|
|
||||||
if (optAudio) playlist.push(optAudio);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await playSequence(playlist);
|
// play question stem first
|
||||||
|
if (questionStep.questionAudioPath) {
|
||||||
|
await playSequence([questionStep.questionAudioPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// play each option in order, highlighting it while it plays
|
||||||
|
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
|
||||||
|
narratingOptionIndex.value = oi;
|
||||||
|
const label = LABELS[oi];
|
||||||
|
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
|
||||||
|
const optAudio = questionStep.optionAudioPaths?.[origIdx];
|
||||||
|
const clips: string[] = [`/audio/labels/${label}_${variant}.mp3`];
|
||||||
|
if (optAudio) clips.push(optAudio);
|
||||||
|
await playSequence(clips);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// aborted
|
// aborted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
narratingOptionIndex.value = null;
|
||||||
questionPlaying.value = false;
|
questionPlaying.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,10 +771,9 @@ function startStepAudio() {
|
||||||
|
|
||||||
if (s.type === "concept" || s.type === "example" || s.type === "summary") {
|
if (s.type === "concept" || s.type === "example" || s.type === "summary") {
|
||||||
if (!s.audioPath || !s.audioChunks || (s.audioChunks as any[]).length === 0) {
|
if (!s.audioPath || !s.audioChunks || (s.audioChunks as any[]).length === 0) {
|
||||||
// no usable audio for this step — skip it automatically so focus mode stays alive
|
// no usable audio — advance state only, the watcher fires startStepAudio for the new step
|
||||||
if (!isLastMainStep.value) {
|
if (!isLastMainStep.value) {
|
||||||
lessonState.stepIndex++;
|
lessonState.stepIndex++;
|
||||||
nextTick(() => startStepAudio());
|
|
||||||
} else {
|
} else {
|
||||||
completeLesson();
|
completeLesson();
|
||||||
}
|
}
|
||||||
|
|
@ -720,7 +786,7 @@ function startStepAudio() {
|
||||||
startKaraoke(audio, s.audioChunks, () => {
|
startKaraoke(audio, s.audioChunks, () => {
|
||||||
if (!isLastMainStep.value) {
|
if (!isLastMainStep.value) {
|
||||||
lessonState.stepIndex++;
|
lessonState.stepIndex++;
|
||||||
nextTick(() => startStepAudio());
|
// watcher handles startStepAudio for the new step
|
||||||
} else {
|
} else {
|
||||||
completeLesson();
|
completeLesson();
|
||||||
}
|
}
|
||||||
|
|
@ -862,7 +928,7 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<!-- ── TOP BAR ─────────────────────────────────────────────────────────── -->
|
<!-- ── TOP BAR ─────────────────────────────────────────────────────────── -->
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button class="topbar-back" @click="goBack" aria-label="Back">
|
<button class="topbar-back" @click="stepBack" aria-label="Previous step" :disabled="lessonState.stepIndex === 0 && lessonState.mode === 'main'">
|
||||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -892,29 +958,33 @@ onBeforeUnmount(() => {
|
||||||
</svg>
|
</svg>
|
||||||
<span class="focus-label">Focus Mode</span>
|
<span class="focus-label">Focus Mode</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="topbar-exit" @click="goBack" aria-label="Exit lesson" title="Back to course">
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- ── KARAOKE OVERLAY ────────────────────────────────────────────────── -->
|
<!-- ── KARAOKE OVERLAY ────────────────────────────────────────────────── -->
|
||||||
<Transition name="karaoke-fade">
|
<Transition name="karaoke-fade">
|
||||||
<div v-if="showKaraoke" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
|
<div v-if="showKaraoke" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
|
||||||
<p class="karaoke-text">{{ currentChunk?.text ?? '…' }}</p>
|
<template v-if="currentChunk">
|
||||||
|
<p class="karaoke-text">{{ currentChunk.text }}</p>
|
||||||
<button class="karaoke-skip" @click="skipStep" title="Skip">
|
<button class="karaoke-skip" @click="skipStep" title="Skip">
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
</Transition>
|
<template v-else>
|
||||||
|
<button class="karaoke-start" @click="startStepAudio">
|
||||||
<!-- ── QUESTION AUDIO OVERLAY ─────────────────────────────────────────── -->
|
<svg width="22" height="22" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<Transition name="karaoke-fade">
|
<path d="M8 5v14l11-7z"/>
|
||||||
<div v-if="showQuestionOverlay" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
|
|
||||||
<p class="karaoke-text karaoke-text--question">Listen carefully…</p>
|
|
||||||
<button class="karaoke-skip" @click="stopQueue" title="Skip to question">
|
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Start
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
|
@ -956,7 +1026,7 @@ onBeforeUnmount(() => {
|
||||||
<!-- ── MAIN ───────────────────────────────────────────────────────────── -->
|
<!-- ── MAIN ───────────────────────────────────────────────────────────── -->
|
||||||
<main
|
<main
|
||||||
class="lesson-main"
|
class="lesson-main"
|
||||||
v-show="!showKaraoke && !showQuestionOverlay && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'"
|
v-show="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div v-if="lessonPending" class="state-center">
|
<div v-if="lessonPending" class="state-center">
|
||||||
|
|
@ -1002,7 +1072,8 @@ onBeforeUnmount(() => {
|
||||||
:key="opt"
|
:key="opt"
|
||||||
class="option"
|
class="option"
|
||||||
:class="{
|
:class="{
|
||||||
'option--hover-ready': !hasAnswered,
|
'option--hover-ready': !hasAnswered && narratingOptionIndex === null,
|
||||||
|
'option--narrating': !hasAnswered && narratingOptionIndex === i,
|
||||||
'option--correct': hasAnswered && opt === displayStep.answer,
|
'option--correct': hasAnswered && opt === displayStep.answer,
|
||||||
'option--wrong': hasAnswered && selectedAnswer === opt && opt !== displayStep.answer,
|
'option--wrong': hasAnswered && selectedAnswer === opt && opt !== displayStep.answer,
|
||||||
'option--dim': hasAnswered && selectedAnswer !== opt && opt !== displayStep.answer,
|
'option--dim': hasAnswered && selectedAnswer !== opt && opt !== displayStep.answer,
|
||||||
|
|
@ -1077,13 +1148,15 @@ onBeforeUnmount(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!lessonDone"
|
v-if="!lessonDone && !completeError"
|
||||||
class="summary-cta"
|
class="summary-cta"
|
||||||
:disabled="completing"
|
:disabled="completing"
|
||||||
@click="completeLesson"
|
@click="completeLesson"
|
||||||
>
|
>
|
||||||
{{ completing ? 'Saving…' : 'Complete Lesson ✓' }}
|
{{ completing ? 'Saving…' : 'Complete Lesson ✓' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button v-if="completeError" class="summary-cta" @click="completeLesson">Retry ✓</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1092,7 +1165,7 @@ onBeforeUnmount(() => {
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ── CONTINUE FOOTER ───────────────────────────────────────────────── -->
|
<!-- ── CONTINUE FOOTER ───────────────────────────────────────────────── -->
|
||||||
<template v-if="!showKaraoke && !showQuestionOverlay && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'">
|
<template v-if="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'">
|
||||||
|
|
||||||
<!-- non-question, non-summary steps -->
|
<!-- non-question, non-summary steps -->
|
||||||
<div
|
<div
|
||||||
|
|
@ -1201,7 +1274,23 @@ onBeforeUnmount(() => {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.topbar-back:hover { opacity: 1; }
|
.topbar-back:hover:not(:disabled) { opacity: 1; }
|
||||||
|
.topbar-back:disabled { opacity: 0.2; cursor: default; }
|
||||||
|
|
||||||
|
.topbar-exit {
|
||||||
|
color: oklch(20% 0.020 55);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.topbar-exit:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.progress-segments {
|
.progress-segments {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -1485,6 +1574,7 @@ onBeforeUnmount(() => {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.option--hover-ready:hover { border-color: #6366F1; background: #F5F3FF; }
|
.option--hover-ready:hover { border-color: #6366F1; background: #F5F3FF; }
|
||||||
|
.option--narrating { border-color: #6366F1; background: #EEF2FF; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
|
||||||
.option--correct { border-color: #10B981; background: #D1FAE5; animation: spring-correct 0.3s ease-out; }
|
.option--correct { border-color: #10B981; background: #D1FAE5; animation: spring-correct 0.3s ease-out; }
|
||||||
.option--wrong { border-color: #EF4444; background: #FEE2E2; }
|
.option--wrong { border-color: #EF4444; background: #FEE2E2; }
|
||||||
.option--dim { opacity: 0.38; }
|
.option--dim { opacity: 0.38; }
|
||||||
|
|
|
||||||
11
drizzle/0001_hardening.sql
Normal file
11
drizzle/0001_hardening.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
ALTER TABLE `courses` ADD COLUMN `audit_status` text DEFAULT 'pending';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `courses` ADD COLUMN `inference_warning` integer DEFAULT 0;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `topics_course_order_unique` ON `topics` (`course_id`, `order`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `lessons_topic_unique` ON `lessons` (`topic_id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `quiz_questions_topic_question_unique` ON `quiz_questions` (`topic_id`, `question`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `user_progress_course_topic_unique` ON `user_progress` (`course_id`, `topic_id`);
|
||||||
1
drizzle/meta/0001_snapshot.json
Normal file
1
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1777109155026,
|
"when": 1777109155026,
|
||||||
"tag": "0000_init",
|
"tag": "0000_init",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000000,
|
||||||
|
"tag": "0001_hardening",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,17 @@ export default defineNuxtConfig({
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
title: "Revisi.one",
|
title: "Revisi.one",
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
src: "https://cloud.umami.is/script.js",
|
||||||
|
defer: true,
|
||||||
|
"data-website-id": "be63f48d-f9da-4d96-9e3c-f4ddf5aee78d",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
compatibilityDate: "2025-07-15",
|
compatibilityDate: "2025-07-15",
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: process.env.NODE_ENV !== "production" },
|
||||||
|
|
||||||
future: { compatibilityVersion: 4 },
|
future: { compatibilityVersion: 4 },
|
||||||
|
|
||||||
|
|
@ -20,14 +27,26 @@ export default defineNuxtConfig({
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
openrouterApiKey: "",
|
openrouterApiKey: "",
|
||||||
openrouterModel: "deepseek/deepseek-v4-flash",
|
openrouterModel: "deepseek/deepseek-v4-flash",
|
||||||
|
openrouterCurriculumModel: "",
|
||||||
openrouterClassificationModel: "deepseek/deepseek-v4-flash",
|
openrouterClassificationModel: "deepseek/deepseek-v4-flash",
|
||||||
openrouterEvaluatorModel: "deepseek/deepseek-r1",
|
openrouterEvaluatorModel: "deepseek/deepseek-r1",
|
||||||
ttsProvider: "elevenlabs",
|
ttsProvider: "elevenlabs",
|
||||||
elevenlabsApiKey: "",
|
elevenlabsApiKey: "",
|
||||||
elevenlabsVoiceId: "21m00Tcm4TlvDq8ikWAM",
|
|
||||||
fishAudioApiKey: "",
|
fishAudioApiKey: "",
|
||||||
|
|
||||||
|
public: {
|
||||||
|
elevenlabsVoiceId: "21m00Tcm4TlvDq8ikWAM",
|
||||||
fishAudioVoiceId: "",
|
fishAudioVoiceId: "",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
routeRules: {
|
||||||
|
"/**": {
|
||||||
|
headers: {
|
||||||
|
"Content-Security-Policy": "default-src 'self'; media-src 'self' blob:; script-src 'self' 'unsafe-inline' https://cloud.umami.is; connect-src 'self' https://cloud.umami.is; style-src 'self' 'unsafe-inline'; img-src 'self' data:;",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
experimental: {
|
experimental: {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"gen:keypair": "node scripts/gen-keypair.mjs",
|
"gen:keypair": "node scripts/gen-keypair.mjs",
|
||||||
"gen:key": "node scripts/gen-key.mjs"
|
"gen:key": "node scripts/gen-key.mjs"
|
||||||
},
|
},
|
||||||
|
"engines": { "node": ">=20.0.0" },
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
|
|
|
||||||
28
server/api/audio/[...path].get.ts
Normal file
28
server/api/audio/[...path].get.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { createReadStream } from "fs";
|
||||||
|
import { resolve, normalize } from "path";
|
||||||
|
import { access } from "fs/promises";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const pathParam = getRouterParam(event, "path") as string | string[];
|
||||||
|
const pathStr = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
|
||||||
|
|
||||||
|
// prevent path traversal
|
||||||
|
const baseDir = resolve(process.cwd(), "private/audio");
|
||||||
|
const filePath = normalize(resolve(baseDir, pathStr));
|
||||||
|
|
||||||
|
if (!filePath.startsWith(baseDir)) {
|
||||||
|
throw createError({ statusCode: 403, message: "Forbidden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(filePath);
|
||||||
|
} catch {
|
||||||
|
throw createError({ statusCode: 404, message: "Audio not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = filePath.endsWith(".mp3") ? "audio/mpeg" : "audio/mpeg";
|
||||||
|
setHeader(event, "Content-Type", ext);
|
||||||
|
setHeader(event, "Cache-Control", "public, max-age=86400");
|
||||||
|
|
||||||
|
return sendStream(event, createReadStream(filePath));
|
||||||
|
});
|
||||||
|
|
@ -15,7 +15,10 @@ export default defineEventHandler(async (event) => {
|
||||||
if (!body.title.trim()) throw createError({ statusCode: 400, message: "Title cannot be empty" });
|
if (!body.title.trim()) throw createError({ statusCode: 400, message: "Title cannot be empty" });
|
||||||
updates.title = body.title.trim();
|
updates.title = body.title.trim();
|
||||||
}
|
}
|
||||||
if (body.subject !== undefined) updates.subject = body.subject;
|
if (body.subject !== undefined) {
|
||||||
|
if (!body.subject.trim()) throw createError({ statusCode: 400, message: "Subject cannot be empty" });
|
||||||
|
updates.subject = body.subject.trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
throw createError({ statusCode: 400, message: "Nothing to update" });
|
throw createError({ statusCode: 400, message: "Nothing to update" });
|
||||||
|
|
|
||||||
35
server/api/courses/[id]/progress.get.ts
Normal file
35
server/api/courses/[id]/progress.get.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { courses, topics, lessons } from "../../../db/schema";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
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" });
|
||||||
|
|
||||||
|
const topicRows = await db.query.topics.findMany({
|
||||||
|
where: eq(topics.courseId, id),
|
||||||
|
orderBy: (t, { asc }) => asc(t.order),
|
||||||
|
});
|
||||||
|
|
||||||
|
let lessonTopicIds: Set<string> = new Set();
|
||||||
|
|
||||||
|
if (topicRows.length > 0) {
|
||||||
|
const topicIds = topicRows.map((t) => t.id);
|
||||||
|
const lessonRows = await db.query.lessons.findMany({
|
||||||
|
where: inArray(lessons.topicId, topicIds),
|
||||||
|
});
|
||||||
|
lessonTopicIds = new Set(lessonRows.map((l) => l.topicId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: course.status,
|
||||||
|
stage: course.stage,
|
||||||
|
topics: topicRows.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
status: t.status,
|
||||||
|
hasLesson: lessonTopicIds.has(t.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -7,9 +7,15 @@ import { resolve } from "path";
|
||||||
import { parsePdf } from "../../../utils/parsePdf";
|
import { parsePdf } from "../../../utils/parsePdf";
|
||||||
import { detectUploadType } from "../../../utils/detectUploadType";
|
import { detectUploadType } from "../../../utils/detectUploadType";
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const id = getRouterParam(event, "id")!;
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
if (!UUID_RE.test(id)) {
|
||||||
|
throw createError({ statusCode: 400, message: "Invalid course id" });
|
||||||
|
}
|
||||||
|
|
||||||
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
|
||||||
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
||||||
|
|
||||||
|
|
@ -20,21 +26,40 @@ export default defineEventHandler(async (event) => {
|
||||||
throw createError({ statusCode: 400, message: "file is required" });
|
throw createError({ statusCode: 400, message: "file is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 50mb limit
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
throw createError({ statusCode: 400, message: "File exceeds 50MB limit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// check pdf magic bytes: %PDF = 0x25 0x50 0x44 0x46
|
||||||
|
if (buffer[0] !== 0x25 || buffer[1] !== 0x50 || buffer[2] !== 0x44 || buffer[3] !== 0x46) {
|
||||||
|
throw createError({ statusCode: 400, message: "File does not appear to be a valid PDF" });
|
||||||
|
}
|
||||||
|
|
||||||
const uploadDir = resolve(process.cwd(), "uploads", id);
|
const uploadDir = resolve(process.cwd(), "uploads", id);
|
||||||
await mkdir(uploadDir, { recursive: true });
|
await mkdir(uploadDir, { recursive: true });
|
||||||
|
|
||||||
const uploadId = randomUUID();
|
const uploadId = randomUUID();
|
||||||
const safeFilename = `${uploadId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
const safeFilename = `${uploadId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
||||||
const storedPath = resolve(uploadDir, safeFilename);
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
// safeFilename shouldnt be just dots/underscores
|
||||||
|
if (/^[._]+$/.test(safeFilename.replace(uploadId + "-", ""))) {
|
||||||
|
throw createError({ statusCode: 400, message: "Invalid filename" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedPath = resolve(uploadDir, safeFilename);
|
||||||
await writeFile(storedPath, buffer);
|
await writeFile(storedPath, buffer);
|
||||||
|
|
||||||
let extractedText: string | null = null;
|
let extractedText: string | null = null;
|
||||||
|
let pdfWarning: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
extractedText = await parsePdf(buffer);
|
extractedText = await parsePdf(buffer);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
// non-fatal
|
console.error(`[upload] PDF text extraction failed for file size ${buffer.length}: ${err?.message ?? err}`);
|
||||||
|
pdfWarning = "PDF text extraction failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
const detectedType = await detectUploadType(file.name, extractedText ?? "");
|
const detectedType = await detectUploadType(file.name, extractedText ?? "");
|
||||||
|
|
@ -48,5 +73,9 @@ export default defineEventHandler(async (event) => {
|
||||||
extractedText,
|
extractedText,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pdfWarning) {
|
||||||
|
return { uploadId, filename: file.name, type: detectedType, warning: pdfWarning };
|
||||||
|
}
|
||||||
|
|
||||||
return { uploadId, filename: file.name, type: detectedType };
|
return { uploadId, filename: file.name, type: detectedType };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,37 @@
|
||||||
import { db } from "../../db/index";
|
import { db } from "../../db/index";
|
||||||
import { courses, topics, userProgress } from "../../db/schema";
|
import { courses, topics, userProgress } from "../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
const allCourses = await db.query.courses.findMany({
|
const allCourses = await db.query.courses.findMany({
|
||||||
orderBy: (c, { desc }) => desc(c.createdAt),
|
orderBy: (c, { desc }) => desc(c.createdAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = [];
|
if (allCourses.length === 0) return [];
|
||||||
|
|
||||||
for (const course of allCourses) {
|
const courseIds = allCourses.map((c) => c.id);
|
||||||
const topicRows = await db.query.topics.findMany({
|
|
||||||
where: eq(topics.courseId, course.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const topicCount = topicRows.length;
|
const [allTopics, allProgress] = await Promise.all([
|
||||||
|
db.query.topics.findMany({ where: inArray(topics.courseId, courseIds) }),
|
||||||
|
db.query.userProgress.findMany({ where: inArray(userProgress.courseId, courseIds) }),
|
||||||
|
]);
|
||||||
|
|
||||||
let completedCount = 0;
|
// group in memory
|
||||||
if (topicCount > 0) {
|
const topicsByCourse = new Map<string, number>();
|
||||||
const progressRows = await db.query.userProgress.findMany({
|
for (const t of allTopics) {
|
||||||
where: eq(userProgress.courseId, course.id),
|
topicsByCourse.set(t.courseId, (topicsByCourse.get(t.courseId) ?? 0) + 1);
|
||||||
});
|
|
||||||
completedCount = progressRows.filter((p) => p.lessonComplete).length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
const completedByCourse = new Map<string, number>();
|
||||||
|
for (const p of allProgress) {
|
||||||
|
if (p.lessonComplete) {
|
||||||
|
completedByCourse.set(p.courseId, (completedByCourse.get(p.courseId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCourses.map((course) => ({
|
||||||
...course,
|
...course,
|
||||||
topicCount,
|
topicCount: topicsByCourse.get(course.id) ?? 0,
|
||||||
completedCount,
|
completedCount: completedByCourse.get(course.id) ?? 0,
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
32
server/api/courses/status.get.ts
Normal file
32
server/api/courses/status.get.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { courses, topics } from "../../db/schema";
|
||||||
|
import { inArray, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const allCourses = await db.query.courses.findMany();
|
||||||
|
|
||||||
|
if (allCourses.length === 0) return [];
|
||||||
|
|
||||||
|
const courseIds = allCourses.map((c) => c.id);
|
||||||
|
const allTopics = await db.query.topics.findMany({
|
||||||
|
where: inArray(topics.courseId, courseIds),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topicCountByCourse = new Map<string, number>();
|
||||||
|
const readyCountByCourse = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const t of allTopics) {
|
||||||
|
topicCountByCourse.set(t.courseId, (topicCountByCourse.get(t.courseId) ?? 0) + 1);
|
||||||
|
if (t.status === "ready") {
|
||||||
|
readyCountByCourse.set(t.courseId, (readyCountByCourse.get(t.courseId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCourses.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
status: c.status,
|
||||||
|
stage: c.stage,
|
||||||
|
topicCount: topicCountByCourse.get(c.id) ?? 0,
|
||||||
|
readyTopicCount: readyCountByCourse.get(c.id) ?? 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
22
server/api/health.get.ts
Normal file
22
server/api/health.get.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { db } from "../db/index";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
let dbOk = false;
|
||||||
|
try {
|
||||||
|
await db.run(sql`SELECT 1`);
|
||||||
|
dbOk = true;
|
||||||
|
} catch {
|
||||||
|
// db is down
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const envOk = !!(config.openrouterApiKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
db: dbOk,
|
||||||
|
env: envOk,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
15
server/api/topics/[id]/branch-status.get.ts
Normal file
15
server/api/topics/[id]/branch-status.get.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { topics, lessons } from "../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
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" });
|
||||||
|
|
||||||
|
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, id) });
|
||||||
|
if (!lesson) throw createError({ statusCode: 404, message: "No lesson for this topic" });
|
||||||
|
|
||||||
|
return { branchStatus: lesson.branchStatus };
|
||||||
|
});
|
||||||
|
|
@ -29,7 +29,7 @@ export default defineEventHandler(async (event) => {
|
||||||
eq(topics.status, "pending")
|
eq(topics.status, "pending")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
if (nextTopic) generateLesson(nextTopic.id);
|
if (nextTopic) generateLesson(nextTopic.id).catch((e) => console.error("[pre-gen]", e));
|
||||||
|
|
||||||
return { status: "ready" };
|
return { status: "ready" };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { db } from "../../../db/index";
|
import { db } from "../../../db/index";
|
||||||
import { topics, userProgress } from "../../../db/schema";
|
import { topics, userProgress } from "../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -12,26 +12,25 @@ export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { lessonComplete, quizScore, tookBranches, branchCount } = body ?? {};
|
const { lessonComplete, quizScore, tookBranches, branchCount } = body ?? {};
|
||||||
|
|
||||||
const existing = await db.query.userProgress.findFirst({
|
// manual validation — no zod
|
||||||
where: and(
|
if (lessonComplete !== undefined && typeof lessonComplete !== "boolean") {
|
||||||
eq(userProgress.topicId, id),
|
throw createError({ statusCode: 400, message: "lessonComplete must be a boolean" });
|
||||||
eq(userProgress.courseId, topic.courseId)
|
}
|
||||||
),
|
if (quizScore !== undefined && quizScore !== null) {
|
||||||
});
|
if (!Number.isInteger(quizScore)) throw createError({ statusCode: 400, message: "quizScore must be an integer" });
|
||||||
|
}
|
||||||
|
if (tookBranches !== undefined && typeof tookBranches !== "boolean") {
|
||||||
|
throw createError({ statusCode: 400, message: "tookBranches must be a boolean" });
|
||||||
|
}
|
||||||
|
if (branchCount !== undefined && !Number.isInteger(branchCount)) {
|
||||||
|
throw createError({ statusCode: 400, message: "branchCount must be an integer" });
|
||||||
|
}
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
|
// NOTE: this requires a UNIQUE constraint on (course_id, topic_id) in user_progress — the migration adds this
|
||||||
await db
|
await db
|
||||||
.update(userProgress)
|
.insert(userProgress)
|
||||||
.set({
|
.values({
|
||||||
lessonComplete: lessonComplete ?? existing.lessonComplete,
|
|
||||||
quizScore: quizScore ?? existing.quizScore,
|
|
||||||
tookBranches: tookBranches ?? existing.tookBranches,
|
|
||||||
branchCount: branchCount ?? existing.branchCount,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.where(eq(userProgress.id, existing.id));
|
|
||||||
} else {
|
|
||||||
await db.insert(userProgress).values({
|
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
courseId: topic.courseId,
|
courseId: topic.courseId,
|
||||||
topicId: id,
|
topicId: id,
|
||||||
|
|
@ -39,8 +38,18 @@ export default defineEventHandler(async (event) => {
|
||||||
quizScore: quizScore ?? null,
|
quizScore: quizScore ?? null,
|
||||||
tookBranches: tookBranches ?? false,
|
tookBranches: tookBranches ?? false,
|
||||||
branchCount: branchCount ?? 0,
|
branchCount: branchCount ?? 0,
|
||||||
|
updatedAt: sql`datetime('now')`,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userProgress.courseId, userProgress.topicId],
|
||||||
|
set: {
|
||||||
|
lessonComplete: lessonComplete ?? sql`excluded.lesson_complete`,
|
||||||
|
quizScore: quizScore !== undefined ? quizScore : sql`excluded.quiz_score`,
|
||||||
|
tookBranches: tookBranches ?? sql`excluded.took_branches`,
|
||||||
|
branchCount: branchCount ?? sql`excluded.branch_count`,
|
||||||
|
updatedAt: sql`datetime('now')`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,6 @@ const dbPath = process.env.DATABASE_PATH || resolve(process.cwd(), "revisione.db
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
sqlite.pragma("foreign_keys = ON");
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
sqlite.pragma("wal_autocheckpoint = 1000");
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ export const courses = sqliteTable("courses", {
|
||||||
costAudio: real("cost_audio").default(0),
|
costAudio: real("cost_audio").default(0),
|
||||||
auditReport: text("audit_report"),
|
auditReport: text("audit_report"),
|
||||||
auditScore: integer("audit_score"),
|
auditScore: integer("audit_score"),
|
||||||
|
auditStatus: text("audit_status", { enum: ["pending", "running", "complete", "error"] }).default("pending"),
|
||||||
|
inferenceWarning: integer("inference_warning", { mode: "boolean" }).default(false),
|
||||||
organisation: text("organisation"),
|
organisation: text("organisation"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|
|
||||||
20
server/plugins/00.validateEnv.ts
Normal file
20
server/plugins/00.validateEnv.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
export default defineNitroPlugin(() => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
if (!config.openrouterApiKey) missing.push("NUXT_OPENROUTER_API_KEY");
|
||||||
|
|
||||||
|
const provider = (config.ttsProvider as string || "elevenlabs").toLowerCase();
|
||||||
|
if (provider === "fishaudio") {
|
||||||
|
if (!config.fishAudioApiKey) missing.push("NUXT_FISH_AUDIO_API_KEY");
|
||||||
|
} else {
|
||||||
|
if (!config.elevenlabsApiKey) missing.push("NUXT_ELEVENLABS_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error("[revisione] Missing required env vars:", missing.join(", "));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[revisione] env validation passed");
|
||||||
|
});
|
||||||
9
server/plugins/errorHandler.ts
Normal file
9
server/plugins/errorHandler.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default defineNitroPlugin((nitro) => {
|
||||||
|
nitro.hooks.hook("error", async (error, { event }) => {
|
||||||
|
// only log server-side, never expose internals to client
|
||||||
|
const status = (error as any)?.statusCode ?? 500;
|
||||||
|
if (status >= 500) {
|
||||||
|
console.error("[revisione] unhandled error:", error?.message ?? error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,32 @@
|
||||||
import { db } from "../db/index";
|
import { db } from "../db/index";
|
||||||
import { courses } from "../db/schema";
|
import { courses, topics, lessons } from "../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { generateCourseInBackground } from "../utils/generateCourse";
|
import { generateCourseInBackground } from "../utils/generateCourse";
|
||||||
|
|
||||||
export default defineNitroPlugin(async () => {
|
export default defineNitroPlugin(async () => {
|
||||||
console.log("[revisione] resumeGeneration plugin started");
|
console.log("[revisione] resumeGeneration plugin started");
|
||||||
|
|
||||||
|
// reset any stuck generating states from a previous run
|
||||||
|
try {
|
||||||
|
const stuckTopics = await db
|
||||||
|
.update(topics)
|
||||||
|
.set({ status: "pending" })
|
||||||
|
.where(eq(topics.status, "generating"))
|
||||||
|
.returning({ id: topics.id });
|
||||||
|
|
||||||
|
const stuckLessons = await db
|
||||||
|
.update(lessons)
|
||||||
|
.set({ branchStatus: "pending" })
|
||||||
|
.where(eq(lessons.branchStatus, "generating"))
|
||||||
|
.returning({ id: lessons.id });
|
||||||
|
|
||||||
|
if (stuckTopics.length > 0 || stuckLessons.length > 0) {
|
||||||
|
console.log(`[revisione] reset ${stuckTopics.length} stuck topic(s) and ${stuckLessons.length} stuck lesson(s) to pending`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[revisione] failed to reset stuck states:", err?.message ?? err);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stuck = await db.query.courses.findMany({
|
const stuck = await db.query.courses.findMany({
|
||||||
where: eq(courses.status, "processing"),
|
where: eq(courses.status, "processing"),
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import { eq } from "drizzle-orm";
|
||||||
import { askAI } from "./openrouter";
|
import { askAI } from "./openrouter";
|
||||||
|
|
||||||
function parseJSON<T>(raw: string): T {
|
function parseJSON<T>(raw: string): T {
|
||||||
|
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
const cleaned = raw.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
const cleaned = text.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
||||||
return JSON.parse(cleaned);
|
return JSON.parse(cleaned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ function log(lessonId: string, msg: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJSON<T>(raw: string): T {
|
function parseJSON<T>(raw: string): T {
|
||||||
|
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
const cleaned = raw.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
const cleaned = text.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
||||||
return JSON.parse(cleaned);
|
return JSON.parse(cleaned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +54,8 @@ export async function generateBranches(topicId: string, lessonId: string): Promi
|
||||||
let costBranchAI = 0;
|
let costBranchAI = 0;
|
||||||
let costBranchAudio = 0;
|
let costBranchAudio = 0;
|
||||||
|
|
||||||
let branchesChanged = false;
|
let branchErrors = 0;
|
||||||
|
let branchSuccesses = 0;
|
||||||
|
|
||||||
for (let si = 0; si < lessonContent.steps.length; si++) {
|
for (let si = 0; si < lessonContent.steps.length; si++) {
|
||||||
const step = lessonContent.steps[si] as any;
|
const step = lessonContent.steps[si] as any;
|
||||||
|
|
@ -119,11 +121,15 @@ Only generate branches for the 3 wrong options. Do not generate a branch for the
|
||||||
|
|
||||||
const parsed = parseJSON<{ branches: Record<string, any> }>(branchResult.text);
|
const parsed = parseJSON<{ branches: Record<string, any> }>(branchResult.text);
|
||||||
step.branches = parsed.branches ?? {};
|
step.branches = parsed.branches ?? {};
|
||||||
branchesChanged = true;
|
|
||||||
|
|
||||||
log(lessonId, ` step ${si} branches generated — ${Object.keys(step.branches).length} wrong options`);
|
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);
|
const wrongOptions = (step.options as string[]).filter((o: string) => o !== step.answer);
|
||||||
|
|
||||||
|
// batch TTS for all branches in this step, 4 at a time
|
||||||
|
type TTSTask = () => Promise<void>;
|
||||||
|
const ttsTasks: TTSTask[] = [];
|
||||||
|
|
||||||
for (let bi = 0; bi < wrongOptions.length; bi++) {
|
for (let bi = 0; bi < wrongOptions.length; bi++) {
|
||||||
const wrongOpt = wrongOptions[bi];
|
const wrongOpt = wrongOptions[bi];
|
||||||
const branch = step.branches[wrongOpt];
|
const branch = step.branches[wrongOpt];
|
||||||
|
|
@ -133,53 +139,79 @@ Only generate branches for the 3 wrong options. Do not generate a branch for the
|
||||||
const bStep = branch.steps[bsi];
|
const bStep = branch.steps[bsi];
|
||||||
const text = [bStep.body, bStep.callout].filter(Boolean).join(" ");
|
const text = [bStep.body, bStep.callout].filter(Boolean).join(" ");
|
||||||
if (!text.trim()) continue;
|
if (!text.trim()) continue;
|
||||||
const r = await generateTTSToPath(text, lessonId, `branch_${si}_${bi}_step_${bsi}.mp3`);
|
|
||||||
|
const filename = `branch_${si}_${bi}_step_${bsi}.mp3`;
|
||||||
|
ttsTasks.push(async () => {
|
||||||
|
const r = await generateTTSToPath(text, lessonId, filename);
|
||||||
if (r) {
|
if (r) {
|
||||||
bStep.audioPath = r.audioPath;
|
bStep.audioPath = r.audioPath;
|
||||||
bStep.audioChunks = r.audioChunks;
|
bStep.audioChunks = r.audioChunks;
|
||||||
costBranchAudio += r.cost;
|
costBranchAudio += r.cost;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (branch.confirmQuestion?.body?.trim()) {
|
if (branch.confirmQuestion?.body?.trim()) {
|
||||||
const r = await generateTTSToPath(branch.confirmQuestion.body, lessonId, `branch_${si}_${bi}_confirm_q.mp3`);
|
const cqBody = branch.confirmQuestion.body;
|
||||||
|
const cqFile = `branch_${si}_${bi}_confirm_q.mp3`;
|
||||||
|
ttsTasks.push(async () => {
|
||||||
|
const r = await generateTTSToPath(cqBody, lessonId, cqFile);
|
||||||
if (r) {
|
if (r) {
|
||||||
branch.confirmQuestion.questionAudioPath = r.audioPath;
|
branch.confirmQuestion.questionAudioPath = r.audioPath;
|
||||||
branch.confirmQuestion.questionAudioChunks = r.audioChunks;
|
branch.confirmQuestion.questionAudioChunks = r.audioChunks;
|
||||||
costBranchAudio += r.cost;
|
costBranchAudio += r.cost;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(branch.confirmQuestion?.options)) {
|
if (Array.isArray(branch.confirmQuestion?.options)) {
|
||||||
branch.confirmQuestion.optionAudioPaths = [];
|
branch.confirmQuestion.optionAudioPaths = new Array(branch.confirmQuestion.options.length).fill(null);
|
||||||
|
|
||||||
for (let oi = 0; oi < branch.confirmQuestion.options.length; oi++) {
|
for (let oi = 0; oi < branch.confirmQuestion.options.length; oi++) {
|
||||||
const optText = branch.confirmQuestion.options[oi];
|
const optText = branch.confirmQuestion.options[oi];
|
||||||
|
const oiCopy = oi;
|
||||||
|
const optFile = `branch_${si}_${bi}_confirm_opt_${oi}.mp3`;
|
||||||
|
|
||||||
if (optText?.trim()) {
|
if (optText?.trim()) {
|
||||||
const r = await generateTTSToPath(optText, lessonId, `branch_${si}_${bi}_confirm_opt_${oi}.mp3`);
|
ttsTasks.push(async () => {
|
||||||
branch.confirmQuestion.optionAudioPaths[oi] = r ? r.audioPath : null;
|
const r = await generateTTSToPath(optText, lessonId, optFile);
|
||||||
|
branch.confirmQuestion.optionAudioPaths[oiCopy] = r ? r.audioPath : null;
|
||||||
if (r) costBranchAudio += r.cost;
|
if (r) costBranchAudio += r.cost;
|
||||||
} else {
|
});
|
||||||
branch.confirmQuestion.optionAudioPaths[oi] = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (branch.hardStop?.trim()) {
|
if (branch.hardStop?.trim()) {
|
||||||
const r = await generateTTSToPath(branch.hardStop, lessonId, `branch_${si}_${bi}_hardstop.mp3`);
|
const hsText = branch.hardStop;
|
||||||
|
const hsFile = `branch_${si}_${bi}_hardstop.mp3`;
|
||||||
|
ttsTasks.push(async () => {
|
||||||
|
const r = await generateTTSToPath(hsText, lessonId, hsFile);
|
||||||
if (r) {
|
if (r) {
|
||||||
branch.hardStopAudioPath = r.audioPath;
|
branch.hardStopAudioPath = r.audioPath;
|
||||||
costBranchAudio += r.cost;
|
costBranchAudio += r.cost;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log(lessonId, ` step ${si} branch ${bi} TTS done`);
|
const BATCH = 4;
|
||||||
|
for (let i = 0; i < ttsTasks.length; i += BATCH) {
|
||||||
|
await Promise.all(ttsTasks.slice(i, i + BATCH).map((fn) => fn()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log(lessonId, ` step ${si} branch TTS done`);
|
||||||
|
branchSuccesses++;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[branches] step ${si} failed for lesson ${lessonId}: ${err?.message ?? err}`);
|
console.error(`[branches] step ${si} failed for lesson ${lessonId}: ${err?.message ?? err}`);
|
||||||
|
branchErrors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (branchesChanged) {
|
const totalQuestionSteps = lessonContent.steps.filter((s: any) => s.type === "question").length;
|
||||||
|
const branchStatus = branchErrors > 0 ? "error" : "ready";
|
||||||
|
|
||||||
|
if (branchSuccesses > 0 || totalQuestionSteps === 0) {
|
||||||
const existing = await db.query.lessons.findFirst({ where: eq(lessons.id, lessonId) });
|
const existing = await db.query.lessons.findFirst({ where: eq(lessons.id, lessonId) });
|
||||||
const prevCostAI = existing?.costAI ?? 0;
|
const prevCostAI = existing?.costAI ?? 0;
|
||||||
const prevCostAudio = existing?.costAudio ?? 0;
|
const prevCostAudio = existing?.costAudio ?? 0;
|
||||||
|
|
@ -190,14 +222,23 @@ Only generate branches for the 3 wrong options. Do not generate a branch for the
|
||||||
costBranchAI,
|
costBranchAI,
|
||||||
costBranchAudio,
|
costBranchAudio,
|
||||||
costTotal: prevCostAI + prevCostAudio + costBranchAI + costBranchAudio,
|
costTotal: prevCostAI + prevCostAudio + costBranchAI + costBranchAudio,
|
||||||
branchStatus: "ready",
|
branchStatus,
|
||||||
})
|
})
|
||||||
.where(eq(lessons.id, lessonId));
|
.where(eq(lessons.id, lessonId));
|
||||||
|
|
||||||
log(lessonId, `✓ branches ready — AI $${costBranchAI.toFixed(4)}, audio $${costBranchAudio.toFixed(4)}`);
|
if (branchErrors > 0) {
|
||||||
|
log(lessonId, `branches done with errors — ${branchSuccesses} ok, ${branchErrors} failed`);
|
||||||
} else {
|
} else {
|
||||||
await db.update(lessons).set({ 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 }).where(eq(lessons.id, lessonId));
|
||||||
|
|
||||||
|
if (totalQuestionSteps === 0) {
|
||||||
log(lessonId, "no question steps found, branch_status set to ready");
|
log(lessonId, "no question steps found, branch_status set to ready");
|
||||||
|
} else {
|
||||||
|
log(lessonId, "all branch steps failed, branch_status set to error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[branches:${lessonId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
|
console.error(`[branches:${lessonId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { randomUUID } from "crypto";
|
||||||
import { askAI } from "./openrouter";
|
import { askAI } from "./openrouter";
|
||||||
import { auditCourse } from "./auditCourse";
|
import { auditCourse } from "./auditCourse";
|
||||||
|
|
||||||
|
const inFlightCourses = new Set<string>();
|
||||||
|
|
||||||
type Stage = "parsing_pdfs" | "analysing_sources" | "building_curriculum" | "finalising" | "ready" | "error";
|
type Stage = "parsing_pdfs" | "analysing_sources" | "building_curriculum" | "finalising" | "ready" | "error";
|
||||||
|
|
||||||
function log(courseId: string, msg: string) {
|
function log(courseId: string, msg: string) {
|
||||||
|
|
@ -18,15 +20,22 @@ async function setStage(courseId: string, stage: Stage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJSON<T>(raw: string): T {
|
function parseJSON<T>(raw: string): T {
|
||||||
|
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
const cleaned = raw.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
const cleaned = text.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
||||||
return JSON.parse(cleaned);
|
return JSON.parse(cleaned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCourseInBackground(courseId: string) {
|
export async function generateCourseInBackground(courseId: string) {
|
||||||
|
if (inFlightCourses.has(courseId)) {
|
||||||
|
log(courseId, "already in flight, skipping duplicate call");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inFlightCourses.add(courseId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const course = await db.query.courses.findFirst({ where: eq(courses.id, courseId) });
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, courseId) });
|
||||||
if (!course) throw new Error(`Course ${courseId} not found`);
|
if (!course) throw new Error(`Course ${courseId} not found`);
|
||||||
|
|
@ -69,10 +78,7 @@ export async function generateCourseInBackground(courseId: string) {
|
||||||
// ── STEP 1b — infer title, subject, organisation ───────────────────────
|
// ── STEP 1b — infer title, subject, organisation ───────────────────────
|
||||||
await setStage(courseId, "analysing_sources");
|
await setStage(courseId, "analysing_sources");
|
||||||
|
|
||||||
const allExtracted = [
|
const allExtracted = [primaryParts.join("\n\n"), secondaryParts.join("\n\n")].join("\n\n");
|
||||||
...primaryParts.join("\n\n"),
|
|
||||||
...secondaryParts.join("\n\n"),
|
|
||||||
].join("\n\n");
|
|
||||||
|
|
||||||
log(courseId, "inferring course title and subject from documents…");
|
log(courseId, "inferring course title and subject from documents…");
|
||||||
|
|
||||||
|
|
@ -96,10 +102,36 @@ ${allExtracted}`,
|
||||||
title: course.title,
|
title: course.title,
|
||||||
subject: course.subject,
|
subject: course.subject,
|
||||||
};
|
};
|
||||||
|
let inferenceWarning = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
inferredMeta = parseJSON(inferenceResult.text);
|
inferredMeta = parseJSON(inferenceResult.text);
|
||||||
} catch {
|
} catch {
|
||||||
log(courseId, "inference parse failed, using defaults");
|
log(courseId, `inference parse failed on first attempt, raw text: ${inferenceResult.text}`);
|
||||||
|
|
||||||
|
// retry once
|
||||||
|
try {
|
||||||
|
const retryResult = 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 += retryResult.cost;
|
||||||
|
inferredMeta = parseJSON(retryResult.text);
|
||||||
|
log(courseId, "inference retry succeeded");
|
||||||
|
} catch (retryErr: any) {
|
||||||
|
log(courseId, `inference retry also failed: ${retryErr?.message ?? retryErr} — using defaults`);
|
||||||
|
inferenceWarning = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log(courseId, `inferred → title: "${inferredMeta.title}", subject: "${inferredMeta.subject}"`);
|
log(courseId, `inferred → title: "${inferredMeta.title}", subject: "${inferredMeta.subject}"`);
|
||||||
|
|
@ -109,6 +141,7 @@ ${allExtracted}`,
|
||||||
title: inferredMeta.title,
|
title: inferredMeta.title,
|
||||||
subject: inferredMeta.subject,
|
subject: inferredMeta.subject,
|
||||||
...(inferredMeta.organisation != null ? { organisation: inferredMeta.organisation } : {}),
|
...(inferredMeta.organisation != null ? { organisation: inferredMeta.organisation } : {}),
|
||||||
|
...(inferenceWarning ? { inferenceWarning: true } : {}),
|
||||||
})
|
})
|
||||||
.where(eq(courses.id, courseId));
|
.where(eq(courses.id, courseId));
|
||||||
|
|
||||||
|
|
@ -119,12 +152,15 @@ ${allExtracted}`,
|
||||||
orderBy: (t, { asc }) => asc(t.order),
|
orderBy: (t, { asc }) => asc(t.order),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (savedTopics.length > 0) {
|
// only skip curriculum if we have topics AND we've moved past building_curriculum
|
||||||
|
if (savedTopics.length > 0 && course.stage !== "building_curriculum") {
|
||||||
log(courseId, `resuming — found ${savedTopics.length} existing topic(s), skipping curriculum generation`);
|
log(courseId, `resuming — found ${savedTopics.length} existing topic(s), skipping curriculum generation`);
|
||||||
} else {
|
} else {
|
||||||
const primaryText = primaryParts.join("\n\n");
|
const primaryText = primaryParts.join("\n\n");
|
||||||
const secondaryText = secondaryParts.join("\n\n");
|
const secondaryText = secondaryParts.join("\n\n");
|
||||||
|
|
||||||
|
const knownFilenames = new Set(uploadRows.map((u) => u.filename));
|
||||||
|
|
||||||
const availableFilesBlock = uploadRows
|
const availableFilesBlock = uploadRows
|
||||||
.map((u) => `- ${u.filename} (${u.type})`)
|
.map((u) => `- ${u.filename} (${u.type})`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
@ -168,23 +204,55 @@ relevantFiles must list only filenames from the AVAILABLE SOURCE FILES list that
|
||||||
The description must be specific about what the student will be able to DO after completing this topic, not just what it covers.`;
|
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");
|
await setStage(courseId, "building_curriculum");
|
||||||
log(courseId, "calling OpenRouter for curriculum…");
|
const curriculumModel = (useRuntimeConfig() as any).openrouterCurriculumModel || undefined;
|
||||||
const curriculumResult = await askAI([{ role: "user", content: curriculumPrompt }]);
|
log(courseId, `calling OpenRouter for curriculum${curriculumModel ? ` (model: ${curriculumModel})` : ""}…`);
|
||||||
|
|
||||||
|
let curriculumResult = await askAI([{ role: "user", content: curriculumPrompt }], { model: curriculumModel });
|
||||||
costs.ai += curriculumResult.cost;
|
costs.ai += curriculumResult.cost;
|
||||||
const curriculum = parseJSON<{ title: string; description: string; difficulty: number; relevantFiles?: string[] }[]>(curriculumResult.text);
|
let curriculum = parseJSON<{ title: string; description: string; difficulty: number; relevantFiles?: string[] }[]>(curriculumResult.text);
|
||||||
|
|
||||||
if (!Array.isArray(curriculum) || curriculum.length === 0) {
|
if (!Array.isArray(curriculum) || curriculum.length === 0) {
|
||||||
throw new Error("AI returned an empty curriculum");
|
throw new Error("AI returned an empty curriculum");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check for blank required fields, retry once if any found
|
||||||
|
const hasBlankFields = curriculum.some((t) => !t.title?.trim() || !t.description?.trim());
|
||||||
|
if (hasBlankFields) {
|
||||||
|
log(courseId, "some topics had blank title/description, retrying curriculum generation…");
|
||||||
|
const retryResult = await askAI([{ role: "user", content: curriculumPrompt }]);
|
||||||
|
costs.ai += retryResult.cost;
|
||||||
|
const retryCurriculum = parseJSON<typeof curriculum>(retryResult.text);
|
||||||
|
|
||||||
|
if (Array.isArray(retryCurriculum) && retryCurriculum.length > 0) {
|
||||||
|
curriculum = retryCurriculum;
|
||||||
|
} else {
|
||||||
|
log(courseId, "retry also had issues, proceeding with original curriculum");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log(courseId, `curriculum received — ${curriculum.length} topics:`);
|
log(courseId, `curriculum received — ${curriculum.length} topics:`);
|
||||||
curriculum.forEach((t, i) => log(courseId, ` ${i + 1}. ${t.title} (difficulty ${t.difficulty})`));
|
curriculum.forEach((t, i) => log(courseId, ` ${i + 1}. ${t.title} (difficulty ${t.difficulty})`));
|
||||||
|
|
||||||
await setStage(courseId, "finalising");
|
await setStage(courseId, "finalising");
|
||||||
|
|
||||||
|
// better-sqlite3 transactions are synchronous — build rows first, then insert
|
||||||
|
const topicRows: any[] = [];
|
||||||
for (let i = 0; i < curriculum.length; i++) {
|
for (let i = 0; i < curriculum.length; i++) {
|
||||||
const t = curriculum[i];
|
const t = curriculum[i];
|
||||||
await db.insert(topics).values({
|
|
||||||
|
if (!t.title?.trim() || !t.description?.trim()) {
|
||||||
|
log(courseId, ` skipping topic ${i + 1} — missing title or description`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFiles = t.relevantFiles ?? [];
|
||||||
|
const validFiles = rawFiles.filter((f: string) => {
|
||||||
|
const ok = knownFilenames.has(f);
|
||||||
|
if (!ok) log(courseId, ` topic "${t.title}" — hallucinated filename filtered out: "${f}"`);
|
||||||
|
return ok;
|
||||||
|
});
|
||||||
|
|
||||||
|
topicRows.push({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
courseId,
|
courseId,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
|
|
@ -192,11 +260,15 @@ The description must be specific about what the student will be able to DO after
|
||||||
order: i,
|
order: i,
|
||||||
difficulty: Math.min(5, Math.max(1, t.difficulty ?? 1)),
|
difficulty: Math.min(5, Math.max(1, t.difficulty ?? 1)),
|
||||||
prerequisiteTopicIds: "[]",
|
prerequisiteTopicIds: "[]",
|
||||||
relevantFiles: JSON.stringify(t.relevantFiles ?? []),
|
relevantFiles: JSON.stringify(validFiles),
|
||||||
status: "pending",
|
status: "pending",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const row of topicRows) {
|
||||||
|
await db.insert(topics).values(row);
|
||||||
|
}
|
||||||
|
|
||||||
savedTopics = await db.query.topics.findMany({
|
savedTopics = await db.query.topics.findMany({
|
||||||
where: eq(topics.courseId, courseId),
|
where: eq(topics.courseId, courseId),
|
||||||
orderBy: (t, { asc }) => asc(t.order),
|
orderBy: (t, { asc }) => asc(t.order),
|
||||||
|
|
@ -217,5 +289,7 @@ The description must be specific about what the student will be able to DO after
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[revisione:${courseId.slice(0, 8)}] ✗ generation failed: ${err?.message ?? err}`);
|
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));
|
await db.update(courses).set({ status: "error", stage: "error" }).where(eq(courses.id, courseId));
|
||||||
|
} finally {
|
||||||
|
inFlightCourses.delete(courseId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
import { db } from "../db/index";
|
import { db } from "../db/index";
|
||||||
import { courses, uploads, topics, lessons, quizQuestions } from "../db/schema";
|
import { courses, uploads, topics, lessons, quizQuestions } from "../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray, and } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { askAI } from "./openrouter";
|
import { askAI } from "./openrouter";
|
||||||
import { generateStepTTS, generateQuestionTTS, generateOptionTTS } from "./generateTTS";
|
import { generateStepTTS, generateQuestionTTS, generateOptionTTS } from "./generateTTS";
|
||||||
import { generateBranches } from "./generateBranches";
|
import { generateBranches } from "./generateBranches";
|
||||||
|
|
||||||
|
// one promise chain per topic so we don't double-generate
|
||||||
|
const topicMutexes = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
function log(topicId: string, msg: string) {
|
function log(topicId: string, msg: string) {
|
||||||
console.log(`[lesson:${topicId.slice(0, 8)}] ${msg}`);
|
console.log(`[lesson:${topicId.slice(0, 8)}] ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJSON<T>(raw: string): T {
|
function parseJSON<T>(raw: string): T {
|
||||||
|
// strip <think>...</think> blocks from reasoning models (deepseek-r1 etc.)
|
||||||
|
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
const cleaned = raw.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
const cleaned = text.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
||||||
return JSON.parse(cleaned);
|
return JSON.parse(cleaned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +31,10 @@ async function generateLessonAudio(
|
||||||
): Promise<{ steps: any[]; cost: number }> {
|
): Promise<{ steps: any[]; cost: number }> {
|
||||||
let cost = 0;
|
let cost = 0;
|
||||||
|
|
||||||
|
// build a flat list of tasks so we can batch them
|
||||||
|
type TTSTask = () => Promise<void>;
|
||||||
|
const tasks: TTSTask[] = [];
|
||||||
|
|
||||||
for (let si = 0; si < steps.length; si++) {
|
for (let si = 0; si < steps.length; si++) {
|
||||||
const step = steps[si];
|
const step = steps[si];
|
||||||
|
|
||||||
|
|
@ -33,61 +42,97 @@ async function generateLessonAudio(
|
||||||
const text = [step.body, step.callout].filter(Boolean).join(" ");
|
const text = [step.body, step.callout].filter(Boolean).join(" ");
|
||||||
if (!text.trim()) continue;
|
if (!text.trim()) continue;
|
||||||
|
|
||||||
|
tasks.push(async () => {
|
||||||
const result = await generateStepTTS(text, lessonId, si);
|
const result = await generateStepTTS(text, lessonId, si);
|
||||||
if (result) {
|
if (result) {
|
||||||
step.audioPath = result.audioPath;
|
step.audioPath = result.audioPath;
|
||||||
step.audioChunks = result.audioChunks;
|
step.audioChunks = result.audioChunks;
|
||||||
cost += result.cost;
|
cost += result.cost;
|
||||||
}
|
}
|
||||||
|
log(topicId, ` step ${si} (${step.type}) TTS done`);
|
||||||
|
});
|
||||||
|
|
||||||
} else if (step.type === "summary") {
|
} else if (step.type === "summary") {
|
||||||
const text = Array.isArray(step.bullets) ? step.bullets.join(". ") : "";
|
const text = Array.isArray(step.bullets) ? step.bullets.join(". ") : "";
|
||||||
if (!text.trim()) continue;
|
if (!text.trim()) continue;
|
||||||
|
|
||||||
|
tasks.push(async () => {
|
||||||
const result = await generateStepTTS(text, lessonId, si);
|
const result = await generateStepTTS(text, lessonId, si);
|
||||||
if (result) {
|
if (result) {
|
||||||
step.audioPath = result.audioPath;
|
step.audioPath = result.audioPath;
|
||||||
step.audioChunks = result.audioChunks;
|
step.audioChunks = result.audioChunks;
|
||||||
cost += result.cost;
|
cost += result.cost;
|
||||||
}
|
}
|
||||||
|
log(topicId, ` step ${si} (summary) TTS done`);
|
||||||
|
});
|
||||||
|
|
||||||
} else if (step.type === "question") {
|
} else if (step.type === "question") {
|
||||||
if (step.body?.trim()) {
|
if (step.body?.trim()) {
|
||||||
|
tasks.push(async () => {
|
||||||
const qResult = await generateQuestionTTS(step.body, lessonId, si);
|
const qResult = await generateQuestionTTS(step.body, lessonId, si);
|
||||||
if (qResult) {
|
if (qResult) {
|
||||||
step.questionAudioPath = qResult.audioPath;
|
step.questionAudioPath = qResult.audioPath;
|
||||||
step.questionAudioChunks = qResult.audioChunks;
|
step.questionAudioChunks = qResult.audioChunks;
|
||||||
cost += qResult.cost;
|
cost += qResult.cost;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(step.options)) {
|
if (Array.isArray(step.options)) {
|
||||||
step.optionAudioPaths = [];
|
step.optionAudioPaths = new Array(step.options.length).fill(null);
|
||||||
|
|
||||||
for (let oi = 0; oi < step.options.length; oi++) {
|
for (let oi = 0; oi < step.options.length; oi++) {
|
||||||
const optText = step.options[oi];
|
const optText = step.options[oi];
|
||||||
|
const oiCopy = oi;
|
||||||
|
|
||||||
if (optText?.trim()) {
|
if (optText?.trim()) {
|
||||||
const oResult = await generateOptionTTS(optText, lessonId, si, oi);
|
tasks.push(async () => {
|
||||||
|
const oResult = await generateOptionTTS(optText, lessonId, si, oiCopy);
|
||||||
if (oResult) {
|
if (oResult) {
|
||||||
step.optionAudioPaths[oi] = oResult.audioPath;
|
step.optionAudioPaths[oiCopy] = oResult.audioPath;
|
||||||
cost += oResult.cost;
|
cost += oResult.cost;
|
||||||
} else {
|
|
||||||
step.optionAudioPaths[oi] = null;
|
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
step.optionAudioPaths[oi] = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.push(async () => {
|
||||||
|
log(topicId, ` step ${si} (question) TTS done`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log(topicId, ` step ${si} (${step.type}) TTS done`);
|
// run in batches of 4
|
||||||
|
const BATCH = 4;
|
||||||
|
for (let i = 0; i < tasks.length; i += BATCH) {
|
||||||
|
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { steps, cost };
|
return { steps, cost };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLesson(topicId: string): Promise<void> {
|
export async function generateLesson(topicId: string): Promise<void> {
|
||||||
|
// chain onto existing promise for this topic so only one runs at a time
|
||||||
|
const prev = topicMutexes.get(topicId) ?? Promise.resolve();
|
||||||
|
let resolveMutex!: () => void;
|
||||||
|
const thisSlot = new Promise<void>((res) => { resolveMutex = res; });
|
||||||
|
topicMutexes.set(topicId, prev.then(() => thisSlot));
|
||||||
|
|
||||||
|
await prev;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── Step 1 — mark topic as generating ──────────────────────────────────
|
// ── Step 1 — atomically claim the topic (only if still pending) ─────────
|
||||||
await db.update(topics).set({ status: "generating" }).where(eq(topics.id, topicId));
|
const claimed = await db
|
||||||
|
.update(topics)
|
||||||
|
.set({ status: "generating" })
|
||||||
|
.where(and(eq(topics.id, topicId), eq(topics.status, "pending")))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (claimed.length === 0) {
|
||||||
|
log(topicId, "topic not in pending state, bailing out — already generating or ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 2 — load context ───────────────────────────────────────────────
|
// ── Step 2 — load context ───────────────────────────────────────────────
|
||||||
const topic = await db.query.topics.findFirst({ where: eq(topics.id, topicId) });
|
const topic = await db.query.topics.findFirst({ where: eq(topics.id, topicId) });
|
||||||
|
|
@ -104,9 +149,17 @@ export async function generateLesson(topicId: string): Promise<void> {
|
||||||
|
|
||||||
const completedLessons: { order: number; title: string; keyConcepts: string[]; analogiesUsed: string[] }[] = [];
|
const completedLessons: { order: number; title: string; keyConcepts: string[]; analogiesUsed: string[] }[] = [];
|
||||||
|
|
||||||
for (const t of allTopics) {
|
const priorTopics = allTopics.filter((t) => t.status === "ready" && t.order < topic.order);
|
||||||
if (t.status !== "ready" || t.order >= topic.order) continue;
|
|
||||||
const l = await db.query.lessons.findFirst({ where: eq(lessons.topicId, t.id) });
|
if (priorTopics.length > 0) {
|
||||||
|
const priorLessons = await db.query.lessons.findMany({
|
||||||
|
where: inArray(lessons.topicId, priorTopics.map((t) => t.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lessonByTopicId = new Map(priorLessons.map((l) => [l.topicId, l]));
|
||||||
|
|
||||||
|
for (const t of priorTopics) {
|
||||||
|
const l = lessonByTopicId.get(t.id);
|
||||||
if (!l) continue;
|
if (!l) continue;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(l.content) as { keyConcepts?: string[]; analogiesUsed?: string[] };
|
const parsed = JSON.parse(l.content) as { keyConcepts?: string[]; analogiesUsed?: string[] };
|
||||||
|
|
@ -118,6 +171,7 @@ export async function generateLesson(topicId: string): Promise<void> {
|
||||||
});
|
});
|
||||||
} catch { /* skip malformed */ }
|
} catch { /* skip malformed */ }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// load relevant source files
|
// load relevant source files
|
||||||
const topicRelevantFiles: string[] = (() => {
|
const topicRelevantFiles: string[] = (() => {
|
||||||
|
|
@ -250,7 +304,16 @@ Steps must interleave concept/example and question types — never two questions
|
||||||
|
|
||||||
let lessonContent: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] } = parseJSON(lessonResult.text);
|
let lessonContent: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] } = parseJSON(lessonResult.text);
|
||||||
|
|
||||||
// ── Step 4 — generate quiz ──────────────────────────────────────────────
|
// validate shape
|
||||||
|
if (!Array.isArray(lessonContent.steps) || lessonContent.steps.length === 0) {
|
||||||
|
throw new Error("lesson content has no steps");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const step of lessonContent.steps) {
|
||||||
|
if (!step.type) throw new Error(`a lesson step is missing the type field`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4 — generate quiz in memory (before any DB writes) ────────────
|
||||||
const quizPrompt = `You are an exam question writer for a university course on ${courseSubject}.
|
const quizPrompt = `You are an exam question writer for a university course on ${courseSubject}.
|
||||||
|
|
||||||
COURSE CONTEXT:
|
COURSE CONTEXT:
|
||||||
|
|
@ -293,11 +356,11 @@ Respond with ONLY valid JSON array, no markdown fences:
|
||||||
explanation: string;
|
explanation: string;
|
||||||
}[]>(quizResult.text);
|
}[]>(quizResult.text);
|
||||||
|
|
||||||
// ── Step 5 — generate TTS for all lesson steps ──────────────────────────
|
// ── Step 5 — commit lesson + quiz + topic status in one transaction ──────
|
||||||
const lessonId = randomUUID();
|
const lessonId = randomUUID();
|
||||||
const ttsProvider = (useRuntimeConfig().ttsProvider as string | undefined)?.toLowerCase() ?? "elevenlabs";
|
const ttsProvider = (useRuntimeConfig().ttsProvider as string | undefined)?.toLowerCase() ?? "elevenlabs";
|
||||||
|
|
||||||
// save lesson first so audio dir has an id to write to
|
// better-sqlite3 doesnt support async transactions — run inserts sequentially
|
||||||
await db.insert(lessons).values({
|
await db.insert(lessons).values({
|
||||||
id: lessonId,
|
id: lessonId,
|
||||||
topicId: topic.id,
|
topicId: topic.id,
|
||||||
|
|
@ -311,33 +374,6 @@ Respond with ONLY valid JSON array, no markdown fences:
|
||||||
branchStatus: "pending",
|
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) {
|
for (const q of questions) {
|
||||||
await db.insert(quizQuestions).values({
|
await db.insert(quizQuestions).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
|
@ -352,6 +388,33 @@ Respond with ONLY valid JSON array, no markdown fences:
|
||||||
|
|
||||||
await db.update(topics).set({ status: "ready" }).where(eq(topics.id, topicId));
|
await db.update(topics).set({ status: "ready" }).where(eq(topics.id, topicId));
|
||||||
|
|
||||||
|
log(topicId, `lesson + quiz saved (${lessonId}), generating TTS…`);
|
||||||
|
|
||||||
|
// ── Step 6 — TTS (outside transaction — long running) ───────────────────
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update lesson with TTS paths + final costs
|
||||||
|
await db.update(lessons)
|
||||||
|
.set({
|
||||||
|
content: JSON.stringify(lessonContent),
|
||||||
|
costAI,
|
||||||
|
costAudio,
|
||||||
|
costTotal: costAI + costAudio,
|
||||||
|
branchStatus: "pending",
|
||||||
|
})
|
||||||
|
.where(eq(lessons.id, lessonId));
|
||||||
|
|
||||||
log(topicId, `✓ lesson ready — cost AI $${costAI.toFixed(4)}, audio $${costAudio.toFixed(4)}`);
|
log(topicId, `✓ lesson ready — cost AI $${costAI.toFixed(4)}, audio $${costAudio.toFixed(4)}`);
|
||||||
|
|
||||||
// ── Step 7 — fire and forget branch generation ──────────────────────────
|
// ── Step 7 — fire and forget branch generation ──────────────────────────
|
||||||
|
|
@ -361,5 +424,9 @@ Respond with ONLY valid JSON array, no markdown fences:
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[lesson:${topicId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
|
console.error(`[lesson:${topicId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
|
||||||
await db.update(topics).set({ status: "error" }).where(eq(topics.id, topicId));
|
await db.update(topics).set({ status: "error" }).where(eq(topics.id, topicId));
|
||||||
|
} finally {
|
||||||
|
resolveMutex();
|
||||||
|
// if nobody queued after us, remove the entry so the map doesnt grow
|
||||||
|
topicMutexes.delete(topicId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,33 @@
|
||||||
import { mkdir, writeFile, access } from "fs/promises";
|
import { mkdir, writeFile, access } from "fs/promises";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
|
import { askAI } from "./openrouter";
|
||||||
|
|
||||||
|
const NARRATION_SYSTEM_PROMPT = `You are a narration script editor for an AI voice actor. Your job is to take educational lesson text and prepare it to be read aloud naturally and engagingly.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do NOT change the meaning, facts, or structure of the content. You are not rewriting it.
|
||||||
|
- Fix anything that would sound awkward or robotic when spoken: remove markdown formatting (asterisks, backticks, hashes), spell out acronyms where helpful, rephrase code snippets or technical shorthand into speakable language.
|
||||||
|
- Add square bracket cues to give the voice character and pacing. These are the only ones you may use: [pause], [long pause], [sighs], [laughs], [clears throat], [hesitates].
|
||||||
|
- Use [pause] at natural breath points — after key ideas, before a new concept, or mid-sentence where a human would pause for effect. Don't overdo it; one every few sentences at most.
|
||||||
|
- Use [sighs] or [laughs] very sparingly — only where a human narrator genuinely would. A [sighs] before a tricky concept, a [laughs] when something is ironic or light. Maybe once or twice per lesson, if at all.
|
||||||
|
- Keep the tone warm, clear, and conversational — like a knowledgeable friend explaining something, not a textbook being read aloud.
|
||||||
|
- Return ONLY the modified narration text. No commentary, no explanation, no quotes around the output.`;
|
||||||
|
|
||||||
|
async function humaniseTTSText(text: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await askAI(
|
||||||
|
[
|
||||||
|
{ role: "system", content: NARRATION_SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: text },
|
||||||
|
],
|
||||||
|
{ temperature: 0.5, maxTokens: 2048 }
|
||||||
|
);
|
||||||
|
return result.text.trim();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[tts] humanise failed, using raw text: ${err?.message ?? err}`);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface AudioChunk {
|
export interface AudioChunk {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -32,6 +60,7 @@ async function callElevenLabs(
|
||||||
model_id: "eleven_turbo_v2_5",
|
model_id: "eleven_turbo_v2_5",
|
||||||
output_format: "mp3_44100_128",
|
output_format: "mp3_44100_128",
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -112,6 +141,7 @@ async function callFishAudio(
|
||||||
mp3_bitrate: 128,
|
mp3_bitrate: 128,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -136,15 +166,17 @@ async function callTTS(
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const provider = getProvider();
|
const provider = getProvider();
|
||||||
|
|
||||||
|
text = await humaniseTTSText(text);
|
||||||
|
|
||||||
if (provider === "fishaudio") {
|
if (provider === "fishaudio") {
|
||||||
const apiKey = config.fishAudioApiKey as string;
|
const apiKey = config.fishAudioApiKey as string;
|
||||||
const voiceId = config.fishAudioVoiceId as string;
|
const voiceId = (config.public.fishAudioVoiceId || config.fishAudioVoiceId) as string;
|
||||||
if (!apiKey) return null;
|
if (!apiKey) return null;
|
||||||
return callFishAudio(text, apiKey, voiceId);
|
return callFishAudio(text, apiKey, voiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = config.elevenlabsApiKey as string;
|
const apiKey = config.elevenlabsApiKey as string;
|
||||||
const voiceId = config.elevenlabsVoiceId as string;
|
const voiceId = (config.public.elevenlabsVoiceId || config.elevenlabsVoiceId) as string;
|
||||||
if (!apiKey) return null;
|
if (!apiKey) return null;
|
||||||
return callElevenLabs(text, apiKey, voiceId);
|
return callElevenLabs(text, apiKey, voiceId);
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +268,7 @@ export async function generateClip(
|
||||||
if (provider === "fishaudio") {
|
if (provider === "fishaudio") {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const fishKey = config.fishAudioApiKey as string;
|
const fishKey = config.fishAudioApiKey as string;
|
||||||
const fishVoice = (config.fishAudioVoiceId as string) || voiceId;
|
const fishVoice = (config.public.fishAudioVoiceId || config.fishAudioVoiceId as string) || voiceId;
|
||||||
|
|
||||||
const res = await fetch("https://api.fish.audio/v1/tts", {
|
const res = await fetch("https://api.fish.audio/v1/tts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -251,6 +283,7 @@ export async function generateClip(
|
||||||
mp3_bitrate: 128,
|
mp3_bitrate: 128,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -275,6 +308,7 @@ export async function generateClip(
|
||||||
output_format: "mp3_44100_128",
|
output_format: "mp3_44100_128",
|
||||||
...(opts?.voice_settings ? { voice_settings: opts.voice_settings } : {}),
|
...(opts?.voice_settings ? { voice_settings: opts.voice_settings } : {}),
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { createPublicKey, verify } from "crypto";
|
||||||
|
|
||||||
function getPublicKey() {
|
function getPublicKey() {
|
||||||
const b64 = process.env.LICENSE_PUBLIC_KEY;
|
const b64 = process.env.LICENSE_PUBLIC_KEY;
|
||||||
if (!b64) throw createError({ statusCode: 500, message: "License system not configured" });
|
if (!b64) {
|
||||||
|
console.error("[revisione] LICENSE_PUBLIC_KEY is not set — license system not configured");
|
||||||
|
throw createError({ statusCode: 500, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
return createPublicKey({
|
return createPublicKey({
|
||||||
key: Buffer.from(b64, "base64"),
|
key: Buffer.from(b64, "base64"),
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
|
||||||
const maxRetries = options.maxRetries ?? 4;
|
const maxRetries = options.maxRetries ?? 4;
|
||||||
|
|
||||||
let lastError: any;
|
let lastError: any;
|
||||||
|
let credit402Retries = 0;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
const label = attempt > 0 ? ` (attempt ${attempt + 1}/${maxRetries + 1})` : "";
|
const label = attempt > 0 ? ` (attempt ${attempt + 1}/${maxRetries + 1})` : "";
|
||||||
|
|
@ -54,6 +55,7 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
|
||||||
temperature: options.temperature ?? 0.3,
|
temperature: options.temperature ?? 0.3,
|
||||||
...(options.maxTokens ? { max_tokens: options.maxTokens } : {}),
|
...(options.maxTokens ? { max_tokens: options.maxTokens } : {}),
|
||||||
},
|
},
|
||||||
|
signal: AbortSignal.timeout(600_000),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -78,16 +80,19 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
|
||||||
const status = err?.response?.status ?? err?.statusCode ?? err?.status;
|
const status = err?.response?.status ?? err?.statusCode ?? err?.status;
|
||||||
const body = err?.data ?? err?.response?._data ?? "(no body)";
|
const body = err?.data ?? err?.response?._data ?? "(no body)";
|
||||||
|
|
||||||
console.error(`[openrouter] ✗ ${elapsed}s — status: ${status ?? "unknown"} | error: ${err?.message}`);
|
const orErrCode = body?.error?.code ?? body?.error?.type ?? "(unknown)";
|
||||||
if (body && body !== "(no body)") {
|
console.error(`[openrouter] ✗ ${elapsed}s — status: ${status ?? "unknown"} | or-code: ${orErrCode} | error: ${err?.message}`);
|
||||||
console.error(`[openrouter] response body:`, JSON.stringify(body).slice(0, 400));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 402 = insufficient credits — wait 60s and keep retrying indefinitely
|
// 402 = insufficient credits — wait 60s and retry up to 5 times
|
||||||
if (status === 402) {
|
if (status === 402) {
|
||||||
console.warn(`[openrouter] insufficient credits — waiting 60s before retry…`);
|
credit402Retries++;
|
||||||
|
if (credit402Retries > 5) {
|
||||||
|
console.error(`[openrouter] 402 retries exhausted (${credit402Retries - 1} attempts), giving up`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
console.warn(`[openrouter] insufficient credits (attempt ${credit402Retries}/5) — waiting 60s before retry…`);
|
||||||
await sleep(60_000);
|
await sleep(60_000);
|
||||||
attempt--; // don't count against maxRetries
|
attempt--; // dont count against maxRetries
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue