Files
edueasy/lib/curriculum/engine.ts
T
renato97 a3360ec6e4 feat: fase 2+3 — expansion masiva multi-perfil + bug fixes + hardening
Fase 2 (expansion):
- Francesca: +6 etapas matematicas (5-10), +7 etapas lectura (3-9), +2 ortografia
- Sebastian: +8 etapas matematicas (4-11), +6 lectura, +3 historia, +3 geografia, +1 banderas
- Nuevas paginas: ortografia, lectura, geografia, matematicas
- ~900 ejercicios nuevos, paridad ~500 por perfil

Fase 3 (bug fixes + hardening):
- Fix: SessionLog auto-create en grade route
- Fix: engine cache (no borrar antes de revisar)
- Fix: FSRS cards actualizadas en flujo curriculum
- Fix: responseMs real (no hardcodeado 3000)
- Fix: options dentro de content en sebastian etapa-1
- Fix: trazo-letra consonantes crash
- Security: execFileSync anti-command-injection en TTS
- DB: 18 cascade deletes + indices + unique constraints
- APIs debug: reset, state, seed
- Docker: espeak-ng + prisma + user/group
- E2E: 23 tests nuevos (curriculum-flow.spec.ts)
- Code review: tailwind colors, middleware dev mode, eslint config

Verificado: tsc exit 0, build exit 0, 61 E2E tests pass
2026-07-25 22:07:28 -03:00

171 lines
5.2 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, 5, 6, 7, 8, 9, 10] },
lecturas: { folder: "francesca-lectura", stages: [1, 2, 3, 4, 5, 6, 7, 8, 9] },
ortografia: { folder: "francesca-ortografia", stages: [1, 2] },
},
SEBASTIAN: {
sumas: { folder: "sebastian", stages: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] },
matematicas: { folder: "sebastian", stages: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] },
lectura: { folder: "sebastian-lectura", stages: [1, 2, 3, 4, 5, 6] },
historia: { folder: "sebastian-historia", stages: [1, 2, 3, 4, 5] },
geografia: { folder: "sebastian-geografia", stages: [1, 2, 3] },
banderas: { folder: "sebastian-banderas", stages: [1, 2] },
},
}
const curriculumCache: Record<string, Record<string, Stage>> = {}
export async function getStagesForProfile(
profile: string,
topic: string,
): Promise<Record<string, Stage>> {
const key = `${profile}-${topic}`
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"
}