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
This commit is contained in:
renato97
2026-07-25 22:07:28 -03:00
parent 25614704dc
commit a3360ec6e4
67 changed files with 5294 additions and 213 deletions
+80
View File
@@ -0,0 +1,80 @@
import type { Stage } from "../types"
type Prob = {
question: string
answer: number
hint: string
emoji: string
distractors: number[]
}
const problems: Prob[] = [
{ question: "Compraste 3 caramelos a $2 cada uno. ¿Cuánto gastaste?", answer: 6, hint: "3 × 2", emoji: "🍬", distractors: [5, 8] },
{ question: "Tenés $20 y querés comprar 4 juguetes iguales. ¿Cuánto cuesta cada uno?", answer: 5, hint: "20 ÷ 4", emoji: "🧸", distractors: [4, 6] },
{ question: "Un libro cuesta $12 y una lapicera cuesta $3. ¿Cuánto cuestan los dos?", answer: 15, hint: "12 + 3", emoji: "📚", distractors: [14, 16] },
{ question: "Tenés $15 y gastás $7 en un helado. ¿Cuánto te queda?", answer: 8, hint: "15 - 7", emoji: "🍦", distractors: [7, 9] },
{ question: "Un cuaderno cuesta $8. Compraste 3 cuadernos. ¿Cuánto pagaste?", answer: 24, hint: "8 × 3", emoji: "📓", distractors: [22, 27] },
{ question: "Una mesa mide 120 cm de alto. Una silla mide 45 cm. ¿Cuánto miden juntos?", answer: 165, hint: "120 + 45", emoji: "📏", distractors: [155, 175] },
{ question: "Una botella tiene 1 litro de agua. Serviste 4 vasos de 250 ml cada uno. ¿Cuánto serviste?", answer: 1000, hint: "4 × 250", emoji: "💧", distractors: [750, 1250] },
{ question: "Compraste 2 kilos de manzanas a $5 el kilo. ¿Cuánto pagaste?", answer: 10, hint: "2 × 5", emoji: "🍎", distractors: [8, 12] },
{ question: "Un tren tiene 3 vagones. En cada vagón viajan 6 personas. ¿Cuántas personas viajan?", answer: 18, hint: "3 × 6", emoji: "🚂", distractors: [16, 20] },
{ question: "Una pizza tiene 8 porciones. Comiste 3. ¿Cuántas quedan?", answer: 5, hint: "8 - 3", emoji: "🍕", distractors: [4, 6] },
{ question: "Gastaste $50 en el supermercado con $100. ¿Cuánto vuelto recibís?", answer: 50, hint: "100 - 50", emoji: "🛒", distractors: [40, 60] },
{ question: "Un lápiz cuesta $3 y una goma cuesta $2. Compraste 2 lápices y 1 goma. ¿Cuánto pagaste?", answer: 8, hint: "2 × 3 + 2", emoji: "✏️", distractors: [7, 10] },
{ question: "Tu clase tiene 24 alumnos. Los dividís en 4 equipos iguales. ¿Cuántos alumnos por equipo?", answer: 6, hint: "24 ÷ 4", emoji: "👥", distractors: [5, 7] },
{ question: "Una receta usa 250 gramos de harina. Hacés 4 recetas. ¿Cuántos gramos necesitás?", answer: 1000, hint: "250 × 4", emoji: "🧁", distractors: [800, 1200] },
{ question: "Corriste 3 vueltas a la plaza. Cada vuelta es 400 metros. ¿Cuánto corriste?", answer: 1200, hint: "3 × 400", emoji: "🏃", distractors: [1000, 1400] },
{ question: "Una caja tiene 12 huevos. Usaste 4 para cocinar. ¿Cuántos quedan?", answer: 8, hint: "12 - 4", emoji: "🥚", distractors: [7, 9] },
{ question: "Compraste 5 paquetes de figuritas a $3 cada uno. ¿Cuánto pagaste?", answer: 15, hint: "5 × 3", emoji: "🃏", distractors: [14, 18] },
{ question: "Son las 10:00. Pasan 3 horas. ¿Qué hora es?", answer: 13, hint: "10 + 3", emoji: "⏰", distractors: [12, 14] },
{ question: "Una regla mide 30 cm. Tenés 4 reglas iguales puestas en fila. ¿Cuánto miden?", answer: 120, hint: "4 × 30", emoji: "📏", distractors: [110, 130] },
{ question: "Compraste 2 docenas de facturas. ¿Cuántas facturas son?", answer: 24, hint: "2 × 12", emoji: "🥐", distractors: [22, 26] },
{ question: "Tenés $100. Comprás un juguete de $35 y otro de $28. ¿Cuánto gastaste?", answer: 63, hint: "35 + 28", emoji: "🧸", distractors: [60, 65] },
{ question: "En una pecera hay 15 peces. Compraste 8 más. ¿Cuántos peces hay?", answer: 23, hint: "15 + 8", emoji: "🐠", distractors: [22, 24] },
{ question: "Tenés 24 lápices y los repartís entre 6 amigos. ¿Cuántos lápices recibe cada uno?", answer: 4, hint: "24 ÷ 6", emoji: "✏️", distractors: [3, 5] },
{ question: "Gastaste $12 en la mañana y $18 en la tarde. ¿Cuánto gastaste en total?", answer: 30, hint: "12 + 18", emoji: "💰", distractors: [28, 32] },
{ question: "Un camión lleva 45 cajas. Descargaron 18. ¿Cuántas cajas quedan?", answer: 27, hint: "45 - 18", emoji: "🚚", distractors: [26, 28] },
{ question: "Una hormiga camina 5 cm por minuto. ¿Cuánto camina en 7 minutos?", answer: 35, hint: "5 × 7", emoji: "🐜", distractors: [33, 40] },
{ question: "Tenés $50. Compras una remera de $22 y una gorra de $15. ¿Cuánto vuelto recibís?", answer: 13, hint: "50 - 22 - 15", emoji: "👕", distractors: [12, 15] },
{ question: "En un álbum caben 9 figuritas por página. Tenés 6 páginas llenas. ¿Cuántas figuritas tenés?", answer: 54, hint: "9 × 6", emoji: "🖼️", distractors: [52, 56] },
{ question: "Un paquete de galletitas trae 18. Comiste 5. ¿Cuántas quedan?", answer: 13, hint: "18 - 5", emoji: "🍪", distractors: [12, 14] },
{ question: "Ahorraste $8 por semana durante 6 semanas. ¿Cuánto ahorraste?", answer: 48, hint: "8 × 6", emoji: "🏦", distractors: [46, 50] },
]
const exercises: any[] = []
for (let i = 0; i < problems.length; i++) {
const p = problems[i]
const ds = p.distractors.slice()
if (ds.length < 2) {
ds.push(p.answer + 2)
}
exercises.push({
id: `fra10-prob-${i}`,
stage: 10,
skillCode: `prob-dinero-${i}`,
type: "problema",
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
content: {
instruction: "Resolvé el problema:",
text: p.question,
audioText: p.question,
emoji: p.emoji,
options: [
{ label: `$${p.answer}`, correct: true, id: `c-${i}` },
{ label: `$${ds[0]}`, correct: false, id: `w1-${i}` },
{ label: `$${ds[1]}`, correct: false, id: `w2-${i}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: `Resolver problemas con dinero y medidas (vida práctica Montessori): ${p.hint}.`,
errorType: "discriminacion-auditiva",
})
}
const stage: Stage = {
id: 10,
name: "Problemas con dinero y medidas",
description: "Aplicar operaciones en contexto de la vida real con dinero ($), medidas (cm, kg, L) y tiempo.",
exercises,
}
export default stage
+41
View File
@@ -0,0 +1,41 @@
import type { Stage } from "../types"
const TABLAS = [2, 5, 10] as const
const exercises: any[] = []
let idx = 0
for (const t of TABLAS) {
for (let m = 1; m <= 10; m++) {
const correct = t * m
exercises.push({
id: `fra5-tabla${t}-${m}`,
stage: 5,
skillCode: `tabla-${t}-x-${m}`,
type: "eleccion",
prerequisites: idx === 0 ? [] : [exercises[idx - 1]?.id].filter(Boolean),
content: {
instruction: `¿Cuánto es ${t} × ${m}?`,
audioText: `${t} por ${m} es ${correct}`,
emoji: "🔢",
options: [
{ label: String(correct), correct: true, id: String(correct) },
{ label: String(correct + 1), correct: false, id: `w1-${idx}` },
{ label: String(correct + t), correct: false, id: `w2-${idx}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: `Memorizar tabla del ${t} con método Montessori (tabla de Pythagore).`,
errorType: "confusion-letra",
})
idx++
}
}
const stage: Stage = {
id: 5,
name: "Tablas de multiplicar (2, 5, 10)",
description: "Memorizar las tablas de multiplicar del 2, 5 y 10 con material Montessori (tabla de Pythagore, cuentas doradas).",
exercises,
}
export default stage
+41
View File
@@ -0,0 +1,41 @@
import type { Stage } from "../types"
const TABLAS = [3, 4, 6, 7, 8, 9] as const
const exercises: any[] = []
let idx = 0
for (const t of TABLAS) {
for (let m = 1; m <= 10; m++) {
const correct = t * m
exercises.push({
id: `fra6-tabla${t}-${m}`,
stage: 6,
skillCode: `tabla-${t}-x-${m}`,
type: "eleccion",
prerequisites: idx === 0 ? [] : [exercises[idx - 1]?.id].filter(Boolean),
content: {
instruction: `¿Cuánto es ${t} × ${m}?`,
audioText: `${t} por ${m} es ${correct}`,
emoji: "✖️",
options: [
{ label: String(correct), correct: true, id: String(correct) },
{ label: String(correct + t), correct: false, id: `w1-${idx}` },
{ label: String(Math.max(correct - 1, 1)), correct: false, id: `w2-${idx}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: `Memorizar tabla del ${t} con método Montessori (tabla perforada, material de cuentas).`,
errorType: "confusion-letra",
})
idx++
}
}
const stage: Stage = {
id: 6,
name: "Tablas de multiplicar (3, 4, 6, 7, 8, 9)",
description: "Completar todas las tablas de multiplicar restantes con método Montessori.",
exercises,
}
export default stage
+44
View File
@@ -0,0 +1,44 @@
import type { Stage } from "../types"
const mults = [
{ a: 12, b: 3 }, { a: 14, b: 2 }, { a: 21, b: 4 }, { a: 23, b: 3 },
{ a: 15, b: 5 }, { a: 18, b: 2 }, { a: 24, b: 3 }, { a: 31, b: 2 },
{ a: 34, b: 2 }, { a: 25, b: 4 }, { a: 42, b: 2 }, { a: 56, b: 3 },
{ a: 13, b: 6 }, { a: 27, b: 3 }, { a: 32, b: 4 }, { a: 45, b: 2 },
{ a: 11, b: 7 }, { a: 22, b: 4 }, { a: 33, b: 3 }, { a: 41, b: 2 },
{ a: 52, b: 3 }, { a: 63, b: 2 }, { a: 71, b: 4 }, { a: 81, b: 2 },
]
const exercises: any[] = []
for (let i = 0; i < mults.length; i++) {
const m = mults[i]
const res = m.a * m.b
exercises.push({
id: `fra7-mult-${m.a}-${m.b}`,
stage: 7,
skillCode: `mult-${m.a}x${m.b}`,
type: "eleccion",
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
content: {
instruction: `Multiplicá: ${m.a} × ${m.b}`,
audioText: `${m.a} por ${m.b}`,
emoji: "✖️",
options: [
{ label: String(res), correct: true, id: String(res) },
{ label: String(res + m.b), correct: false, id: `w1-${i}` },
{ label: String(res - m.a), correct: false, id: `w2-${i}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: `Multiplicar ${m.a} × ${m.b} con método Montessori (juego de sellos, descomposición decena-unidad).`,
errorType: "confusion-letra",
})
}
const stage: Stage = {
id: 7,
name: "Multiplicación de 2 dígitos × 1 dígito",
description: "Resolver multiplicaciones de 2 dígitos por 1 dígito descomponiendo en decenas y unidades.",
exercises,
}
export default stage
+44
View File
@@ -0,0 +1,44 @@
import type { Stage } from "../types"
const divisions = [
{ a: 12, b: 2 }, { a: 12, b: 3 }, { a: 12, b: 4 }, { a: 15, b: 3 },
{ a: 15, b: 5 }, { a: 16, b: 4 }, { a: 18, b: 2 }, { a: 18, b: 3 },
{ a: 20, b: 4 }, { a: 20, b: 5 }, { a: 24, b: 3 }, { a: 24, b: 4 },
{ a: 25, b: 5 }, { a: 27, b: 3 }, { a: 28, b: 4 }, { a: 30, b: 5 },
{ a: 30, b: 6 }, { a: 32, b: 4 }, { a: 35, b: 5 }, { a: 36, b: 4 },
{ a: 36, b: 6 }, { a: 40, b: 5 }, { a: 42, b: 6 }, { a: 45, b: 5 },
]
const exercises: any[] = []
for (let i = 0; i < divisions.length; i++) {
const d = divisions[i]
const res = d.a / d.b
exercises.push({
id: `fra8-div-${d.a}-${d.b}`,
stage: 8,
skillCode: `div-${d.a}-${d.b}`,
type: "eleccion",
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
content: {
instruction: `Dividí: ${d.a} ÷ ${d.b}`,
audioText: `${d.a} dividido ${d.b}`,
emoji: "➗",
options: [
{ label: String(res), correct: true, id: String(res) },
{ label: String(res + 1), correct: false, id: `w1-${i}` },
{ label: String(Math.max(res - 1, 1)), correct: false, id: `w2-${i}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: `Dividir ${d.a} ÷ ${d.b} con método Montessori (cuentas doradas, reparto equitativo).`,
errorType: "discriminacion-auditiva",
})
}
const stage: Stage = {
id: 8,
name: "Divisiones exactas (1 dígito)",
description: "Resolver divisiones exactas de 1 dígito con material Montessori de reparto equitativo.",
exercises,
}
export default stage
+135
View File
@@ -0,0 +1,135 @@
import type { Stage } from "../types"
type Frac = { num: number; den: number }
function randFrac(): Frac {
const denoms = [2, 3, 4]
const d = denoms[Math.floor(Math.random() * denoms.length)]
const n = Math.floor(Math.random() * (d - 1)) + 1
return { num: n, den: d }
}
function fracLabel(f: Frac): string {
return `${f.num}/${f.den}`
}
// Recognize which fraction is bigger
const compares: { a: Frac; b: Frac; correct: "a" | "b" }[] = []
for (let i = 0; i < 8; i++) {
let a = randFrac()
let b = randFrac()
while (a.num / a.den === b.num / b.den) {
b = randFrac()
}
compares.push({ a, b, correct: a.num / a.den > b.num / b.den ? "a" : "b" })
}
// Identify the fraction (label + visual)
const labels = [
{ frac: { num: 1, den: 2 }, label: "un medio" },
{ frac: { num: 1, den: 3 }, label: "un tercio" },
{ frac: { num: 2, den: 3 }, label: "dos tercios" },
{ frac: { num: 1, den: 4 }, label: "un cuarto" },
{ frac: { num: 3, den: 4 }, label: "tres cuartos" },
{ frac: { num: 2, den: 4 }, label: "dos cuartos" },
]
const exercises: any[] = []
// Part 1: Compare fractions
for (let i = 0; i < compares.length; i++) {
const c = compares[i]
const aVal = c.a.num / c.a.den
const bVal = c.b.num / c.b.den
exercises.push({
id: `fra9-frac-compare-${i}`,
stage: 9,
skillCode: `frac-compare-${i}`,
type: "conciencia",
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
content: {
instruction: `¿Cuál es la fracción más grande?`,
text: `${fracLabel(c.a)} vs ${fracLabel(c.b)}`,
audioText: `${fracLabel(c.a)} o ${fracLabel(c.b)}, cuál es más grande`,
emoji: "🥧",
options: [
{ label: fracLabel(c.a), correct: c.correct === "a", id: `a-${i}` },
{ label: fracLabel(c.b), correct: c.correct === "b", id: `b-${i}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: "Comparar fracciones con método Montessori (círculos de fracciones rojos).",
errorType: "confusion-letra",
})
}
// Part 2: Identify fractions by name
for (let i = 0; i < labels.length; i++) {
const l = labels[i]
const idx = compares.length + i
exercises.push({
id: `fra9-frac-label-${i}`,
stage: 9,
skillCode: `frac-label-${i}`,
prerequisites: idx === 0 ? [] : [exercises[idx - 1].id],
type: "eleccion",
content: {
instruction: `¿Cuál es "${l.label}"?`,
audioText: `${l.label}`,
emoji: "🔵",
options: [
{ label: fracLabel(l.frac), correct: true, id: `c-${i}` },
{ label: fracLabel(labels[(i + 1) % labels.length].frac), correct: false, id: `w1-${i}` },
{ label: fracLabel(labels[(i + 2) % labels.length].frac), correct: false, id: `w2-${i}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: "Identificar fracciones por nombre con método Montessori.",
errorType: "confusion-letra",
})
}
// Part 3: Shade recognition (conciencia)
const shades = [
{ num: 1, den: 2, text: "Si dividís una pizza en 2 partes iguales y tomás 1, ¿qué fracción comiste?" },
{ num: 1, den: 3, text: "Si dividís una pizza en 3 partes iguales y tomás 1, ¿qué fracción comiste?" },
{ num: 2, den: 3, text: "Si dividís una pizza en 3 partes iguales y tomás 2, ¿qué fracción comiste?" },
{ num: 1, den: 4, text: "Si dividís una pizza en 4 partes iguales y tomás 1, ¿qué fracción comiste?" },
{ num: 3, den: 4, text: "Si dividís una pizza en 4 partes iguales y tomás 3, ¿qué fracción comiste?" },
{ num: 2, den: 4, text: "Si dividís una pizza en 4 partes iguales y tomás 2, ¿qué fracción comiste?" },
{ num: 0, den: 0, text: "" },
{ num: 0, den: 0, text: "" },
{ num: 0, den: 0, text: "" },
]
for (let i = 0; i < shades.length; i++) {
if (!shades[i].text) continue
const s = shades[i]
const idx = compares.length + labels.length + i
exercises.push({
id: `fra9-frac-shade-${i}`,
stage: 9,
skillCode: `frac-shade-${i}`,
prerequisites: idx === 0 ? [] : [exercises[idx - 1].id],
type: "conciencia",
content: {
instruction: s.text,
audioText: s.text,
emoji: "🍕",
options: [
{ label: `${s.num}/${s.den}`, correct: true, id: `c-${i}` },
{ label: `${s.den}/${s.num}`, correct: false, id: `w1-${i}` },
{ label: `${s.num + 1}/${s.den}`, correct: false, id: `w2-${i}` },
].sort(() => Math.random() - 0.5),
},
functionalContext: "Reconocer fracciones en contexto de partes de un entero (círculos Montessori).",
errorType: "confusion-letra",
})
}
const stage: Stage = {
id: 9,
name: "Fracciones simples (1/2, 1/3, 1/4)",
description: "Reconocer y comparar fracciones básicas con círculos de fracciones Montessori.",
exercises,
}
export default stage