harden database interactions and improve error handling

This commit is contained in:
ImBenji 2026-04-28 14:36:13 +01:00
parent 5a4caaf1d0
commit e1f168a302
29 changed files with 869 additions and 241 deletions

46
FIXES.md Normal file
View 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.

View file

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

View 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`);

View file

@ -0,0 +1 @@
{}

View file

@ -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
} }
] ]
} }

View file

@ -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: {

View file

@ -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",

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

View file

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

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

View file

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

View file

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

View 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
View 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(),
};
});

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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()

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

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

View file

@ -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"),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"),

View file

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