En desarrollo · 2026
OpoStudy — Oposiciones con repetición espaciada
Preparación de oposiciones para Mossos d’Esquadra. Las diez fases hechas: diseño, backend, catálogo, repetición espaciada, sesiones, simulacros, administración con revisión editorial, generación con IA y observabilidad. Con interfaz para estudiar, examinarse y revisar contenido. Desplegado, y sin una sola pregunta todavía. Todavía no hay interfaz de estudio ni despliegue.
- Rol
- Diseño y desarrollo
- Fases completadas
- 10 de 10
- Registros de decisión
- 7
- Tests
- 285
Problema
Preparar una oposición es retener mucha información durante meses. Un test en PDF no sabe qué has fallado: repites lo que ya dominas y no vuelves a ver lo que erraste hace tres semanas, que es justo lo que se te ha olvidado.
Y las aplicaciones de test genéricas presentan igual una pregunta sacada del BOE y una inventada. Estudiar sobre contenido incorrecto es peor que no estudiar: consolida el error y no se nota hasta el examen.
Decisión
Escribir el diseño antes que el código. Siete registros de decisión y cinco documentos de arquitectura —modelo de datos, API, algoritmo, despliegue— están fechados antes de la primera migración: unas 3.600 líneas decidiendo qué entidades existen y por qué las relaciones son las que son.
De ahí salieron tres decisiones de modelado que se pueden discutir. La primera: «subtema» no llegó a ser una tabla. Es la misma entidad que «tema», con las mismas columnas y las mismas relaciones, y el tercer nivel que tienen los temarios oficiales habría pedido una tercera tabla. Un árbol con parent_id admite profundidad arbitraria; a cambio, recorrerlo en SQL es más incómodo que un join.
La segunda: la tabla de progreso del usuario no se creó. Es una tabla de agregados, o sea una caché, y una caché sin un problema de rendimiento medido añade una segunda fuente de verdad que se puede desincronizar con la primera. Los números no la piden: doscientas preguntas al día son unas 73.000 filas por usuario y año, y agrupar eso con un índice tarda milisegundos. Hay un umbral escrito para revisar la decisión —100 ms en el percentil 95 de la consulta del panel— en lugar de dejarla abierta.
La tercera: la numeración de un tema vive en la relación, no en el tema. «Derecho penal» es el Tema 3 en Mossos y el Tema 2 en Policía Local. Si el número fuese una columna del tema habría que elegir uno, incorrecto en todas las oposiciones menos una, o duplicar el tema con todas sus preguntas detrás. El código de temario está en la tabla pivote.
Implementación
Los invariantes están en el motor y no en el código de la aplicación. Una regla que solo vive en la aplicación se salta desde una importación masiva, desde un seeder o desde una consola de producción.
Un índice único parcial sobre answer_options (question_id) where is_correct hace imposible que una pregunta tenga dos respuestas correctas. Ese estado corrupto no da ningún síntoma: la aplicación corregiría mal en silencio y falsearía las estadísticas de quien estudia, y es el fallo de datos más caro de detectar a posteriori.
Una restricción CHECK impide publicar una pregunta generada por un modelo de lenguaje sin revisor asignado. Eso convierte lo que era una intención escrita en un documento de decisión en una garantía que un job con un fallo lógico no puede saltarse.
Corregir ocurre en el servidor, y la respuesta correcta no sale hacia fuera antes de contestar. Hay dos clases de recurso —una para contestar y otra para revisar— en lugar de una con un parámetro «incluir solución», porque ese parámetro acaba pasándose mal desde algún sitio y el fallo sería silencioso: nadie ve un JSON de más, se ve una aplicación que funciona. Once tests recorren el cuerpo entero de la respuesta buscando la solución, no una clave concreta.
Un intento de respuesta es un hecho ocurrido: no tiene updated_at y guarda si se acertó en lugar de deducirlo al leer. Deducirlo significaría que corregir una pregunta mal redactada cambia retroactivamente el pasado de todo el que ya la contestó, y con él las estadísticas que le dicen si va preparado.
Las opciones se barajan de forma estable por usuario y pregunta. Si quien redacta —o el modelo que genera— tiende a escribir la correcta en primer lugar, devolverlas ordenadas por identificador la delata sin que ninguna respuesta contenga la solución; y si el orden cambiara en cada recarga, la opción que alguien tenía a medio leer saltaría de sitio.
El algoritmo de repetición espaciada está escrito como función pura: sin base de datos debajo, sin contenedor de dependencias y sin reloj propio —el instante entra por parámetro y el aleatorizador por constructor—. Eso es lo que permite simular diez mil repasos encadenados y comprobar los invariantes después de cada uno. Con la misma lógica dentro de un controlador, esa prueba serían diez mil peticiones HTTP y no la escribiría nadie.
Escribir esa simulación corrigió el documento de diseño en dos sitios. El documento justificaba el desplazamiento aleatorio de las fechas diciendo que sin él la carga diaria sería «cero durante seis días y doscientas el séptimo». Medido, el pico peor pasa de 1.91 a 1.62 veces la mediana de su vecindad: el desplazamiento mejora, pero el argumento asumía que las preguntas introducidas el mismo día reciben la misma secuencia de calificaciones, y no la reciben. Se queda, porque mejora y no cuesta nada, pero la afirmación fuerte no era cierta y está corregida con los números.
La segunda corrección fue del propio test. La primera versión comparaba cada día con la mediana de los ciento ochenta y denunciaba un pico de seis veces que no existía: la carga decae —el corpus deja de crecer y los intervalos se alargan—, así que estaba midiendo la pendiente y no los picos. Un umbral ajustado a esa medición mal planteada habría quedado tan flojo que el test no habría vuelto a detectar nada.
La sesión de estudio mezcla cuatro orígenes —repaso vencido, temas flojos, preguntas nuevas y aleatorias— y ahí la decisión que hay que defender es que la mezcla es una petición y no una garantía. Quien empieza no tiene repasos vencidos ni temas flojos, porque no ha contestado nada: aplicar los porcentajes al pie de la letra le daría siete preguntas de las treinta que pidió. Lo que un origen no puede cubrir pasa al siguiente por prioridad.
Y el reparto no se puede hacer contando primero y eligiendo después, que es el camino evidente. Los orígenes se solapan: una pregunta de un tema flojo que además no se ha visto nunca cuenta como flojo y como nueva a la vez, así que sumando los cuatro recuentos salen más preguntas de las que existen y el reparto asigna huecos que luego solo se pueden llenar repitiendo. Se recorre una sola vez, en orden, descartando lo ya elegido.
El diseño guardaba la mezcla pedida para poder comparar resultados entre mezclas dentro de unos meses. No se podía: sabías que habías pedido un 40% de repasos, no si llegó a haberlos. Se guarda también de qué origen salió cada pregunta, que es lo que hace comprobable la razón por la que se guardaba la primera.
Los tests corren contra PostgreSQL 17 real y no sobre SQLite en memoria, precisamente porque SQLite no tiene ninguna de esas dos capacidades. Allí, el test que comprueba que una pregunta no puede tener dos correctas pasaría en verde sin haber comprobado nada — y un test que da confianza sin probar nada es peor que no tenerlo.
Estado
Las diez fases del roadmap, hechas. Backend: catálogo, repetición espaciada, sesiones de estudio con composición configurable, simulacros cronometrados con penalización, administración con flujo de revisión editorial, generación con IA detrás de una interfaz de proveedor y observabilidad. Interfaz en Next.js para estudiar, examinarse, ver el progreso y revisar contenido. 285 tests con 139.000 aserciones, de los cuales 195 corren contra PostgreSQL real, y PHPStan en nivel 6 sin errores.
Desplegar destapó seis fallos que ni los 285 tests ni la construcción en local podían encontrar: réplicas de un servicio incompatibles con el nombre fijo que pone el orquestador; montajes de ficheros del repositorio que Docker convierte en directorios vacíos cuando el origen no existe; una validación de nginx que resuelve DNS todavía inexistente; el fichero de hosts en solo lectura durante la construcción; una variable de dominio que el orquestador no lee sino que genera; y un sembrador que creaba un usuario de prueba con correo conocido en producción, que solo falló por accidente. Es la clase de fallo que no aparece sin desplegar de verdad.
Lo que sigue sin estar: contenido. Ni una pregunta. El sembrador no las inventa a propósito, así que la aplicación funciona y todavía no sirve para estudiar. Tampoco hay proveedor de IA configurado: el flujo de generación está entero y el generador por defecto falla diciendo qué falta, en lugar de devolver preguntas inventadas que irían a la cola de revisión indistinguibles de las reales.
El seeder tampoco siembra preguntas, y es deliberado: una pregunta inventada para rellenar es contenido incorrecto con apariencia de correcto, que es justo lo que este proyecto existe para evitar. Los temas y las cinco normas sí son reales, y se siembran sin verificar, porque «sin verificar» y «verificada y correcta» no son lo mismo.