Markr (2): el motor, donde el MCER manda y la IA propone
En la primera entrada conté por qué tiré el prototipo de 2025. Esta va sobre el corazón de la nueva versión: el motor que recibe una redacción y devuelve los errores, las palabras y estructuras con su nivel y la nota.
Es también la entrada donde cuento el error más importante del día: la IA se inventó niveles del MCER, y yo casi no me doy cuenta.
Un motor que no sabe nada de Django
La primera decisión de arquitectura fue que el motor (engine/) es una librería de Python pura, sin dependencia de Django. La web solo lo envuelve. Eso permite probarlo desde la terminal:
uv run python -m engine redaccion.txt --level B1
Y, sobre todo, permite escribir tests y scripts de evaluación del motor sin levantar base de datos, colas ni servidor web.
El reparto del trabajo
texto ─► spaCy: frases numeradas, lemas, categorías engine/nlp.py
├► ocurrencias del fichero MCER + acepciones engine/reference.py
├► Claude (salida JSON validada con Pydantic) engine/llm.py
│ acepción de cada palabra ambigua
│ errores · estructuras propuestas · coherencia
├► anclaje: citas literales → posiciones engine/anchor.py
├► fusión de fuentes (gana el MCER) engine/pipeline.py
└► nota 0–10 engine/scoring.py
Cada pieza hace lo que mejor sabe hacer:
- spaCy (el modelo pequeño,
en_core_web_sm) es determinista y rápido. Separa el texto en frases numeradas ([S1],[S2]…) y da el lema y la categoría de cada palabra. - El fichero MCER (el English Vocabulary Profile, unas 15.000 acepciones) se busca en el texto por lema y categoría. Así made up his mind encuentra make up your mind, y went encuentra go. Esto no usa IA.
- Claude hace lo que el código no puede hacer bien: decidir en qué sentido usa el alumno una palabra, detectar errores, reconocer estructuras gramaticales y valorar la coherencia.
- El código se encarga de todo lo que tiene que ser exacto: posiciones en el texto, niveles oficiales y nota.
Claude nunca da posiciones
Un modelo de lenguaje es malo contando caracteres. Si le pides que diga "el error está entre la posición 143 y la 151", acabarás subrayando cosas que no son.
Por eso la salida de Claude, definida con modelos de Pydantic, nunca contiene posiciones. Para cada error devuelve el número de frase y el fragmento copiado literalmente:
class LLMError(BaseModel):
model_config = ConfigDict(extra="forbid")
sentence: int = Field(description="Número de la frase [S<n>] donde está el error.")
quote: str = Field(description="Fragmento erróneo copiado literalmente del texto, mínimo.")
correction: str
category: ErrorCategory # grammar, spelling, vocabulary, word_order, punctuation
severity: Severity # minor, moderate, serious
explanation: str = Field(description="Explicación breve en español para el alumno.")
El servidor busca esa cita en la frase indicada (tolerando comillas tipográficas y espacios distintos). Si la frase viene mal numerada pero la cita es única en el texto, también vale. Si la cita no aparece, la anotación se descarta y queda registrada en un diagnóstico. Así es imposible resaltar algo que el alumno no escribió.
La salida estructurada también ayuda: la API valida la respuesta contra el esquema y el extra="forbid" hace que cualquier campo inesperado sea un error, no un dato silencioso.
El error: niveles inventados
La primera versión del motor funcionó a la primera de forma sospechosa. Para detectar estructuras, Claude había escrito un catálogo de unas 55 estructuras gramaticales de A1 a C2, con su nivel, "de forma provisional". Los textos de prueba salían bien, la landing enseñaba ejemplos con niveles...
Entonces pregunté: "¿Los textos de prueba tienen esos matches reales con el fichero de CEFR que te he dado? ¿Lo puedes demostrar?"
La respuesta fue que no. El catálogo estaba escrito de memoria, sin contrastarlo con mi fichero. Al cruzar las expresiones que sí aparecen en el EVP:
| Expresión | Nivel del catálogo inventado | Nivel en el EVP |
|---|---|---|
| despite / in spite of | B2 | B1 |
| would para hábitos pasados | B1 | B2 |
| however | B2 | A2 |
| had better | B2 | A2 |
| even though | B1 | B2 |
| as if / as though | C2 | B2 |
| used to | B1 | B1 ✓ |
| no sooner … than | C1 | C1 ✓ |
12 de 18 no coincidían. Y varios de esos niveles erróneos estaban en la demo de la landing, presentados como criterio MCER.
Es el tipo de error que más miedo da con la IA: no falla de forma escandalosa, sino que produce algo plausible, coherente consigo mismo y falso. Los tests pasaban porque comprobaban que el sistema era coherente con su propio catálogo.
La regla de fuentes
La solución fue una regla que propuse yo y que acabó siendo la mejor decisión de diseño del proyecto: distinguir siempre dos fuentes de nivel.
- MCER (oficial). Las palabras y expresiones de mi fichero. El nivel, la definición y el ejemplo salen siempre del fichero. Cuando una palabra tiene acepciones de distinto nivel (would es A1 en would like, B1 en would (IF) y B2 en would (OFTEN)), Claude solo elige cuál de las acepciones del fichero usa el alumno. Si elige un identificador que no estaba entre los candidatos, se descarta.
- IA (propuesta). Las estructuras que el fichero no recoge (presente perfecto, pasiva, oraciones escindidas…) las detecta Claude y propone un nivel. En pantalla se marcan como "Propuesto por la IA" con subrayado discontinuo, y en la nota pesan la mitad.
- Si coinciden, gana el MCER. Si una estructura que detecta la IA contiene una entrada del fichero que define esa misma estructura (un segundo condicional con if (MIGHT)), se queda con el nivel del fichero. Se guarda también lo que proponía la IA, por transparencia.
El catálogo inventado se borró. Y para que no vuelva a pasar, hay tests que fallan si cualquier nivel que se presente como MCER no coincide con el fichero: en el motor, en los textos de evaluación, en la demo de la landing y en la escalera de niveles. Son tests que no comprueban código, sino la honestidad del producto.
El claim de la landing salió de aquí: "El MCER pone el criterio. La IA, la velocidad."
Un matiz que destapó la primera prueba real
Al corregir un texto B1 con la regla nueva apareció un fallo sutil. Claude enlazó el estilo indirecto (que proponía como B2) con la entrada tell (SPEAK) del fichero, que es A1. Por la regla de fusión, la estructura bajó a A1.
El arreglo fue concretar la instrucción: solo se enlazan entradas que definen la propia estructura, no palabras sueltas que aparecen dentro con su significado normal. El prompt incluye ejemplos de enlaces válidos (used to, if (MIGHT), would (OFTEN)) y de inválidos, y termina con un "en caso de duda, deja la lista vacía". Al repetir la prueba, el estilo indirecto se quedó como propuesta de la IA, como debía.
La nota, en código
La nota no la pone el modelo. Se calcula así:
nota = 0,4 · precisión + 0,4 · rango + 0,2 · coherencia
- Precisión: errores ponderados por gravedad (leve 0,5, moderado 1, grave 2) cada 100 palabras.
- Rango: el nivel más alto con al menos tres evidencias distintas bien usadas, comparado con el nivel objetivo del alumno. Las del MCER cuentan 1 y las propuestas por la IA, 0,5. Repetir la misma palabra no suma: rango es variedad.
- Coherencia: la da Claude de 0 a 10, con criterio del nivel objetivo.
La nota es relativa al nivel del alumno: el mismo texto puntúa más para un B1 que para un C1. Todos los pesos están en un ScoringConfig para poder calibrarlos.
Y aquí está lo que queda pendiente. Con los textos de prueba, el nivel según el MCER sale un nivel por encima del pretendido de B1 a C1, porque bastan tres palabras de un nivel para "alcanzarlo". Un B1 con addicted, moreover y tool (B2 en el fichero) llega a B2 aunque su gramática sea de B1. No quise elegir los números a ojo: eso se calibra con 20 o 30 redacciones reales corregidas por profesores, que es lo siguiente que necesito conseguir.
Medir, no suponer
Para eso existe eval/run_eval.py: compara la salida del motor con textos anotados y mide cuántos errores y entradas del MCER encuentra, cuántos se inventa y la diferencia de nota. Hay siete textos de prueba, uno por nivel de A1 a C2 más uno corto, que simulan alumnos hispanohablantes con sus errores típicos (I have 12 years, depends of, I am agree…).
Hay que ser honestos con lo que miden: esas anotaciones las hizo Claude, no un profesor. Sirven para detectar regresiones y comparar modelos, no como verdad absoluta.
En la próxima entrada salgo del motor y cuento la aplicación web: la subida de ficheros con sus límites de seguridad, el editor de notas a prueba de XSS y cómo compartir una corrección con un enlace y un PIN.