Skip to content

Tutor IA

El Tutor IA es un panel lateral de 380 px de ancho fijo disponible en el contexto de estudio (/learn/:pathSlug/:lessonId). Permite al estudiante hacer preguntas sobre el contenido de la lección activa y recibir respuestas generadas por un LLM con acceso al contexto de la lección (RAG).

El panel se abre mediante un FAB flotante (botón 🤖, posición fixed bottom: 76px right: 24px) que aparece cuando el panel está cerrado. Una vez abierto, ocupa el lateral derecho de la pantalla junto al contenido de la lección.


sequenceDiagram
participant U as Usuario (browser)
participant FE as TutorPanel<br/>(React)
participant API as apps/api<br/>POST /ai/tutor/stream
participant AIB as apps/ai-backend<br/>callTutor()
participant LLM as LLM Provider<br/>(Anthropic/OpenAI/Gemini)
U->>FE: Escribe pregunta + Enter
FE->>FE: dispatch(addUserMessage)
FE->>FE: dispatch(setStatus('streaming'))
FE->>API: fetch POST /ai/tutor/stream<br/>{moduleId, lessonId, message, conversationHistory}
API->>API: Carga contenido lección (Prisma)<br/>Trunca a 8 000 chars
API->>AIB: callTutor(TutorPayload)
AIB->>LLM: Llamada LLM + RAG Qdrant
LLM-->>AIB: Respuesta completa
AIB-->>API: { answer }
API->>API: persistConversationWithMeta()<br/>→ TutorConversation + TutorMessage x2
loop word-by-word (delay 25ms)
API-->>FE: data: {"type":"chunk","text":"palabra "}
FE->>FE: dispatch(appendStreamChunk)
end
API-->>FE: data: {"type":"done","messageId","provider","model","latencyMs"}
FE->>FE: dispatch(finalizeAssistantMessage)
FE->>FE: dispatch(setStatus('idle'))

ArchivoDescripción
apps/web/src/components/lesson/TutorPanel.tsxPanel lateral principal. Gestiona el input, SSE streaming, mensajes, rating, historial y barra de uso
apps/web/src/store/tutorSlice.tsSlice Redux con todo el estado del tutor en memoria
apps/web/src/components/dashboard/TutorActivityWidget.tsxWidget en el dashboard del estudiante con KPIs de actividad mensual y sparkline semanal
apps/web/src/components/dashboard/TutorHistoryModal.tsxModal con historial paginado de sesiones — se abre desde el widget del dashboard
apps/web/src/store/api/aiApi.tsEndpoints RTK Query del tutor (ver sección siguiente)
ComponenteRol
MsgTextRenderer Markdown mínimo: **negrita**, `código`, saltos de línea
StreamingCursorCursor parpadeante animado (CSS tutorBlink) durante el streaming
TypingDotsTres puntos animados (CSS tutorBounce) mientras la respuesta está en tránsito antes de que llegue el primer chunk
RatingBtnBotón thumbs-up / thumbs-down con estado activo visual

El frontend no usa EventSource estándar porque el endpoint es POST (envía el body con la pregunta). En su lugar usa la Fetch Streams API directamente:

const response = await fetch(`${API_BASE}/ai/tutor/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('fl_token') ?? ''}`,
},
body: JSON.stringify({ moduleId, lessonId, message, conversationHistory }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const event = JSON.parse(line.slice(6))
// ...
}
}
typeCampos adicionalesAcción en el frontend
chunktext: stringdispatch(appendStreamChunk(text)) — acumula en streamingText
donemessageId, provider, model, latencyMsdispatch(finalizeAssistantMessage(...)) — mueve streamingText a messages[]
errorcode: 'SERVICE_UNAVAILABLE'dispatch(setStatus('error')) — muestra caja de error con botón “Reintentar”

Si el backend no emite el primer chunk en 8 000 ms, se activa isSlowResponse = true y se muestra el aviso ”⏳ Esto está tardando un poco más de lo normal…”. El flag se limpia cuando llega el primer chunk o al resetear el estado.


interface TutorState {
messages: TutorMessage[] // historial de la sesión actual (incluye el mensaje de bienvenida)
streamingText: string // texto acumulado del chunk en curso
isOpen: boolean // visibilidad del panel
lastModuleId: string | null
lastLessonId: string | null
status: 'idle' | 'streaming' | 'error'
isSlowResponse: boolean
showHistory: boolean // activa el drawer de historial sobre el panel
usageThisMonth: number
usageLimit: number // 0 = ilimitado
usagePlan: string
}

El mensaje de bienvenida (id 'welcome') es estático y se personaliza con el primer nombre del usuario (setWelcomeName). Los mensajes con id === 'welcome' no muestran botones de rating.


Cada envío crea una nueva TutorConversation en BD con los dos mensajes asociados (user + assistant). No existe agrupación automática en sesión — cada pregunta genera su propia conversación.

El historial se consulta desde dos puntos:

  1. Panel lateral — drawer deslizable activado por el botón ☰. Carga GET /ai/tutor/my-history?page=1&limit=20 solo cuando showHistory === true (lazy loading via RTK Query skip).
  2. DashboardTutorHistoryModal con paginación acumulativa (botón “Cargar más” → incrementa page).

Ambos usan useGetTutorHistoryQuery del aiApi.


Solo las respuestas del asistente (excepto el mensaje de bienvenida) muestran los botones thumbs-up / thumbs-down.

Flujo:

  1. El usuario hace clic en 👍 o 👎.
  2. Se llama dispatch(setRating({ messageId, rating })) → actualización optimista inmediata en Redux.
  3. Se invoca rateMessage({ messageId, rating }) (RTK Query mutation → POST /ai/tutor/rate).
  4. El backend valida que el mensaje pertenece al usuario (tutorMessage.userId === user.sub) antes de persistir.

Si la llamada a la API falla, el estado optimista en Redux se mantiene igual (no hay rollback).


PlanLímite mensualComportamiento al agotar
FREE3 preguntasSe oculta el input y aparece CTA “Ver planes ↗” con enlace a /upgrade
STARTER40 preguntasIgual que FREE al agotar
PROSin límite (0)Nunca muestra barra de uso
ELITESin límite (0)Nunca muestra barra de uso
CORPORATE_USERSin límite (0)Nunca muestra barra de uso
CORPORATE_ADMINSin límite (0)Nunca muestra barra de uso
CONTENT_ADMINSin límite (0)Nunca muestra barra de uso
SUPER_ADMINSin límite (0)Nunca muestra barra de uso

La barra de uso (progress bar de 4 px) solo se renderiza cuando usageLimit > 0. El color cambia según el porcentaje usado: verde (var(--fl-pr)) hasta 79 %, ámbar hasta 94 %, rojo a partir del 95 %.

El contador se obtiene del endpoint GET /ai/tutor/monthly-usage y se sincroniza al estado Redux en el primer render del panel.


HookEndpointDescripción
useGetTutorSessionInfoQueryGET /ai/tutor/session-infoProveedor y modelo activo para el badge del panel
useGetTutorUsageQueryGET /ai/tutor/my-usageKPIs para el TutorActivityWidget del dashboard
useGetTutorHistoryQueryGET /ai/tutor/my-historyHistorial paginado de sesiones
useRateTutorMessageMutationPOST /ai/tutor/rateValorar un mensaje del asistente
useGetTutorSuggestionsQueryGET /ai/tutor/suggestionsSugerencias de arranque para la lección activa
useGetTutorMonthlyUsageQueryGET /ai/tutor/monthly-usageContador y límite mensual del usuario activo

El streaming no pasa por RTK Query — usa fetch directo por necesidad (POST + respuesta chunked que RTK Query no maneja nativamente).


Todos requieren JwtAuthGuard. Controlador: AITutorController en apps/api/src/modules/ai/tutor.controller.ts.

POST /ai/tutor/stream — Streaming SSE principal

Section titled “POST /ai/tutor/stream — Streaming SSE principal”
interface StreamDto {
moduleId?: string
lessonId?: string
message: string
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
}

Flujo interno:

  1. Configura headers SSE (Content-Type: text/event-stream, X-Accel-Buffering: no).
  2. Si lessonId presente, carga el contenido Markdown de la lección (truncado a 8 000 chars) para el contexto.
  3. Llama AIBackendClient.callTutor(TutorPayload) de forma síncrona (espera respuesta completa del LLM).
  4. Persiste la conversación en TutorConversation + dos TutorMessage (user + assistant) con metadatos de latencia, provider y model.
  5. Emite la respuesta palabra a palabra con un delay de 25 ms entre palabras.
  6. Finaliza con el evento done que incluye el messageId persistido.

POST /ai/tutor/chat — Chat no-streaming (legacy)

Section titled “POST /ai/tutor/chat — Chat no-streaming (legacy)”

Endpoint original, no streaming. Devuelve { answer: string } de forma síncrona. Persiste la conversación asíncronamente (fire-and-forget). No expone metadatos de provider/model al cliente.

interface RateMessageDto {
messageId: string
rating: 'up' | 'down'
}

Devuelve tres sugerencias hardcoded. La futura versión consultará sugerencias por lessonId en BD.

Devuelve { used: number; limit: number; plan: string }. Cuenta mensajes con role: 'user' desde el inicio del mes calendario.

Paginación offset, limit por defecto 20. Retorna sesiones con nombre del módulo resuelto.

Estadísticas del dashboard: sesiones del mes (días distintos con actividad), total de preguntas, módulo más consultado y actividad semanal (8 semanas).

Consulta AIRouteConfig para el use case tutor_chat y devuelve el proveedor y modelo activos. Si no hay config, devuelve { provider: 'anthropic', model: 'claude-sonnet-4-5' }.


El endpoint POST /ai/tutor/stream inyecta el contenido de la lección activa en el payload enviado a apps/ai-backend:

if (dto.lessonId) {
const lesson = await this.prisma.lesson.findUnique({
where: { id: dto.lessonId },
select: { title: true, contentMd: true, moduleLessons: { ... } },
})
if (lesson) {
currentModuleContent = `# ${lesson.title}\n\n${content.slice(0, 8000)}`
}
}

El frontend sincroniza automáticamente el contexto al Redux store cuando cambia de lección:

// En LessonPlayerPage.tsx
useEffect(() => {
if (lesson && lessonId) {
dispatch(setModuleContext({ moduleId: lesson.module.id, lessonId }))
}
}, [lesson?.id, lessonId, dispatch])

El historial de conversación enviado en cada request se limita a los últimos 10 mensajes de la sesión actual, excluyendo el mensaje de bienvenida.


TutorConversation
├── id (uuid)
├── userId
├── moduleId?
├── lessonId?
└── messages: TutorMessage[]
TutorMessage
├── id (uuid)
├── conversationId
├── userId
├── role: 'user' | 'assistant'
├── content
├── rating: 'up' | 'down' | null
├── provider? ← solo en mensajes del asistente (vía streaming)
├── model? ← ídem
└── latencyMs? ← ídem

Cada llamada al endpoint de streaming crea una TutorConversation nueva con dos TutorMessage. No hay continuación de conversación entre peticiones — el historial conversacional se pasa como array en cada request.