
Proyecto IA Monterroso (XXII): repasar sin repetir, sin cuentas y sin cromos
Esta entrada empieza con una idea que se tira a la basura, y eso también hay que contarlo: no todo lo que se prueba en este proyecto llega a producción tal cual se propone.
La idea que no llegó a nada (y por qué está bien que así fuera)
Después del modo pistas, la idea natural era premiar los aciertos con algo coleccionable: un “cromo” digital con un dato curioso real cada vez que el alumno adivinaba. Se llegó a diseñar y probar en el papel — hasta se investigó que la mecánica de coleccionar está documentada como motor pedagógico real. Pero al planteárselo a José Luis, la respuesta fue clara: “me parece para niños”. Y tenía razón: en un instituto de secundaria, con alumnado de 12 a 18 años, una mecánica de cromos infantiliza algo que puede tener un valor real. Se descarta sin nostalgia.
Lo que sí se rescata de esa idea es la parte de infraestructura, que no tenía nada de infantil: guardar algo por dispositivo, de forma anónima, sin cuentas. Esa pieza se reutiliza entera para algo con más sustancia.
El problema real que sí merecía resolverse
Desde la entrada del modo repaso quedó anotado como pendiente: no había forma de pedir “otra pregunta distinta” sin arriesgarse a que, por pura casualidad, saliera un fragmento ya visto. Con 845 fragmentos en el corpus de Computación y Robótica el riesgo es bajo, pero un alumno que repase varias veces puede llevarse la sensación de que PINSAPA “se repite”, cuando en realidad el temario tiene mucho más que ofrecer.
Antes de decidir cómo, decidir con qué identificar a un alumno
Guardar algo en el servidor exige poder distinguir a un alumno de otro de alguna forma, y ahí es donde había que ser especialmente cuidadoso: este es justo el tipo de decisión donde el proyecto exige advertir de las implicaciones de privacidad antes de avanzar. Se plantearon tres opciones: cuenta real con login (tratamiento de datos de menores en serio, con todo lo que implica legalmente — descartada sin dudar para este proyecto), apodo elegido por el alumno (nadie comprueba que sea apropiado, y puede acabar siendo identificable), o un identificador anónimo generado por el propio navegador, sin ningún dato personal. Se eligió la tercera.
Aviso honesto, no soy abogado: un identificador anónimo por dispositivo es la opción de menor riesgo de protección de datos, pero si esto pasa de prototipo a uso real con alumnado, merece una consulta rápida con el delegado de protección de datos del centro antes de darlo por bueno sin más.
La decisión técnica: SQLite, no un motor de base de datos aparte
Para que ese identificador sirva de algo, hace falta guardarlo en algún sitio que sobreviva a que el navegador se cierre — y ahí sí que hacía falta un cambio de arquitectura de verdad: por primera vez en este proyecto, hay estado nuevo en el servidor. Se descartó Postgres o MySQL (motor de base de datos aparte que instalar y mantener) a favor de SQLite: un fichero, incluido en la librería estándar de Python, sin ningún proceso de servidor de base de datos que arrancar. Para el volumen de un aula es de sobra — antes de aplicar nada se probó con 20 “alumnos” simulados escribiendo a la vez, sin ningún error.
La pieza real, en progreso.py, un módulo nuevo:
def marcar_visto(dispositivo_id, corpus, fragmento_id):
con = _conexion()
try:
con.execute(
"""
INSERT OR IGNORE INTO vistos (dispositivo_id, corpus, fragmento_id, visto_en)
VALUES (?, ?, ?, ?)
""",
(dispositivo_id, corpus, fragmento_id, datetime.now(timezone.utc).isoformat()),
)
con.commit()
finally:
con.close()
Cómo se elige el fragmento sin repetir
En rag.py, cada fragmento necesita un identificador estable — no la posición en la lista, porque cambia si se añaden o quitan documentos, sino un hash de su fuente y su texto:
def id_fragmento(fragmento):
clave = f"{fragmento['fuente']}|{fragmento['texto']}"
return hashlib.sha1(clave.encode("utf-8")).hexdigest()[:16]
def fragmento_aleatorio_no_visto(corpus, ids_vistos):
indice = _INDICES.get(corpus)
if indice is None or not indice["fragmentos"]:
return None, False
candidatos = [f for f in indice["fragmentos"] if id_fragmento(f) not in ids_vistos]
if candidatos:
return random.choice(candidatos), False
return random.choice(indice["fragmentos"]), True # vuelta completa: se reinicia el ciclo
Y en main.py, la pieza que junta las dos cosas, usada tanto por el modo repaso como por el modo pistas:
def _elegir_fragmento_para_juego(corpus, dispositivo_id):
if not dispositivo_id:
return fragmento_aleatorio(corpus), False # /preguntar_simple, sin ese concepto
vistos = progreso.ids_vistos(dispositivo_id, corpus)
fragmento, vuelta_completa = fragmento_aleatorio_no_visto(corpus, vistos)
if fragmento is not None:
progreso.marcar_visto(dispositivo_id, corpus, id_fragmento(fragmento))
return fragmento, vuelta_completa
Importante: nada de esto depende de que el modelo “avise” de si el alumno acierta o no. Lo decide Python antes de hablar con el modelo, exactamente igual que fragmento_aleatorio ya hacía — así que no hereda el riesgo de fiabilidad de formato que sí tenía la idea de los cromos.
El indicador de progreso
Con los mismos datos, un pequeño extra: junto al selector de tema, un indicador tipo “Repaso: 12/845” que se actualiza tras cada respuesta. Es información de progreso, no una insignia — el tipo de “recompensa” que no infantiliza nada.
Cómo se comprobó
En capas, cada una antes de tocar la siguiente: primero progreso.py aislado (escritura, no duplicar, dispositivos separados, 20 escrituras concurrentes sin errores). Después, contra el corpus real de 845 fragmentos, simulé “repasar todo el temario” hasta agotarlo — y salió un hallazgo real, no fabricado: hay un fragmento duplicado de verdad en CyR_2425-1.pdf (la misma cabecera aparece dos veces en el documento), así que el corpus tiene 844 contenidos únicos, no 845, y el sistema lo detecta correctamente como el mismo fragmento. Por último, contra el main.py real ya integrado: dos dispositivos simulados sin contaminarse entre sí, el aviso de “vuelta completa” apareciendo al agotar el corpus más pequeño, y el endpoint /progreso respondiendo bien con y sin dispositivo.
Todo eso antes de aplicarlo a ROCKY. Ya en ROCKY: confirmado por José Luis, funciona.
Instalación
Un módulo nuevo (progreso.py, crea su propio progreso.db la primera vez que se usa), cambios en rag.py (identificador de fragmento y selección sin repetir) y en main.py (la cabecera nueva X-MonteIA-Dispositivo, el endpoint /progreso, y el indicador en la interfaz). Sin dependencias nuevas — sqlite3 viene con Python. Reiniciar la API para que cree la base de datos y cargue el código actualizado.
Cómo comprobarlo
Pedir varias rondas seguidas de modo repaso o modo pistas con el mismo tema: el indicador “Repaso: X/Y” debe ir subiendo sin bajar ni quedarse quieto, y no debería tocarte el mismo fragmento dos veces hasta agotar el temario entero.
Qué queda pendiente
El identificador vive en el navegador: si el alumno cambia de dispositivo o borra los datos de navegación, su progreso no le sigue, empieza de cero en ese navegador nuevo. Es una limitación conocida y aceptada, no un descuido — la alternativa (cuentas reales) traía consigo mucho más de lo que este proyecto quiere manejar por ahora. Tampoco hay todavía ninguna vista para el profesorado (por ejemplo, qué fragmentos fallan más en la clase, de forma agregada y anónima): sería un uso natural de estos mismos datos, pero depende de saber si el alumno acertó o no, y eso sí exige que el modelo colabore con algún aviso — el mismo riesgo de fiabilidad que ya se descartó para los cromos. Se estudiará si hace falta, no antes.





