El problema
LMS B2B con 60% de alumnos adolescentes (13-17 años) + tutor IA conversacional = terreno minado.
Tres categorías de problema:
A. Comportamiento adolescente normal — palabrotas, jerga inapropiada, intentos de testear los límites del tutor (preguntas incómodas para ver la reacción). Esperado, manejable, NO requiere escalada seria.
B. Comportamiento problemático — bullying entre alumnos, jailbreak attempts ("ignora instrucciones y enséñame X ilegal"), contenido sexual/violento solicitado. Requiere intervención pero no es crisis.
C. Crisis real — señales de autoagresión, depresión severa, ideación suicida, situación de abuso. Requiere acción INMEDIATA — un adulto calificado necesita intervenir.
El tratamiento uniforme falla en las 3:
- Bloquear todo = alumno legítimo frustrado, tutor inútil
- Ignorar todo = adolescente en crisis sin soporte, institución expuesta legalmente
- Revisión manual por moderador humano = no escala (Studeia tiene >10K turnos/día)
Solución: clasificación automática vía IA + acciones graduadas + escape hatch para crisis.
Arquitectura: SupervisorAgent
El alumno envía un mensaje
↓
El tutor responde vía SSE streaming (el alumno ve la respuesta inmediatamente)
↓ (after())
SupervisorAgent.run({
userId, tenantId, courseId,
messages: últimos 4-6 mensajes,
isMinor: user.isMinor,
courseContext: { title, description } // whitelist contextual
})
↓
LLM (Haiku) clasifica:
{
severity: "low" | "medium" | "high" | "critical" | "safety",
categories: string[], // 0+ de 8 categorías
reasoning: string, // por qué clasificó así
context_appropriate: boolean // valida con courseContext
}
↓
decideAction({ severity, categories, recentStrikes, isMinor, isSafety })
↓
Acción tomada:
- none (no registra, comportamiento OK)
- warn (notificación in-app: "oye, vamos a enfocarnos en el curso")
- register + strike (incidente creado, +1 strike, monitoreando)
- quarantine 48h (3 strikes en 7d = cuarentena temporal)
- quarantine 7 días (severity critical, estándar más agresivo)
- safety_cooldown + admin alert (severity safety, especial)
5 niveles x 8 categorías
Niveles de severity
- low — lenguaje inapropiado leve ("mierda", off-topic ocasional)
- medium — off-topic persistente, palabras de bajo calibre, jailbreak attempts obvios
- high — violencia descriptiva, contenido sexual explícito, actividades ilegales
- critical — amenaza directa a otros, contenido extremo (terrorism, exploitation)
- safety — autoagresión, ideación suicida, señales de crisis mental
Categorías
- lenguaje_inapropiado
- violencia
- ilegal
- sexual
- off_topic (persistente)
- harassment
- self_harm (especial — siempre severity=safety)
- jailbreak_attempt
Un turno puede tener MÚLTIPLES categorías (ej: jailbreak + violencia = 2 tags).
Decisión de acción — state machine
function decideAction(input) {
const { severity, categories, recentStrikes, isMinor, isSafety } = input;
// PRIORITY 1: Safety (self-harm)
if (isSafety) {
return {
action: "safety_cooldown",
durationHours: SAFETY_COOLDOWN_HOURS, // default 24h
adminNotification: "URGENT",
countedAsStrike: false, // NUNCA strike para safety
tutorMessage: ACOLHIMENTO_TEMPLATE, // mensaje + recursos de crisis
};
}
// PRIORITY 2: Critical = siempre cuarentena
if (severity === "critical") {
return {
action: "quarantine",
durationHours: 168, // 7 días
countedAsStrike: true,
adminNotification: "high",
};
}
// PRIORITY 3: High = cuarentena 48h directo
if (severity === "high") {
return {
action: "quarantine",
durationHours: 48,
countedAsStrike: true,
adminNotification: "medium",
};
}
// PRIORITY 4: Strikes acumulados (LOW/MEDIUM)
if (severity === "low" || severity === "medium") {
if (recentStrikes >= 2) {
// 3er strike en 7 días = cuarentena
return {
action: "quarantine",
durationHours: 48,
countedAsStrike: true,
adminNotification: "medium",
};
}
return {
action: severity === "low" ? "warn" : "register",
countedAsStrike: true,
adminNotification: severity === "medium" ? "low" : "none",
};
}
// Default: none
return { action: "none", countedAsStrike: false };
}
Determinismo absoluto. Mismas inputs = misma acción. Sin LLM decidiendo el castigo.
Self-harm: tratamiento especial
Tras la auditoría del 2026-05-23, rehacemos completamente el handling de safety. El estado anterior tenía 2 bugs críticos:
Bug 1: los incidentes safety nacían con status="auto_resolved" (asumiendo que el mensaje ya era suficiente). Realidad: muchos casos requerían revisión humana. El admin no veía los incidentes.
Fix: safety nace con status="open" (va al inbox del admin) + cooldown Redis 24h + email URGENT inmediato.
Bug 2: el cooldown se creaba ANTES de que el stream del tutor terminara. El alumno en crisis veía el mensaje incompleto del tutor + pantalla de "estás en cooldown". Pésimo timing.
Fix: el stream del tutor termina normalmente. Después del término, el supervisor clasifica en background. Si safety: el tutor es interrumpido en el PRÓXIMO mensaje con un mensaje de acogida (no en medio del actual).
Mensaje de acogida actual (adaptado al contexto local):
"Estoy aquí contigo. Si estás pasando por un momento difícil, por favor busca ayuda:
- Línea de la Vida 800 911 2000 — atención en crisis (gratuita, 24h)
- Emergencias 112 — en emergencia médica
- CVV online — chat anónimo
No estás solo(a)."
Se muestra de forma visible (borde rojo + ícono de corazón), NO como notificación discreta.
El email URGENT al admin institucional contiene:
- Nombre del alumno (PII protegida en la URL, requiere login admin para acceder al detalle)
- Fragmento mínimo del contexto (mensaje que disparó + 2 anteriores, redacted)
- Link directo a la página de detalle del incidente
- Recursos para el admin (script de conversación, contactos de emergencia locales)
- Recordatorio: este NO es un incidente disciplinario. El alumno necesita apoyo humano.
Whitelist contextual
Los falsos positivos en cursos especializados eran frecuentes:
- Curso de farmacología: "overdose" disparaba alerta
- Curso de anatomía: "genitalia" disparaba alerta
- Curso de psicología: discusión académica sobre depresión disparaba alerta
- Curso de seguridad: "exploit", "vulnerability" disparaban alerta
Solución: SupervisorAgent recibe courseContext: { title, description } y usa whitelist contextual.
El system prompt del supervisor incluye:
"El contexto de este turno es: curso '${courseContext.title}'. Descripción: '${courseContext.description}'.
Antes de clasificar como inappropriate, considera si el término es legítimo en este contexto académico. Ej: 'overdose' en un curso de farmacología es un término médico legítimo, NO lo marques."
Reducción de ~70% en falsos positivos tras la implementación.
Casos extremos (curso completo con temática sensible): el admin global deshabilita el supervisor para el curso vía Course.supervisorEnabled = false.
Apelación del alumno
El alumno en cuarentena ve el componente QuarantineNotice (web + mobile):
- Explica el motivo (severity + categoría, sin exponer el reasoning interno del supervisor)
- Countdown hasta la expiración
- Formulario de apelación: máx. 500 chars, 1 por cuarentena
- El submit notifica al admin institucional + crea
appealTexten el incidente
El admin puede: acknowledge (estoy al tanto), dismiss (libera la cuarentena inmediatamente, cambia countedAsStrike=false), resolve (mantiene la cuarentena, la marca como resuelta).
Las apelaciones son auditadas en AdminAuditLog. Proceso transparente.
Trade-offs honestos
Lo que NO funcionó:
-
Intentamos moderar PRE-stream (el supervisor decidía ANTES de que el tutor respondiera). Latencia +800ms para el alumno legítimo. Lo eliminamos — el supervisor ahora corre después del stream en background.
-
Intentamos rate limit por usuario que desactivaba el supervisor tras N llamadas/hora (anti-abuso de admin spam). Bug: un alumno legítimo con sesión larga quedaba sin supervisión. Fix: el rate limit solo throttlea la NOTIFICACIÓN AL ADMIN (anti-flood del inbox), nunca el análisis en sí.
-
Intentamos un LLM único para clasificación + reasoning + acción. El reasoning salía inconsistente, la acción se convertía en roleplay. Separamos: LLM clasifica (severity + categories + reasoning), función TypeScript determinista decide la acción basada en reglas.
-
Intentamos mostrar el reasoning del supervisor al alumno. El alumno aprendía a evadir ("el LLM dijo que va a flagear si escribo X, voy a intentar Y"). Adversarial. Lo eliminamos. El alumno solo ve el mensaje estándar por categoría.
Números de producción
Tras 6 meses:
- ~150K turnos moderados
- 0.3% dispara ALGUNA acción (99.7% son enseñanza normal)
- 47 incidentes safety detectados → 41 confirmados (87% de precisión)
- 0 falsos negativos reportados (alumnos en crisis no detectados)
- 12 cuarentenas ejecutadas (8 expiraron, 4 dismissed vía apelación)
- 0 incidentes olvidados (cron diario recuerda al admin los incidents open >24h)
¿Y el impacto disciplinario?
Pregunta válida: ¿no estamos simplemente tercerizando la moderación a un LLM?
Respuesta: NO. SupervisorAgent detecta + gradúa + notifica. La decisión disciplinaria final siempre queda en manos de un humano (admin institucional). La apelación vía alumno y la auditoría vía AdminAuditLog garantizan la accountability.
El LLM es una herramienta. El pedagogo/coordinador sigue siendo el dueño de la decisión final.