- Add Profile enum (ISABELLA, FRANCESCA, SEBASTIAN) to Child model - Add ?profile= query param override to /api/curriculum/next - Pass profile prop from profile-specific pages to ExerciseSession - Fix curriculum import paths (engine uses @/curriculum/ alias) - Clear stale curriculumCache on dev reload - Fix self-referential prerequisites in Francesca etapa-1 - Fix prerequisite chains in Francesca etapa-2,3,4 and Sebastian etapa-2,3 - Add Francesca curriculum: 2-3 digit sums, subtractions, multiplications, short readings - Add Sebastian curriculum: 2-3 digit sums, interactive exercises, Argentine history, flags - All 44 E2E tests pass, TypeScript compiles cleanly
169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
import { prisma } from "@/lib/db"
|
|
import type { Exercise, Stage } from "@/curriculum/types"
|
|
|
|
const STAGE_THRESHOLD = 0.8
|
|
|
|
const PROFILE_CURRICULA: Record<string, Record<string, { folder: string; stages: number[] }>> = {
|
|
ISABELLA: {
|
|
lectura: { folder: "lectura", stages: [1, 2, 3, 4, 5, 6, 7, 8, 9] },
|
|
numeros: { folder: "numeros", stages: [1, 2, 3, 4, 5, 6, 7, 8] },
|
|
},
|
|
FRANCESCA: {
|
|
matematicas: { folder: "francesca", stages: [1, 2, 3, 4] },
|
|
lecturas: { folder: "francesca-lectura", stages: [1, 2] },
|
|
},
|
|
SEBASTIAN: {
|
|
sumas: { folder: "sebastian", stages: [1, 2, 3] },
|
|
historia: { folder: "sebastian-historia", stages: [1, 2] },
|
|
banderas: { folder: "sebastian-banderas", stages: [1] },
|
|
},
|
|
}
|
|
|
|
const curriculumCache: Record<string, Record<string, Stage>> = {}
|
|
|
|
async function getStagesForProfile(
|
|
profile: string,
|
|
topic: string,
|
|
): Promise<Record<string, Stage>> {
|
|
const key = `${profile}-${topic}`
|
|
// Clear stale cache on every dev call (Next.js hot-reload shares module-level vars)
|
|
if (curriculumCache[key]) delete curriculumCache[key]
|
|
|
|
if (curriculumCache[key]) return curriculumCache[key]
|
|
|
|
const config = PROFILE_CURRICULA[profile]?.[topic]
|
|
if (!config) return {}
|
|
|
|
const stages: Record<string, Stage> = {}
|
|
for (const s of config.stages) {
|
|
try {
|
|
const mod = await import(`@/curriculum/${config.folder}/etapa-${s}`)
|
|
stages[String(s)] = mod.default
|
|
} catch {
|
|
// stage not yet implemented
|
|
}
|
|
}
|
|
|
|
curriculumCache[key] = stages
|
|
return stages
|
|
}
|
|
|
|
async function ensureInitialStage(childId: string, topic: string) {
|
|
try {
|
|
const exists = await prisma.curriculumProgress.findFirst({
|
|
where: { childId, topic },
|
|
})
|
|
if (!exists) {
|
|
await prisma.curriculumProgress.create({
|
|
data: { childId, topic, stage: 1 },
|
|
})
|
|
}
|
|
} catch {
|
|
// Child may not exist yet in FK constraint — proceed gracefully
|
|
}
|
|
}
|
|
|
|
export async function getCurrentStage(childId: string, topic: string) {
|
|
await ensureInitialStage(childId, topic)
|
|
const progress = await prisma.curriculumProgress.findFirst({
|
|
where: { childId, topic },
|
|
orderBy: { stage: "asc" },
|
|
})
|
|
return progress
|
|
}
|
|
|
|
export async function getNextExercise(
|
|
childId: string,
|
|
topic: string,
|
|
profile: string = "ISABELLA",
|
|
): Promise<Exercise | null> {
|
|
await ensureInitialStage(childId, topic)
|
|
|
|
const progress = await prisma.curriculumProgress.findFirst({
|
|
where: { childId, topic, completedAt: null },
|
|
orderBy: { stage: "asc" },
|
|
})
|
|
|
|
if (!progress) return null
|
|
|
|
const stages = await getStagesForProfile(profile, topic)
|
|
const stage = stages[String(progress.stage)]
|
|
if (!stage || !stage.exercises.length) return null
|
|
|
|
const allAttemps = await prisma.skillAttempt.findMany({
|
|
where: { exerciseId: { not: null }, sessionLog: { childId } },
|
|
select: { exerciseId: true, correct: true },
|
|
})
|
|
|
|
const mastered = new Set(
|
|
allAttemps
|
|
.filter((a) => a.correct)
|
|
.map((a) => a.exerciseId),
|
|
)
|
|
|
|
const attempted = new Set(allAttemps.map((a) => a.exerciseId))
|
|
|
|
const available = stage.exercises.filter((ex) => {
|
|
if (mastered.has(ex.id)) return false
|
|
const prereqsMet = ex.prerequisites.every((p) => mastered.has(p))
|
|
return prereqsMet
|
|
})
|
|
|
|
if (available.length === 0) {
|
|
if (attempted.size > 0 && stage.exercises.length > 0) {
|
|
const masteryRatio = mastered.size / stage.exercises.length
|
|
if (masteryRatio >= STAGE_THRESHOLD) {
|
|
await advanceStage(childId, topic, progress.stage)
|
|
return getNextExercise(childId, topic, profile)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const fsrsCards = await prisma.fsrsCard.findMany({
|
|
where: { childId, skillCode: { in: available.map((e) => e.skillCode) } },
|
|
})
|
|
|
|
const dueMap = new Map(fsrsCards.map((c) => [c.skillCode, c.dueAt.getTime()]))
|
|
const now = Date.now()
|
|
|
|
const unsorted = [...available]
|
|
unsorted.sort((a, b) => {
|
|
const dueA = dueMap.get(a.skillCode) ?? 0
|
|
const dueB = dueMap.get(b.skillCode) ?? 0
|
|
const isDueA = dueA <= now ? 0 : 1
|
|
const isDueB = dueB <= now ? 0 : 1
|
|
if (isDueA !== isDueB) return isDueA - isDueB
|
|
if (!attempted.has(a.id) && !attempted.has(b.id)) return 0
|
|
if (!attempted.has(a.id)) return -1
|
|
if (!attempted.has(b.id)) return 1
|
|
return dueA - dueB
|
|
})
|
|
|
|
return unsorted[0] || null
|
|
}
|
|
|
|
async function advanceStage(childId: string, topic: string, currentStage: number) {
|
|
await prisma.curriculumProgress.updateMany({
|
|
where: { childId, topic, stage: currentStage },
|
|
data: { completedAt: new Date() },
|
|
})
|
|
const nextStage = currentStage + 1
|
|
const exists = await prisma.curriculumProgress.findUnique({
|
|
where: { childId_topic_stage: { childId, topic, stage: nextStage } },
|
|
})
|
|
if (!exists) {
|
|
await prisma.curriculumProgress.create({
|
|
data: { childId, topic, stage: nextStage },
|
|
})
|
|
}
|
|
}
|
|
|
|
export function getErrorTypeForExercise(
|
|
exercise: Exercise,
|
|
correct: boolean,
|
|
): string | null {
|
|
if (correct) return null
|
|
return exercise.errorType || "discriminacion-auditiva"
|
|
}
|