Cómo construí un auditor de rúbricas que corre en el navegador
Una tarea universitaria llega con dos textos: el enunciado (qué hay que entregar) y la rúbrica (cómo se califica). El estudiante termina su documento y se queda con una duda razonable: ¿cubrí todo lo que el docente pidió, en el orden que lo pidió? CrasterDoc intenta contestar eso antes de entregar, con una restricción de diseño que condiciona todo lo demás: todo corre en el navegador y la parte que lee el enunciado no usa IA.
Este artículo cuenta cómo está armado ese flujo, con ejemplos del código real (src/lib/academico/). Lo que no cuento aquí lo puedes ver en la página Cómo se construyó CrasterDoc.
El contrato: de texto suelto a una especificación
Todo el flujo gira alrededor de un tipo, EspecificacionActividad. Es lo que sale de leer el enunciado y lo que el auditor usa después para revisar el documento:
interface Requisito {
id: string // "ENT-1", "PASO-5", "C2", "FMT-FUENTE"…
texto: string // lo que el docente exige, en una frase
origen: 'paso' | 'entregable' | 'rubrica' | 'formato' | 'portada'
criterio?: string // criterio de la rúbrica que lo puntúa
verificacion: 'automatico' | 'manual'
}
La palabra importante es verificacion. Algunos requisitos se pueden comprobar con el texto del documento (hay una sección de referencias, la portada trae el grupo). Otros solo los puede confirmar la persona («el video dura menos de cinco minutos»). Esa distinción atraviesa el resto del diseño: lo que el programa no puede comprobar se marca como manual y nunca se da por cumplido.
Leer un enunciado copiado de Moodle
Los enunciados no llegan limpios: vienen pegados de Moodle o de un PDF, con tildes inconsistentes, rayas largas, viñetas raras. Buscar con expresiones regulares sobre ese texto es frágil, y normalizarlo (minúsculas, sin tildes) rompe los índices para recortar el original.
La solución es pequeña y vale la pena copiarla. La función normalizarTexto produce una copia de la misma longitud que el original, carácter por carácter:
export function normalizarTexto(texto: string): string {
let out = ''
for (let i = 0; i < texto.length; i++) {
const c = texto[i]
if (/[‐-―−]/.test(c)) {
out += '-' // todas las rayas pasan a "-"
continue
}
const base = c.normalize('NFD')[0] ?? c // quita la tilde
const minus = base.toLowerCase()
out += minus.length === 1 ? minus : base
}
return out
}
Como la copia tiene la misma longitud, un índice encontrado en la versión normalizada sirve tal cual para cortar el texto original y mostrar al estudiante la frase con sus tildes y mayúsculas. Ese pequeño truco evita guardar dos mapas de posiciones.
La rúbrica: tres formas de decir lo mismo
Cada docente escribe la rúbrica a su manera. extraerRubrica prueba tres estrategias en orden: una tabla pegada («Criterio | Excelente | Aceptable | Insuficiente»), criterios con puntos por nivel, o niveles por línea. De ahí sale una escala real, no una inventada:
export function escalaDeRubrica(rubrica: CriterioRubrica[]): EscalaRubrica | undefined {
const total = rubrica.reduce((s, c) => s + c.maximo, 0)
const conPeso = rubrica.every((c) => c.peso !== undefined)
// …
if (conPeso && Math.abs(total - 100) < 0.5) return { tipo: 'porcentaje', total }
if (etiquetas) return { tipo: 'cualitativa', total, etiquetas }
return { tipo: 'puntos', total }
}
Puntos, porcentajes o niveles cualitativos («Excelente», «Bueno»…): el puntaje que ve el estudiante usa la escala del docente, no una genérica de 0 a 100.
Asociar cada requisito con un criterio
Un requisito como «incluir referencias en formato APA» tiene que terminar bajo el criterio correcto de la rúbrica. No hay IA: asociarCriterio compara raíces de palabras. Primero reduce cada palabra a una raíz aproximada (quita plurales y terminaciones, corta a siete letras), descarta palabras vacías y genéricas («trabajo», «documento»), y puntúa:
const p =
3 * similitud(r, rc.nombre) + // coincide con el nombre del criterio
similitud(rs, rc.nombre) + // coincide con el título de la sección
Math.min(3, similitud(r, rc.descriptores)) // y con lo que dicen los niveles
if (p >= 3 && (!mejor || p > mejor.p)) mejor = { c, p }
Si ningún criterio llega al umbral de 3, el requisito queda sin criterio. Preferí que algo quede suelto a que se asigne mal.
El auditor: hallazgos y una estimación honesta
Con la especificación en mano, el auditor revisa el documento (o el texto de un PDF ya exportado) y devuelve dos cosas.
Hallazgos con severidad. P0 es lo que no debería salir así (datos [PENDIENTE: …] sin completar, restos del texto de la plantilla), P1 y P2 son mejoras. Si hay un P0 abierto, el resultado es bloqueante y el PDF se exporta con la marca BORRADOR.
Una estimación por criterio. Cada criterio recoge los «items» que le corresponden (secciones presentes, portada completa, referencias bien formadas…) y los promedia. Después aEscala lleva esa proporción al nivel real de la rúbrica con una regla deliberadamente conservadora:
// El nivel máximo solo si todo lo verificable está completo;
// si falta algo, como mucho el anterior.
const tope = niveles.length - 1
const indice = p >= 0.999 ? tope : Math.min(tope - 1, Math.round(p * tope))
Y lo más importante: cuando un criterio depende de algo que el programa no puede juzgar, devuelve nivelEstimado: null con la explicación «lo juzga el docente», y esos puntos no suman. Hay incluso reglas explícitas contra el autoengaño: sin ninguna sección escrita, ni la portada ni el formato de la plantilla aportan puntos; sin enlace de video, un criterio que lo incluye no puede pasar del 20 %.
El resultado es un puntaje que es una estimación, etiquetada así en la interfaz, y que puede decir «te faltan estas tres cosas para el máximo». Eso es más útil que un número que parece exacto.
Por qué en el navegador y sin IA
- Privacidad. El enunciado y el documento de un estudiante no salen de su equipo. Solo salen si la persona usa de forma explícita el asistente de IA opcional o la copia en Drive.
- Determinismo. El mismo enunciado produce siempre los mismos requisitos, y eso se puede probar. El repositorio tiene fixtures sintéticos de enunciados (un ensayo de historia, un laboratorio de física, un proyecto de software) y pruebas que fijan lo que el extractor debe encontrar.
- Sin costo por consulta. No hay clave que gastar ni límite que explicar para la parte principal.
La IA queda para lo que sí necesita lenguaje (redactar un borrador), y es opcional.
Lo que aprendí
- Modelar la incertidumbre como dato.
verificacion: "manual"ynivelEstimado: nullobligan a la interfaz a ser honesta; no hay forma de olvidarse de ellos. - Heurísticas pequeñas y testeables ganan a una regla universal. Raíces de palabras, un umbral, una copia normalizada de igual longitud: cada una se explica en una línea y tiene su prueba.
- Un auditor conservador genera confianza. Prefiero que diga «no evaluable» a que celebre una nota que el docente después no va a dar.
CrasterDoc es gratuito y no pide cuenta: puedes probarlo en crasterdoc.morelodev.com. Si quieres saber quién lo hace, está la página Sobre el autor, y el detalle del producto en su ficha en este portafolio.