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 genState = ref<GenState | null>(null);
|
||||
|
||||
const abortController = ref<AbortController | null>(null);
|
||||
const generationFired = ref(false);
|
||||
|
||||
const GEN_TIPS = [
|
||||
"Reading through your past papers...",
|
||||
"Crafting analogies just for this topic...",
|
||||
|
|
@ -36,6 +39,7 @@ let genTipTimer: ReturnType<typeof setInterval> | null = null;
|
|||
let genStageTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startGenAnimations() {
|
||||
if (genTipTimer) return;
|
||||
genTipTimer = setInterval(() => {
|
||||
genTipVisible.value = false;
|
||||
setTimeout(() => {
|
||||
|
|
@ -55,7 +59,12 @@ function stopGenAnimations() {
|
|||
}
|
||||
|
||||
async function loadLesson() {
|
||||
lessonPending.value = true;
|
||||
lesson.value = null;
|
||||
|
||||
const pendingTimer = setTimeout(() => { lessonPending.value = true; }, 100);
|
||||
|
||||
abortController.value = new AbortController();
|
||||
|
||||
try {
|
||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||
lesson.value = data;
|
||||
|
|
@ -68,11 +77,15 @@ async function loadLesson() {
|
|||
lessonError.value = err;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(pendingTimer);
|
||||
lessonPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerGeneration() {
|
||||
if (generationFired.value) return;
|
||||
generationFired.value = true;
|
||||
|
||||
genState.value = "loading";
|
||||
startGenAnimations();
|
||||
try {
|
||||
|
|
@ -95,6 +108,7 @@ onMounted(loadLesson);
|
|||
|
||||
onUnmounted(() => {
|
||||
stopGenAnimations();
|
||||
abortController.value?.abort();
|
||||
});
|
||||
|
||||
// ── branch loading overlay ─────────────────────────────────────────────────
|
||||
|
|
@ -103,8 +117,13 @@ const branchOverlayVisible = ref(false);
|
|||
const branchOverlayError = ref(false);
|
||||
|
||||
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const branchPollStart = ref(0);
|
||||
|
||||
function startBranchPoll() {
|
||||
if (branchPollTimer) return;
|
||||
|
||||
branchPollStart.value = Date.now();
|
||||
|
||||
branchPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||
|
|
@ -117,6 +136,11 @@ function startBranchPoll() {
|
|||
branchOverlayError.value = true;
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
|
||||
if (Date.now() - branchPollStart.value > 60_000) {
|
||||
stopBranchPoll();
|
||||
branchOverlayError.value = true;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
|
|
@ -295,10 +319,12 @@ const showMainExplanation = computed(() => {
|
|||
|
||||
const shuffledOptions = ref<string[]>([]);
|
||||
|
||||
function fisherYates(arr: string[]): string[] {
|
||||
function seededShuffle(arr: string[], seed: number): string[] {
|
||||
const a = [...arr];
|
||||
let s = seed;
|
||||
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]];
|
||||
}
|
||||
return a;
|
||||
|
|
@ -307,7 +333,8 @@ function fisherYates(arr: string[]): string[] {
|
|||
watch([() => stepKey.value, steps], () => {
|
||||
const s = displayStep.value;
|
||||
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 {
|
||||
shuffledOptions.value = [];
|
||||
}
|
||||
|
|
@ -478,19 +505,24 @@ function advance() {
|
|||
}
|
||||
|
||||
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));
|
||||
for (const k of newKeys) {
|
||||
const s = steps.value[k];
|
||||
if (s?.type === "question" && opts[k] === s.answer) lessonScore.value++;
|
||||
}
|
||||
}, { deep: true });
|
||||
});
|
||||
|
||||
watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => {
|
||||
if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration();
|
||||
});
|
||||
|
||||
const celebrationFired = ref(false);
|
||||
|
||||
function fireCelebration() {
|
||||
if (celebrationFired.value) return;
|
||||
celebrationFired.value = true;
|
||||
|
||||
const count = 160;
|
||||
const defaults = { startVelocity: 35, spread: 80, ticks: 70, zIndex: 200 };
|
||||
const interval = setInterval(() => {
|
||||
|
|
@ -502,13 +534,20 @@ function fireCelebration() {
|
|||
|
||||
const lessonDone = ref(false);
|
||||
const completing = ref(false);
|
||||
const completeError = ref(false);
|
||||
|
||||
async function completeLesson() {
|
||||
if (completing.value || lessonDone.value) return;
|
||||
completing.value = true;
|
||||
completeError.value = false;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||
|
||||
try {
|
||||
await $fetch(`/api/topics/${topicId}/progress`, {
|
||||
method: "POST",
|
||||
signal: ctrl.signal,
|
||||
body: {
|
||||
lessonComplete: true,
|
||||
tookBranches: branchesEntered.value > 0,
|
||||
|
|
@ -516,12 +555,38 @@ async function completeLesson() {
|
|||
},
|
||||
});
|
||||
lessonDone.value = true;
|
||||
} catch {
|
||||
completing.value = false;
|
||||
completeError.value = true;
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -529,6 +594,7 @@ const focusMode = ref(false);
|
|||
const currentChunk = ref<{ text: string; start: number; end: number } | null>(null);
|
||||
const karaokeAudioEl = ref<HTMLAudioElement | null>(null);
|
||||
const questionPlaying = ref(false);
|
||||
const narratingOptionIndex = ref<number | null>(null);;
|
||||
const focusEverActivated = ref(false);
|
||||
|
||||
if (import.meta.client) {
|
||||
|
|
@ -550,6 +616,7 @@ const showKaraoke = computed(() => {
|
|||
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(() =>
|
||||
focusMode.value &&
|
||||
(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 {
|
||||
if (!questionVariants.value[stepIndex]) {
|
||||
questionVariants.value[stepIndex] = Math.floor(Math.random() * 4) + 1;
|
||||
}
|
||||
return questionVariants.value[stepIndex];
|
||||
return (Math.abs(stepIndex) % 4) + 1;
|
||||
}
|
||||
|
||||
async function playQuestionLike(
|
||||
|
|
@ -672,26 +734,31 @@ async function playQuestionLike(
|
|||
) {
|
||||
stopQueue();
|
||||
questionPlaying.value = true;
|
||||
narratingOptionIndex.value = null;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
// aborted
|
||||
}
|
||||
|
||||
narratingOptionIndex.value = null;
|
||||
questionPlaying.value = false;
|
||||
}
|
||||
|
||||
|
|
@ -704,10 +771,9 @@ function startStepAudio() {
|
|||
|
||||
if (s.type === "concept" || s.type === "example" || s.type === "summary") {
|
||||
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) {
|
||||
lessonState.stepIndex++;
|
||||
nextTick(() => startStepAudio());
|
||||
} else {
|
||||
completeLesson();
|
||||
}
|
||||
|
|
@ -720,7 +786,7 @@ function startStepAudio() {
|
|||
startKaraoke(audio, s.audioChunks, () => {
|
||||
if (!isLastMainStep.value) {
|
||||
lessonState.stepIndex++;
|
||||
nextTick(() => startStepAudio());
|
||||
// watcher handles startStepAudio for the new step
|
||||
} else {
|
||||
completeLesson();
|
||||
}
|
||||
|
|
@ -862,7 +928,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
<!-- ── TOP BAR ─────────────────────────────────────────────────────────── -->
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
|
|
@ -892,29 +958,33 @@ onBeforeUnmount(() => {
|
|||
</svg>
|
||||
<span class="focus-label">Focus Mode</span>
|
||||
</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>
|
||||
|
||||
<!-- ── KARAOKE OVERLAY ────────────────────────────────────────────────── -->
|
||||
<Transition name="karaoke-fade">
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ── QUESTION AUDIO OVERLAY ─────────────────────────────────────────── -->
|
||||
<Transition name="karaoke-fade">
|
||||
<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"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="karaoke-start" @click="startStepAudio">
|
||||
<svg width="22" height="22" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
|
|
@ -956,7 +1026,7 @@ onBeforeUnmount(() => {
|
|||
<!-- ── MAIN ───────────────────────────────────────────────────────────── -->
|
||||
<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">
|
||||
|
|
@ -1002,7 +1072,8 @@ onBeforeUnmount(() => {
|
|||
:key="opt"
|
||||
class="option"
|
||||
:class="{
|
||||
'option--hover-ready': !hasAnswered,
|
||||
'option--hover-ready': !hasAnswered && narratingOptionIndex === null,
|
||||
'option--narrating': !hasAnswered && narratingOptionIndex === i,
|
||||
'option--correct': hasAnswered && opt === displayStep.answer,
|
||||
'option--wrong': hasAnswered && selectedAnswer === opt && opt !== displayStep.answer,
|
||||
'option--dim': hasAnswered && selectedAnswer !== opt && opt !== displayStep.answer,
|
||||
|
|
@ -1077,13 +1148,15 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
|
||||
<button
|
||||
v-if="!lessonDone"
|
||||
v-if="!lessonDone && !completeError"
|
||||
class="summary-cta"
|
||||
:disabled="completing"
|
||||
@click="completeLesson"
|
||||
>
|
||||
{{ completing ? 'Saving…' : 'Complete Lesson ✓' }}
|
||||
</button>
|
||||
|
||||
<button v-if="completeError" class="summary-cta" @click="completeLesson">Retry ✓</button>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
|
@ -1092,7 +1165,7 @@ onBeforeUnmount(() => {
|
|||
</main>
|
||||
|
||||
<!-- ── 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 -->
|
||||
<div
|
||||
|
|
@ -1201,7 +1274,23 @@ onBeforeUnmount(() => {
|
|||
opacity: 0.5;
|
||||
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 {
|
||||
flex: 1;
|
||||
|
|
@ -1485,6 +1574,7 @@ onBeforeUnmount(() => {
|
|||
font-size: 1rem;
|
||||
}
|
||||
.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--wrong { border-color: #EF4444; background: #FEE2E2; }
|
||||
.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,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1777200000000,
|
||||
"tag": "0001_hardening",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,17 @@ export default defineNuxtConfig({
|
|||
app: {
|
||||
head: {
|
||||
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",
|
||||
devtools: { enabled: true },
|
||||
devtools: { enabled: process.env.NODE_ENV !== "production" },
|
||||
|
||||
future: { compatibilityVersion: 4 },
|
||||
|
||||
|
|
@ -20,14 +27,26 @@ export default defineNuxtConfig({
|
|||
runtimeConfig: {
|
||||
openrouterApiKey: "",
|
||||
openrouterModel: "deepseek/deepseek-v4-flash",
|
||||
openrouterCurriculumModel: "",
|
||||
openrouterClassificationModel: "deepseek/deepseek-v4-flash",
|
||||
openrouterEvaluatorModel: "deepseek/deepseek-r1",
|
||||
ttsProvider: "elevenlabs",
|
||||
elevenlabsApiKey: "",
|
||||
elevenlabsVoiceId: "21m00Tcm4TlvDq8ikWAM",
|
||||
fishAudioApiKey: "",
|
||||
|
||||
public: {
|
||||
elevenlabsVoiceId: "21m00Tcm4TlvDq8ikWAM",
|
||||
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: {
|
||||
experimental: {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"gen:keypair": "node scripts/gen-keypair.mjs",
|
||||
"gen:key": "node scripts/gen-key.mjs"
|
||||
},
|
||||
"engines": { "node": ">=20.0.0" },
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"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" });
|
||||
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) {
|
||||
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 { 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) => {
|
||||
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) });
|
||||
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" });
|
||||
}
|
||||
|
||||
// 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);
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
const uploadId = randomUUID();
|
||||
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);
|
||||
|
||||
let extractedText: string | null = null;
|
||||
let pdfWarning: string | undefined;
|
||||
|
||||
try {
|
||||
extractedText = await parsePdf(buffer);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} catch (err: any) {
|
||||
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 ?? "");
|
||||
|
|
@ -48,5 +73,9 @@ export default defineEventHandler(async (event) => {
|
|||
extractedText,
|
||||
});
|
||||
|
||||
if (pdfWarning) {
|
||||
return { uploadId, filename: file.name, type: detectedType, warning: pdfWarning };
|
||||
}
|
||||
|
||||
return { uploadId, filename: file.name, type: detectedType };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,35 +1,37 @@
|
|||
import { db } from "../../db/index";
|
||||
import { courses, topics, userProgress } from "../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const allCourses = await db.query.courses.findMany({
|
||||
orderBy: (c, { desc }) => desc(c.createdAt),
|
||||
});
|
||||
|
||||
const result = [];
|
||||
if (allCourses.length === 0) return [];
|
||||
|
||||
for (const course of allCourses) {
|
||||
const topicRows = await db.query.topics.findMany({
|
||||
where: eq(topics.courseId, course.id),
|
||||
});
|
||||
const courseIds = allCourses.map((c) => c.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;
|
||||
if (topicCount > 0) {
|
||||
const progressRows = await db.query.userProgress.findMany({
|
||||
where: eq(userProgress.courseId, course.id),
|
||||
});
|
||||
completedCount = progressRows.filter((p) => p.lessonComplete).length;
|
||||
// group in memory
|
||||
const topicsByCourse = new Map<string, number>();
|
||||
for (const t of allTopics) {
|
||||
topicsByCourse.set(t.courseId, (topicsByCourse.get(t.courseId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
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,
|
||||
topicCount,
|
||||
completedCount,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
topicCount: topicsByCourse.get(course.id) ?? 0,
|
||||
completedCount: completedByCourse.get(course.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
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")
|
||||
)
|
||||
});
|
||||
if (nextTopic) generateLesson(nextTopic.id);
|
||||
if (nextTopic) generateLesson(nextTopic.id).catch((e) => console.error("[pre-gen]", e));
|
||||
|
||||
return { status: "ready" };
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from "../../../db/index";
|
||||
import { topics, userProgress } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -12,26 +12,25 @@ export default defineEventHandler(async (event) => {
|
|||
const body = await readBody(event);
|
||||
const { lessonComplete, quizScore, tookBranches, branchCount } = body ?? {};
|
||||
|
||||
const existing = await db.query.userProgress.findFirst({
|
||||
where: and(
|
||||
eq(userProgress.topicId, id),
|
||||
eq(userProgress.courseId, topic.courseId)
|
||||
),
|
||||
});
|
||||
// manual validation — no zod
|
||||
if (lessonComplete !== undefined && typeof lessonComplete !== "boolean") {
|
||||
throw createError({ statusCode: 400, message: "lessonComplete must be a boolean" });
|
||||
}
|
||||
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
|
||||
.update(userProgress)
|
||||
.set({
|
||||
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({
|
||||
.insert(userProgress)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
courseId: topic.courseId,
|
||||
topicId: id,
|
||||
|
|
@ -39,8 +38,18 @@ export default defineEventHandler(async (event) => {
|
|||
quizScore: quizScore ?? null,
|
||||
tookBranches: tookBranches ?? false,
|
||||
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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ const dbPath = process.env.DATABASE_PATH || resolve(process.cwd(), "revisione.db
|
|||
const sqlite = new Database(dbPath);
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
sqlite.pragma("wal_autocheckpoint = 1000");
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export const courses = sqliteTable("courses", {
|
|||
costAudio: real("cost_audio").default(0),
|
||||
auditReport: text("audit_report"),
|
||||
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"),
|
||||
createdAt: text("created_at")
|
||||
.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 { courses } from "../db/schema";
|
||||
import { courses, topics, lessons } from "../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateCourseInBackground } from "../utils/generateCourse";
|
||||
|
||||
export default defineNitroPlugin(async () => {
|
||||
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 {
|
||||
const stuck = await db.query.courses.findMany({
|
||||
where: eq(courses.status, "processing"),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import { eq } from "drizzle-orm";
|
|||
import { askAI } from "./openrouter";
|
||||
|
||||
function parseJSON<T>(raw: string): T {
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(text);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ function log(lessonId: string, msg: string) {
|
|||
}
|
||||
|
||||
function parseJSON<T>(raw: string): T {
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(text);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,7 +54,8 @@ export async function generateBranches(topicId: string, lessonId: string): Promi
|
|||
let costBranchAI = 0;
|
||||
let costBranchAudio = 0;
|
||||
|
||||
let branchesChanged = false;
|
||||
let branchErrors = 0;
|
||||
let branchSuccesses = 0;
|
||||
|
||||
for (let si = 0; si < lessonContent.steps.length; si++) {
|
||||
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);
|
||||
step.branches = parsed.branches ?? {};
|
||||
branchesChanged = true;
|
||||
|
||||
log(lessonId, ` step ${si} branches generated — ${Object.keys(step.branches).length} wrong options`);
|
||||
|
||||
const wrongOptions = (step.options as string[]).filter((o: string) => o !== step.answer);
|
||||
|
||||
// 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++) {
|
||||
const wrongOpt = wrongOptions[bi];
|
||||
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 text = [bStep.body, bStep.callout].filter(Boolean).join(" ");
|
||||
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) {
|
||||
bStep.audioPath = r.audioPath;
|
||||
bStep.audioChunks = r.audioChunks;
|
||||
costBranchAudio += r.cost;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (branch.confirmQuestion?.body?.trim()) {
|
||||
const r = await generateTTSToPath(branch.confirmQuestion.body, lessonId, `branch_${si}_${bi}_confirm_q.mp3`);
|
||||
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) {
|
||||
branch.confirmQuestion.questionAudioPath = r.audioPath;
|
||||
branch.confirmQuestion.questionAudioChunks = r.audioChunks;
|
||||
costBranchAudio += r.cost;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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++) {
|
||||
const optText = branch.confirmQuestion.options[oi];
|
||||
const oiCopy = oi;
|
||||
const optFile = `branch_${si}_${bi}_confirm_opt_${oi}.mp3`;
|
||||
|
||||
if (optText?.trim()) {
|
||||
const r = await generateTTSToPath(optText, lessonId, `branch_${si}_${bi}_confirm_opt_${oi}.mp3`);
|
||||
branch.confirmQuestion.optionAudioPaths[oi] = r ? r.audioPath : null;
|
||||
ttsTasks.push(async () => {
|
||||
const r = await generateTTSToPath(optText, lessonId, optFile);
|
||||
branch.confirmQuestion.optionAudioPaths[oiCopy] = r ? r.audioPath : null;
|
||||
if (r) costBranchAudio += r.cost;
|
||||
} else {
|
||||
branch.confirmQuestion.optionAudioPaths[oi] = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (branch.hardStop?.trim()) {
|
||||
const r = await generateTTSToPath(branch.hardStop, lessonId, `branch_${si}_${bi}_hardstop.mp3`);
|
||||
const hsText = branch.hardStop;
|
||||
const hsFile = `branch_${si}_${bi}_hardstop.mp3`;
|
||||
ttsTasks.push(async () => {
|
||||
const r = await generateTTSToPath(hsText, lessonId, hsFile);
|
||||
if (r) {
|
||||
branch.hardStopAudioPath = r.audioPath;
|
||||
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) {
|
||||
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 prevCostAI = existing?.costAI ?? 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,
|
||||
costBranchAudio,
|
||||
costTotal: prevCostAI + prevCostAudio + costBranchAI + costBranchAudio,
|
||||
branchStatus: "ready",
|
||||
branchStatus,
|
||||
})
|
||||
.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 {
|
||||
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");
|
||||
} else {
|
||||
log(lessonId, "all branch steps failed, branch_status set to error");
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[branches:${lessonId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { randomUUID } from "crypto";
|
|||
import { askAI } from "./openrouter";
|
||||
import { auditCourse } from "./auditCourse";
|
||||
|
||||
const inFlightCourses = new Set<string>();
|
||||
|
||||
type Stage = "parsing_pdfs" | "analysing_sources" | "building_curriculum" | "finalising" | "ready" | "error";
|
||||
|
||||
function log(courseId: string, msg: string) {
|
||||
|
|
@ -18,15 +20,22 @@ async function setStage(courseId: string, stage: Stage) {
|
|||
}
|
||||
|
||||
function parseJSON<T>(raw: string): T {
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(text);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateCourseInBackground(courseId: string) {
|
||||
if (inFlightCourses.has(courseId)) {
|
||||
log(courseId, "already in flight, skipping duplicate call");
|
||||
return;
|
||||
}
|
||||
inFlightCourses.add(courseId);
|
||||
|
||||
try {
|
||||
const course = await db.query.courses.findFirst({ where: eq(courses.id, courseId) });
|
||||
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 ───────────────────────
|
||||
await setStage(courseId, "analysing_sources");
|
||||
|
||||
const allExtracted = [
|
||||
...primaryParts.join("\n\n"),
|
||||
...secondaryParts.join("\n\n"),
|
||||
].join("\n\n");
|
||||
const allExtracted = [primaryParts.join("\n\n"), secondaryParts.join("\n\n")].join("\n\n");
|
||||
|
||||
log(courseId, "inferring course title and subject from documents…");
|
||||
|
||||
|
|
@ -96,10 +102,36 @@ ${allExtracted}`,
|
|||
title: course.title,
|
||||
subject: course.subject,
|
||||
};
|
||||
let inferenceWarning = false;
|
||||
|
||||
try {
|
||||
inferredMeta = parseJSON(inferenceResult.text);
|
||||
} 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}"`);
|
||||
|
|
@ -109,6 +141,7 @@ ${allExtracted}`,
|
|||
title: inferredMeta.title,
|
||||
subject: inferredMeta.subject,
|
||||
...(inferredMeta.organisation != null ? { organisation: inferredMeta.organisation } : {}),
|
||||
...(inferenceWarning ? { inferenceWarning: true } : {}),
|
||||
})
|
||||
.where(eq(courses.id, courseId));
|
||||
|
||||
|
|
@ -119,12 +152,15 @@ ${allExtracted}`,
|
|||
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`);
|
||||
} else {
|
||||
const primaryText = primaryParts.join("\n\n");
|
||||
const secondaryText = secondaryParts.join("\n\n");
|
||||
|
||||
const knownFilenames = new Set(uploadRows.map((u) => u.filename));
|
||||
|
||||
const availableFilesBlock = uploadRows
|
||||
.map((u) => `- ${u.filename} (${u.type})`)
|
||||
.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.`;
|
||||
|
||||
await setStage(courseId, "building_curriculum");
|
||||
log(courseId, "calling OpenRouter for curriculum…");
|
||||
const curriculumResult = await askAI([{ role: "user", content: curriculumPrompt }]);
|
||||
const curriculumModel = (useRuntimeConfig() as any).openrouterCurriculumModel || undefined;
|
||||
log(courseId, `calling OpenRouter for curriculum${curriculumModel ? ` (model: ${curriculumModel})` : ""}…`);
|
||||
|
||||
let curriculumResult = await askAI([{ role: "user", content: curriculumPrompt }], { model: curriculumModel });
|
||||
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) {
|
||||
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:`);
|
||||
curriculum.forEach((t, i) => log(courseId, ` ${i + 1}. ${t.title} (difficulty ${t.difficulty})`));
|
||||
|
||||
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++) {
|
||||
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(),
|
||||
courseId,
|
||||
title: t.title,
|
||||
|
|
@ -192,11 +260,15 @@ The description must be specific about what the student will be able to DO after
|
|||
order: i,
|
||||
difficulty: Math.min(5, Math.max(1, t.difficulty ?? 1)),
|
||||
prerequisiteTopicIds: "[]",
|
||||
relevantFiles: JSON.stringify(t.relevantFiles ?? []),
|
||||
relevantFiles: JSON.stringify(validFiles),
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of topicRows) {
|
||||
await db.insert(topics).values(row);
|
||||
}
|
||||
|
||||
savedTopics = await db.query.topics.findMany({
|
||||
where: eq(topics.courseId, courseId),
|
||||
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) {
|
||||
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));
|
||||
} finally {
|
||||
inFlightCourses.delete(courseId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import { db } from "../db/index";
|
||||
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 { askAI } from "./openrouter";
|
||||
import { generateStepTTS, generateQuestionTTS, generateOptionTTS } from "./generateTTS";
|
||||
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) {
|
||||
console.log(`[lesson:${topicId.slice(0, 8)}] ${msg}`);
|
||||
}
|
||||
|
||||
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 {
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(text);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +31,10 @@ async function generateLessonAudio(
|
|||
): Promise<{ steps: any[]; cost: number }> {
|
||||
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++) {
|
||||
const step = steps[si];
|
||||
|
||||
|
|
@ -33,61 +42,97 @@ async function generateLessonAudio(
|
|||
const text = [step.body, step.callout].filter(Boolean).join(" ");
|
||||
if (!text.trim()) continue;
|
||||
|
||||
tasks.push(async () => {
|
||||
const result = await generateStepTTS(text, lessonId, si);
|
||||
if (result) {
|
||||
step.audioPath = result.audioPath;
|
||||
step.audioChunks = result.audioChunks;
|
||||
cost += result.cost;
|
||||
}
|
||||
log(topicId, ` step ${si} (${step.type}) TTS done`);
|
||||
});
|
||||
|
||||
} else if (step.type === "summary") {
|
||||
const text = Array.isArray(step.bullets) ? step.bullets.join(". ") : "";
|
||||
if (!text.trim()) continue;
|
||||
|
||||
tasks.push(async () => {
|
||||
const result = await generateStepTTS(text, lessonId, si);
|
||||
if (result) {
|
||||
step.audioPath = result.audioPath;
|
||||
step.audioChunks = result.audioChunks;
|
||||
cost += result.cost;
|
||||
}
|
||||
log(topicId, ` step ${si} (summary) TTS done`);
|
||||
});
|
||||
|
||||
} else if (step.type === "question") {
|
||||
if (step.body?.trim()) {
|
||||
tasks.push(async () => {
|
||||
const qResult = await generateQuestionTTS(step.body, lessonId, si);
|
||||
if (qResult) {
|
||||
step.questionAudioPath = qResult.audioPath;
|
||||
step.questionAudioChunks = qResult.audioChunks;
|
||||
cost += qResult.cost;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(step.options)) {
|
||||
step.optionAudioPaths = [];
|
||||
step.optionAudioPaths = new Array(step.options.length).fill(null);
|
||||
|
||||
for (let oi = 0; oi < step.options.length; oi++) {
|
||||
const optText = step.options[oi];
|
||||
const oiCopy = oi;
|
||||
|
||||
if (optText?.trim()) {
|
||||
const oResult = await generateOptionTTS(optText, lessonId, si, oi);
|
||||
tasks.push(async () => {
|
||||
const oResult = await generateOptionTTS(optText, lessonId, si, oiCopy);
|
||||
if (oResult) {
|
||||
step.optionAudioPaths[oi] = oResult.audioPath;
|
||||
step.optionAudioPaths[oiCopy] = oResult.audioPath;
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
// ── Step 1 — mark topic as generating ──────────────────────────────────
|
||||
await db.update(topics).set({ status: "generating" }).where(eq(topics.id, topicId));
|
||||
// ── Step 1 — atomically claim the topic (only if still pending) ─────────
|
||||
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 ───────────────────────────────────────────────
|
||||
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[] }[] = [];
|
||||
|
||||
for (const t of allTopics) {
|
||||
if (t.status !== "ready" || t.order >= topic.order) continue;
|
||||
const l = await db.query.lessons.findFirst({ where: eq(lessons.topicId, t.id) });
|
||||
const priorTopics = allTopics.filter((t) => t.status === "ready" && t.order < topic.order);
|
||||
|
||||
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;
|
||||
try {
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
// load relevant source files
|
||||
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);
|
||||
|
||||
// ── 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}.
|
||||
|
||||
COURSE CONTEXT:
|
||||
|
|
@ -293,11 +356,11 @@ Respond with ONLY valid JSON array, no markdown fences:
|
|||
explanation: string;
|
||||
}[]>(quizResult.text);
|
||||
|
||||
// ── Step 5 — generate TTS for all lesson steps ──────────────────────────
|
||||
// ── Step 5 — commit lesson + quiz + topic status in one transaction ──────
|
||||
const lessonId = randomUUID();
|
||||
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({
|
||||
id: lessonId,
|
||||
topicId: topic.id,
|
||||
|
|
@ -311,33 +374,6 @@ Respond with ONLY valid JSON array, no markdown fences:
|
|||
branchStatus: "pending",
|
||||
});
|
||||
|
||||
log(topicId, `lesson saved (${lessonId}), generating TTS…`);
|
||||
|
||||
let costAudio = 0;
|
||||
try {
|
||||
const { steps: stepsWithAudio, cost: audioCost } = await generateLessonAudio(
|
||||
lessonContent.steps as any[],
|
||||
lessonId,
|
||||
topicId
|
||||
);
|
||||
lessonContent.steps = stepsWithAudio;
|
||||
costAudio = audioCost;
|
||||
} catch (err: any) {
|
||||
console.error(`[lesson] TTS failed for ${lessonId}: ${err?.message ?? err}`);
|
||||
}
|
||||
|
||||
// ── Step 6 — save to DB ─────────────────────────────────────────────────
|
||||
await db.update(lessons)
|
||||
.set({
|
||||
content: JSON.stringify(lessonContent),
|
||||
costAI,
|
||||
costAudio,
|
||||
costTotal: costAI + costAudio,
|
||||
branchStatus: "pending",
|
||||
})
|
||||
.where(eq(lessons.id, lessonId));
|
||||
|
||||
// save quiz questions
|
||||
for (const q of questions) {
|
||||
await db.insert(quizQuestions).values({
|
||||
id: randomUUID(),
|
||||
|
|
@ -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));
|
||||
|
||||
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)}`);
|
||||
|
||||
// ── Step 7 — fire and forget branch generation ──────────────────────────
|
||||
|
|
@ -361,5 +424,9 @@ Respond with ONLY valid JSON array, no markdown fences:
|
|||
} catch (err: any) {
|
||||
console.error(`[lesson:${topicId.slice(0, 8)}] ✗ failed: ${err?.message ?? err}`);
|
||||
await db.update(topics).set({ status: "error" }).where(eq(topics.id, topicId));
|
||||
} 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 { 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 {
|
||||
text: string;
|
||||
|
|
@ -32,6 +60,7 @@ async function callElevenLabs(
|
|||
model_id: "eleven_turbo_v2_5",
|
||||
output_format: "mp3_44100_128",
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -112,6 +141,7 @@ async function callFishAudio(
|
|||
mp3_bitrate: 128,
|
||||
streaming: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
@ -136,15 +166,17 @@ async function callTTS(
|
|||
const config = useRuntimeConfig();
|
||||
const provider = getProvider();
|
||||
|
||||
text = await humaniseTTSText(text);
|
||||
|
||||
if (provider === "fishaudio") {
|
||||
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;
|
||||
return callFishAudio(text, apiKey, voiceId);
|
||||
}
|
||||
|
||||
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;
|
||||
return callElevenLabs(text, apiKey, voiceId);
|
||||
}
|
||||
|
|
@ -236,7 +268,7 @@ export async function generateClip(
|
|||
if (provider === "fishaudio") {
|
||||
const config = useRuntimeConfig();
|
||||
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", {
|
||||
method: "POST",
|
||||
|
|
@ -251,6 +283,7 @@ export async function generateClip(
|
|||
mp3_bitrate: 128,
|
||||
streaming: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
@ -275,6 +308,7 @@ export async function generateClip(
|
|||
output_format: "mp3_44100_128",
|
||||
...(opts?.voice_settings ? { voice_settings: opts.voice_settings } : {}),
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { createPublicKey, verify } from "crypto";
|
|||
|
||||
function getPublicKey() {
|
||||
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({
|
||||
key: Buffer.from(b64, "base64"),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
|
|||
const maxRetries = options.maxRetries ?? 4;
|
||||
|
||||
let lastError: any;
|
||||
let credit402Retries = 0;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
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,
|
||||
...(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 body = err?.data ?? err?.response?._data ?? "(no body)";
|
||||
|
||||
console.error(`[openrouter] ✗ ${elapsed}s — status: ${status ?? "unknown"} | error: ${err?.message}`);
|
||||
if (body && body !== "(no body)") {
|
||||
console.error(`[openrouter] response body:`, JSON.stringify(body).slice(0, 400));
|
||||
}
|
||||
const orErrCode = body?.error?.code ?? body?.error?.type ?? "(unknown)";
|
||||
console.error(`[openrouter] ✗ ${elapsed}s — status: ${status ?? "unknown"} | or-code: ${orErrCode} | error: ${err?.message}`);
|
||||
|
||||
// 402 = insufficient credits — wait 60s and keep retrying indefinitely
|
||||
// 402 = insufficient credits — wait 60s and retry up to 5 times
|
||||
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);
|
||||
attempt--; // don't count against maxRetries
|
||||
attempt--; // dont count against maxRetries
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue