Tutor IA
¿Qué es?
Section titled “¿Qué es?”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.
Arquitectura
Section titled “Arquitectura”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'))Componentes frontend
Section titled “Componentes frontend”| Archivo | Descripción |
|---|---|
apps/web/src/components/lesson/TutorPanel.tsx | Panel lateral principal. Gestiona el input, SSE streaming, mensajes, rating, historial y barra de uso |
apps/web/src/store/tutorSlice.ts | Slice Redux con todo el estado del tutor en memoria |
apps/web/src/components/dashboard/TutorActivityWidget.tsx | Widget en el dashboard del estudiante con KPIs de actividad mensual y sparkline semanal |
apps/web/src/components/dashboard/TutorHistoryModal.tsx | Modal con historial paginado de sesiones — se abre desde el widget del dashboard |
apps/web/src/store/api/aiApi.ts | Endpoints RTK Query del tutor (ver sección siguiente) |
Subcomponentes dentro de TutorPanel
Section titled “Subcomponentes dentro de TutorPanel”| Componente | Rol |
|---|---|
MsgText | Renderer Markdown mínimo: **negrita**, `código`, saltos de línea |
StreamingCursor | Cursor parpadeante animado (CSS tutorBlink) durante el streaming |
TypingDots | Tres puntos animados (CSS tutorBounce) mientras la respuesta está en tránsito antes de que llegue el primer chunk |
RatingBtn | Botón thumbs-up / thumbs-down con estado activo visual |
SSE Streaming
Section titled “SSE Streaming”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)) // ... }}Eventos SSE emitidos por el backend
Section titled “Eventos SSE emitidos por el backend”type | Campos adicionales | Acción en el frontend |
|---|---|---|
chunk | text: string | dispatch(appendStreamChunk(text)) — acumula en streamingText |
done | messageId, provider, model, latencyMs | dispatch(finalizeAssistantMessage(...)) — mueve streamingText a messages[] |
error | code: 'SERVICE_UNAVAILABLE' | dispatch(setStatus('error')) — muestra caja de error con botón “Reintentar” |
Detección de respuesta lenta
Section titled “Detección de respuesta lenta”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.
Estado Redux (tutorSlice)
Section titled “Estado Redux (tutorSlice)”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.
Historial de conversaciones
Section titled “Historial de conversaciones”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:
- Panel lateral — drawer deslizable activado por el botón ☰. Carga
GET /ai/tutor/my-history?page=1&limit=20solo cuandoshowHistory === true(lazy loading via RTK Queryskip). - Dashboard —
TutorHistoryModalcon paginación acumulativa (botón “Cargar más” → incrementapage).
Ambos usan useGetTutorHistoryQuery del aiApi.
Rating de respuestas
Section titled “Rating de respuestas”Solo las respuestas del asistente (excepto el mensaje de bienvenida) muestran los botones thumbs-up / thumbs-down.
Flujo:
- El usuario hace clic en 👍 o 👎.
- Se llama
dispatch(setRating({ messageId, rating }))→ actualización optimista inmediata en Redux. - Se invoca
rateMessage({ messageId, rating })(RTK Query mutation →POST /ai/tutor/rate). - 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).
Límites por plan
Section titled “Límites por plan”| Plan | Límite mensual | Comportamiento al agotar |
|---|---|---|
FREE | 3 preguntas | Se oculta el input y aparece CTA “Ver planes ↗” con enlace a /upgrade |
STARTER | 40 preguntas | Igual que FREE al agotar |
PRO | Sin límite (0) | Nunca muestra barra de uso |
ELITE | Sin límite (0) | Nunca muestra barra de uso |
CORPORATE_USER | Sin límite (0) | Nunca muestra barra de uso |
CORPORATE_ADMIN | Sin límite (0) | Nunca muestra barra de uso |
CONTENT_ADMIN | Sin límite (0) | Nunca muestra barra de uso |
SUPER_ADMIN | Sin 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.
RTK Query endpoints (en aiApi.ts)
Section titled “RTK Query endpoints (en aiApi.ts)”| Hook | Endpoint | Descripción |
|---|---|---|
useGetTutorSessionInfoQuery | GET /ai/tutor/session-info | Proveedor y modelo activo para el badge del panel |
useGetTutorUsageQuery | GET /ai/tutor/my-usage | KPIs para el TutorActivityWidget del dashboard |
useGetTutorHistoryQuery | GET /ai/tutor/my-history | Historial paginado de sesiones |
useRateTutorMessageMutation | POST /ai/tutor/rate | Valorar un mensaje del asistente |
useGetTutorSuggestionsQuery | GET /ai/tutor/suggestions | Sugerencias de arranque para la lección activa |
useGetTutorMonthlyUsageQuery | GET /ai/tutor/monthly-usage | Contador 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).
Endpoints backend
Section titled “Endpoints backend”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:
- Configura headers SSE (
Content-Type: text/event-stream,X-Accel-Buffering: no). - Si
lessonIdpresente, carga el contenido Markdown de la lección (truncado a 8 000 chars) para el contexto. - Llama
AIBackendClient.callTutor(TutorPayload)de forma síncrona (espera respuesta completa del LLM). - Persiste la conversación en
TutorConversation+ dosTutorMessage(user + assistant) con metadatos de latencia, provider y model. - Emite la respuesta palabra a palabra con un delay de 25 ms entre palabras.
- Finaliza con el evento
doneque incluye elmessageIdpersistido.
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.
POST /ai/tutor/rate
Section titled “POST /ai/tutor/rate”interface RateMessageDto { messageId: string rating: 'up' | 'down'}GET /ai/tutor/suggestions?lessonId=
Section titled “GET /ai/tutor/suggestions?lessonId=”Devuelve tres sugerencias hardcoded. La futura versión consultará sugerencias por lessonId en BD.
GET /ai/tutor/monthly-usage
Section titled “GET /ai/tutor/monthly-usage”Devuelve { used: number; limit: number; plan: string }. Cuenta mensajes con role: 'user' desde el inicio del mes calendario.
GET /ai/tutor/my-history?page=&limit=
Section titled “GET /ai/tutor/my-history?page=&limit=”Paginación offset, limit por defecto 20. Retorna sesiones con nombre del módulo resuelto.
GET /ai/tutor/my-usage
Section titled “GET /ai/tutor/my-usage”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).
GET /ai/tutor/session-info
Section titled “GET /ai/tutor/session-info”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' }.
Contexto de la lección
Section titled “Contexto de la lección”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.tsxuseEffect(() => { 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.
Persistencia en BD
Section titled “Persistencia en BD”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? ← ídemCada 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.