diff --git a/Cargo.lock b/Cargo.lock index 0e71450..1a77545 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,6 +298,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "bcrypt", "chrono", "common", diff --git a/README.md b/README.md index daf7a61..4942235 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **AI Audio Evaluation**: Evaluación inteligente de pronunciación y contenido con feedback en lenguaje natural. - **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.). - **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada. - - **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots). + - **Gamified Activities**: Nuevos tipos de bloques interactivos incluyendo Juegos de Memoria (con generación automática por IA) y Puntos Calientes (Hotspots). - **Course Marketing & Summary**: Sistema de metadatos estructurados (objetivos, requisitos) y landing pages premium para una mejor presentación de cursos. - **Global AI Task Dashboard**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar tareas de IA en segundo plano (transcripciones, quices, etc). - **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual. diff --git a/roadmap.md b/roadmap.md index b5fa243..4e6310f 100644 --- a/roadmap.md +++ b/roadmap.md @@ -135,7 +135,7 @@ - [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas con feedback de IA detallado (Completado) - [x] **Eliminación de Cursos**: Gestión completa del ciclo de vida del contenido (Completado) - [x] **Quices con Contexto IA**: Generación de evaluaciones con enfoque y tipo personalizable (Completado) -- [x] **Actividades Gamificadas**: Nuevos bloques de Juego de Memoria e Identificación Visual (Hotspots) (Completado) +- [x] **Actividades Gamificadas**: Nuevos tipos de bloques interactivos incluyendo Juegos de Memoria (con generación automática por IA) y Puntos Calientes (Hotspots). (Completado) - [x] **Marcadores de Video**: Preguntas que pausan el video en timestamps específicos (Completado) ## Fase 12: Generador de Cursos "Mágico" con IA ✅ @@ -221,15 +221,15 @@ ## Fase 19: Presentación Visual y Marketing de Cursos ✅ - [x] **Metadatos de Marketing Estructurados**: Captura de objetivos, requisitos, público objetivo y certificación en Studio. (Completado) - [x] **Premium Course Summary**: Nueva interfaz de "Acerca del Curso" en Experience con diseño de alta fidelidad y navegación por pestañas. (Completado) -- [x] **Dashboard Global de Tareas AI**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar todas las generaciones en segundo plano (videos, transcripciones, quices, etc). (Completado) +- [x] **Dashboard Global de Tareas AI**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar todas las generaciones en segundo plano (transcripciones, quices, juegos de memoria, etc). (Completado) --- **Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos** y **Landing Pages de Cursos (Marketing) automatizadas**. -## Fase 20: IA Generativa Avanzada (En Diseño) 🧠 -- [ ] **Generación de Juegos de Memoria Conceptuales**: Creación automática de parejas (concepto/definición) a partir de transcripciones. -- [ ] **Simulaciones de Rol y Diálogos Ramificados**: Motor de escenarios interactivos con respuestas dinámicas de la IA. +## Fase 20: IA Generativa Avanzada (En Ejecución) 🧠 +- [x] **Generación de Juegos de Memoria Conceptuales**: Creación automática de parejas (concepto/definición) a partir de transcripciones. (Completado) +- [x] **Simulaciones de Rol y Diálogos Ramificados**: Motor de escenarios interactivos con respuestas dinámicas de la IA. (Completado) - [ ] **Auto-Hotspots Pedagógicos**: Identificación automática de puntos de interés en imágenes con descripciones técnicas. - [ ] **Diagramas de Mermaid Dinámicos**: Visualización automática de procesos y mapas mentales a partir del contenido de la lección. - [ ] **Laboratorios de Código con Hints de IA**: Generación de desafíos de programación con pistas contextuales basadas en errores. @@ -239,6 +239,6 @@ **Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos** y **Landing Pages de Cursos (Marketing) automatizadas**. **Próximas Prioridades**: -1. **Conceptual Memory Match (IA)**: Implementar el primer generador de actividades interactivas. +1. **Auto-Hotspots Pedagógicos**: Identificación automática de puntos de interés en imágenes. 2. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1. 3. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos. diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index 8fb25fe..3b34c86 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -27,3 +27,4 @@ openidconnect.workspace = true anyhow.workspace = true zip = "0.6" mime_guess = "2.0" +base64 = "0.22.1" diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 2751711..771bc13 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -17,7 +17,9 @@ use common::models::{ use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; +use base64::{engine::general_purpose, Engine as _}; use std::env; +use reqwest::header::HeaderMap; use uuid::Uuid; use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; @@ -1720,10 +1722,251 @@ pub async fn reorder_lessons( tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(StatusCode::OK) } +#[derive(Deserialize)] +pub struct GenerateHotspotsPayload { + pub image_url: String, + pub prompt_hint: Option, +} + +pub async fn generate_hotspots( + Org(org_ctx): Org, + _claims: common::auth::Claims, + State(pool): State, + Path(lesson_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + // 1. Resolve image path + // imageUrl in frontend is like "/assets/filename.ext" + // We need to map it to "uploads/filename.ext" + let filename = payload.image_url.split('/').last().unwrap_or_default(); + if filename.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + let storage_path = format!("uploads/{}", filename); + + // 2. Read and encode image + let image_data = tokio::fs::read(&storage_path).await.map_err(|e| { + tracing::error!("Failed to read image at {}: {}", storage_path, e); + StatusCode::NOT_FOUND + })?; + + let base64_image = general_purpose::STANDARD.encode(image_data); + let mime_type = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string(); + let image_url_data = format!("data:{};base64,{}", mime_type, base64_image); + + // 3. Fetch lesson context (optional but helpful for AI) + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // 4. Setup AI Request + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llava:latest".to_string()); // Default to llava for vision + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4o".to_string(), + ) + }; + + let system_prompt = "Eres un experto en análisis visual pedagógico. \ + Tu tarea es identificar los puntos de interés más importantes en la imagen proporcionada \ + que sean relevantes para una lección educativa. \ + \ + Para cada punto identificado, proporciona: \ + - Un nombre o etiqueta corta (label). \ + - Una descripción técnica o pedagógica de lo que es o su función. \ + - Coordenadas X e Y en porcentaje (0-100) respecto a la imagen. (x: 0 es izquierda, 100 es derecha; y: 0 es arriba, 100 es abajo). \ + \ + RESPONDE ÚNICAMENTE CON UN ARRAY JSON DE OBJETOS CON EL SIGUIENTE FORMATO: \ + [ \ + { \"label\": \"Nombre\", \"description\": \"Descripción\", \"x\": 50.5, \"y\": 20.0 } \ + ]"; + + let user_prompt = format!( + "Analiza esta imagen para la lección: {0}. {1}", + lesson.title, + payload.prompt_hint.as_deref().unwrap_or("Identifica los componentes técnicos o partes clave de la imagen.") + ); + + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", "application/json".parse().unwrap()); + if !auth_header.is_empty() { + headers.insert("Authorization", auth_header.parse().unwrap()); + } + + let response = client.post(&url) + .headers(headers) + .json(&json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": [ + { "type": "text", "text": format!("{}\n\n{}", system_prompt, user_prompt) }, + { "type": "image_url", "image_url": { "url": image_url_data } } + ] + } + ], + "response_format": { "type": "json_object" }, + "temperature": 0.2 + })) + .send() + .await + .map_err(|e| { + tracing::error!("AI request failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let ai_text = response.text().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let ai_json: serde_json::Value = serde_json::from_str(&ai_text).map_err(|e| { + tracing::error!("Failed to parse AI response: {}. Text: {}", e, ai_text); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // OpenAI and some local servers return { "choices": [ { "message": { "content": "..." } } ] } + let content = ai_json["choices"][0]["message"]["content"].as_str() + .ok_or_else(|| { + tracing::error!("Unexpected AI response format: {:?}", ai_json); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Attempt to parse the content as JSON (it should be an array) + let hotspots: serde_json::Value = if let Ok(parsed) = serde_json::from_str(content) { + parsed + } else { + // Fallback: try to find the array in the text if AI wrapped it in markdown or something + if let Some(start) = content.find('[') { + if let Some(end) = content.rfind(']') { + serde_json::from_str(&content[start..=end]).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + } else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + Ok(Json(hotspots)) +} + +#[derive(Deserialize)] +pub struct GenerateRolePlayPayload { + pub prompt_hint: Option, +} + +pub async fn generate_role_play( + Org(org_ctx): Org, + _claims: common::auth::Claims, + State(pool): State, + Path(lesson_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + // 1. Fetch lesson context + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + let content_text = lesson.summary.as_ref() + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| format!("Lesson: {}", lesson.title)); + + // 2. Setup AI Request + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4o".to_string(), + ) + }; + + let system_prompt = "Eres un experto diseñador de instrucciones y pedagogía. \ + Tu tarea es crear un escenario de juego de rol interactivo basado en el contenido de la lección proporcionada. \ + El escenario debe permitir al estudiante practicar conceptos clave en un entorno realista. \ + \ + RESPONDE ÚNICAMENTE CON UN OBJETO JSON VÁLIDO CON EL SIGUIENTE FORMATTO: \ + { \ + \"title\": \"Título de la simulación\", \ + \"scenario\": \"Descripción detallada del entorno y la situación\", \ + \"ai_persona\": \"Quién es la IA y qué actitud debe tener\", \ + \"user_role\": \"Quién es el estudiante en esta situación\", \ + \"objectives\": \"Qué debe lograr el estudiante\", \ + \"initial_message\": \"El primer mensaje que la IA enviará para iniciar la conversación\" \ + } \ + \ + Asegúrate de que el escenario sea desafiante pero apropiado para el nivel del estudiante."; + + let user_prompt = format!( + "Contenido de la lección: {0}\n\nSugerencia del usuario: {1}", + content_text, + payload.prompt_hint.as_deref().unwrap_or("Crea un escenario relevante para practicar los temas de la lección.") + ); + + let response = client.post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt } + ], + "response_format": { "type": "json_object" }, + "temperature": 0.7 + })) + .send().await + .map_err(|e| { + tracing::error!("AI Role-Play generation request failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + tracing::error!("AI API error: {}", err_body); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let ai_data: serde_json::Value = response.json().await.map_err(|e| { + tracing::error!("Failed to parse AI response: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let content = ai_data["choices"][0]["message"]["content"].as_str().ok_or_else(|| { + tracing::error!("AI response missing content field"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let parsed_json: serde_json::Value = serde_json::from_str(content).map_err(|e| { + tracing::error!("Failed to parse content as JSON: {}. Content: {}", e, content); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(parsed_json)) +} + #[derive(Deserialize)] pub struct AuthPayload { pub email: String, diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 6707ff1..e0ea856 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -147,6 +147,8 @@ async fn main() { .route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt)) .route("/lessons/{id}/summarize", post(handlers::summarize_lesson)) .route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz)) + .route("/lessons/{id}/generate-role-play", post(handlers::generate_role_play)) + .route("/lessons/{id}/generate-hotspots", post(handlers::generate_hotspots)) .route("/courses/generate", post(handlers::generate_course)) .route("/courses/{id}/export", get(handlers::export_course)) .route("/courses/import", post(handlers::import_course)) diff --git a/services/lms-service/migrations/20260309000001_add_block_id_to_chat_sessions.sql b/services/lms-service/migrations/20260309000001_add_block_id_to_chat_sessions.sql new file mode 100644 index 0000000..982a246 --- /dev/null +++ b/services/lms-service/migrations/20260309000001_add_block_id_to_chat_sessions.sql @@ -0,0 +1,3 @@ +-- Migration: Add block_id to chat_sessions to link sessions to specific Role-Playing blocks +ALTER TABLE chat_sessions ADD COLUMN block_id UUID; +CREATE INDEX idx_chat_sessions_block_id ON chat_sessions(block_id); diff --git a/services/lms-service/migrations/20260309000002_sync_content_blocks.sql b/services/lms-service/migrations/20260309000002_sync_content_blocks.sql new file mode 100644 index 0000000..8d599f5 --- /dev/null +++ b/services/lms-service/migrations/20260309000002_sync_content_blocks.sql @@ -0,0 +1,2 @@ +-- Migration: Sync content_blocks to lessons table (LMS) +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS content_blocks JSONB DEFAULT NULL; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 2e25c47..8d2b7d8 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -2336,6 +2336,13 @@ pub struct ChatPayload { pub session_id: Option, } +#[derive(Deserialize)] +pub struct ChatRolePlayPayload { + pub message: String, + pub session_id: Option, + pub block_id: String, +} + #[derive(Serialize)] pub struct ChatResponse { pub response: String, @@ -2599,6 +2606,178 @@ pub async fn chat_with_tutor( })) } +pub async fn chat_role_play( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(lesson_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + tracing::info!("Chat Role Play: lesson_id={}, org_id={}, user_id={}, role={}", lesson_id, org_ctx.id, claims.sub, claims.role); + // 1. Fetch lesson with access check (matches get_lesson_content logic) + let is_preview = claims.token_type.as_deref() == Some("preview"); + + let lesson = if is_preview { + sqlx::query_as::<_, Lesson>( + "SELECT l.* FROM lessons l + JOIN modules m ON l.module_id = m.id + WHERE l.id = $1 AND l.organization_id = $2", + ) + .bind(lesson_id) + .bind(claims.org) + .fetch_optional(&pool) + .await + } else { + sqlx::query_as::<_, Lesson>( + "SELECT l.* FROM lessons l + JOIN modules m ON l.module_id = m.id + LEFT JOIN enrollments e ON m.course_id = e.course_id AND e.user_id = $2 + WHERE l.id = $1 AND (e.id IS NOT NULL OR l.is_previewable = true OR $3 = 'admin')", + ) + .bind(lesson_id) + .bind(claims.sub) + .bind(&claims.role) + .fetch_optional(&pool) + .await + }.map_err(|e| { + tracing::error!("chat_role_play: DB error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error de base de datos".into()) + })? + .ok_or((StatusCode::NOT_FOUND, "Lección no encontrada o acceso denegado".into()))?; + + // 2. Find the specific role-playing block in metadata or content_blocks + let blocks = lesson.content_blocks + .or_else(|| lesson.metadata.as_ref().and_then(|m| m.get("blocks").cloned())) + .and_then(|b| b.as_array().cloned()) + .ok_or((StatusCode::BAD_REQUEST, "No se encontraron bloques en la lección".into()))?; + + let block = blocks.iter().find(|b| { + b.get("id").and_then(|id| id.as_str()) == Some(&payload.block_id) + }).ok_or((StatusCode::NOT_FOUND, "Bloque de simulación no encontrado".into()))?; + + let scenario = block.get("scenario").and_then(|s| s.as_str()).unwrap_or(""); + let ai_persona = block.get("ai_persona").and_then(|s| s.as_str()).unwrap_or(""); + let user_role = block.get("user_role").and_then(|s| s.as_str()).unwrap_or(""); + let objectives = block.get("objectives").and_then(|s| s.as_str()).unwrap_or(""); + + // Try to parse block_id as Uuid for DB storage, if it's not a Uuid (legacy), we store None + let block_uuid = Uuid::parse_str(&payload.block_id).ok(); + + // 3. Handle Session + let session_id = if let Some(sid) = payload.session_id { + sid + } else { + let row = sqlx::query( + "INSERT INTO chat_sessions (organization_id, user_id, lesson_id, block_id, title) VALUES ($1, $2, $3, $4, $5) RETURNING id" + ) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(Some(lesson_id)) + .bind(block_uuid) + .bind(format!("Simulación: {}", block.get("title").and_then(|t| t.as_str()).unwrap_or("Rol"))) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to create role-play session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error al crear la sesión de simulación".into()) + })?; + + use sqlx::Row; + row.get::(0) + }; + + // 4. Save user message + sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)") + .bind(session_id) + .bind("user") + .bind(&payload.message) + .execute(&pool) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al guardar el mensaje del usuario".into()))?; + + // 5. Fetch history (last 10 messages for context) + let history_rows = sqlx::query( + "SELECT role, content FROM chat_messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT 10" + ) + .bind(session_id) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + let mut conversation_history = String::new(); + for row in history_rows.into_iter().rev() { + use sqlx::Row; + let role: String = row.get("role"); + let content: String = row.get("content"); + conversation_history.push_str(&format!("{}: {}\n", role.to_uppercase(), content)); + } + + // 6. AI Request + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ("https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4-turbo".to_string()) + }; + + let system_prompt = format!( + "Eres un motor de simulación de rol educativo para OpenCCB.\n\n\ + ESCENARIO: {0}\n\ + TU PERSONAJE (IA): {1}\n\ + ROL DEL ESTUDIANTE: {2}\n\ + OBJETIVOS PEDAGÓGICOS: {3}\n\n\ + INSTRUCCIONES:\n\ + 1. Mantente ESTRICTAMENTE en tu personaje.\n\ + 2. No rompas la simulación a menos que sea absolutamente necesario para guiar al estudiante.\n\ + 3. Si el estudiante se desvía de los objetivos, intenta redirigirlo sutilmente dentro del personaje.\n\ + 4. Tus respuestas deben ser naturales, dinámicas y fomentar la participación del estudiante.\n\ + 5. Al final, si el estudiante logra los objetivos, felicítalo dentro del personaje.\n\ + 6. Responde en el mismo idioma de los mensajes del estudiante.\n\n\ + HISTORIAL DE LA SIMULACIÓN:\n{4}", + scenario, ai_persona, user_role, objectives, conversation_history + ); + + let response = client.post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": payload.message } + ], + "temperature": 0.8 + })) + .send().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud de IA: {}", e)))?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", err_body))); + } + + let ai_data: serde_json::Value = response.json().await.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al analizar la respuesta de la IA".into()))?; + let ai_response = ai_data["choices"][0]["message"]["content"].as_str().unwrap_or("Lo siento, tuve un problema procesando la simulación.").to_string(); + + // 7. Save assistant response + let _ = sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)") + .bind(session_id) + .bind("assistant") + .bind(&ai_response) + .execute(&pool).await; + + Ok(Json(ChatResponse { + response: ai_response, + session_id, + })) +} + pub async fn get_lesson_feedback( Org(org_ctx): Org, claims: Claims, @@ -2798,13 +2977,19 @@ fn extract_block_content(metadata: &Option) -> String { } } "ordering" => { - if let Some(items) = block.get("items").and_then(|i| i.as_array()) { + if let Some(items) = block.get("items").and_then(|it| it.as_array()) { for (i, item) in items.iter().enumerate() { - let text = item.as_str().unwrap_or(""); - block_content.push_str(&format!("Item {}: {}\n", i + 1, text)); + if let Some(text) = item.as_str() { + block_content.push_str(&format!("{}. {}\n", i + 1, text)); + } } } } + "role-playing" => { + let scenario = block.get("scenario").and_then(|s| s.as_str()).unwrap_or(""); + let ai_persona = block.get("ai_persona").and_then(|s| s.as_str()).unwrap_or(""); + block_content.push_str(&format!("Escenario: {}\nIA Persona: {}\n", scenario, ai_persona)); + } "short-answer" | "audio-response" => { if let Some(prompt) = block.get("prompt").and_then(|p| p.as_str()) { block_content.push_str(&format!("Prompt: {}\n", prompt)); diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index c1be7f4..6f0b41e 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -121,6 +121,7 @@ async fn main() { .route("/audio/evaluate", post(handlers::evaluate_audio_response)) .route("/audio/evaluate-file", post(handlers::evaluate_audio_file)) .route("/lessons/{id}/chat", post(handlers::chat_with_tutor)) + .route("/lessons/{id}/chat-role-play", post(handlers::chat_role_play)) .route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback)) .route("/notifications", get(handlers::get_notifications)) .route( diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 6ee168a..492493a 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -78,6 +78,7 @@ pub struct Lesson { pub important_date_type: Option, // "exam", "assignment", "milestone", etc. pub transcription_status: Option, pub is_previewable: bool, + pub content_blocks: Option, pub created_at: DateTime, } @@ -93,6 +94,7 @@ impl Default for Lesson { summary: None, transcription: None, metadata: None, + content_blocks: None, grading_category_id: None, is_graded: false, max_attempts: None, diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 728baac..2993e75 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -18,6 +18,7 @@ import HotspotPlayer from "@/components/blocks/HotspotPlayer"; import MemoryPlayer from "@/components/blocks/MemoryPlayer"; import DocumentPlayer from "@/components/blocks/DocumentPlayer"; import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer"; +import RolePlayingPlayer from "@/components/blocks/RolePlayingPlayer"; import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer"; import InteractiveTranscript from "@/components/InteractiveTranscript"; import AITutor from "@/components/AITutor"; @@ -449,6 +450,19 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les onComplete={(score) => handleBlockComplete(block.id, score)} /> ); + case 'role-playing': + return ( + + ); case 'peer-review': return ( = ({ aria-labelledby={`ann-title-${announcement.id}`} className={`relative p-6 rounded-2xl border transition-all duration-300 ${announcement.is_pinned ? 'bg-primary-500/10 border-primary-500/30' - : 'bg-white/5 border-white/10 hover:border-white/20' + : 'bg-white dark:bg-white/5 border-slate-100 dark:border-white/10 hover:border-slate-200 dark:hover:border-white/20' }`}> {announcement.is_pinned && (
@@ -55,8 +55,8 @@ export const AnnouncementCard: React.FC = ({ )}
-

{announcement.author_name}

-

+

{announcement.author_name}

+

{formatDistanceToNow(new Date(announcement.created_at), { addSuffix: true, locale: es })}

@@ -77,8 +77,8 @@ export const AnnouncementCard: React.FC = ({ )} -

{announcement.title}

-
+

{announcement.title}

+
{announcement.content}
diff --git a/web/experience/src/components/AnnouncementsList.tsx b/web/experience/src/components/AnnouncementsList.tsx index b911dce..f599f0d 100644 --- a/web/experience/src/components/AnnouncementsList.tsx +++ b/web/experience/src/components/AnnouncementsList.tsx @@ -44,8 +44,8 @@ export const AnnouncementsList: React.FC = ({ courseId,
-

Anuncios del Curso

-

Mantente al día con las últimas noticias

+

Anuncios del Curso

+

Mantente al día con las últimas noticias

@@ -57,7 +57,7 @@ export const AnnouncementsList: React.FC = ({ courseId, placeholder="Buscar anuncios..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} - className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors w-full sm:w-64" + className="pl-10 pr-4 py-2 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-xl text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors w-full sm:w-64" /> {isInstructor && ( @@ -89,12 +89,12 @@ export const AnnouncementsList: React.FC = ({ courseId, ))} ) : ( -
-
- +
+
+
-

No hay anuncios

-

+

No hay anuncios

+

{searchTerm ? `No se encontraron anuncios que coincidan con "${searchTerm}"` : "Aún no se han publicado anuncios en este curso."} diff --git a/web/experience/src/components/blocks/RolePlayingPlayer.tsx b/web/experience/src/components/blocks/RolePlayingPlayer.tsx new file mode 100644 index 0000000..fcbde6a --- /dev/null +++ b/web/experience/src/components/blocks/RolePlayingPlayer.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { lmsApi } from "@/lib/api"; +import { Send, User, Bot, Sparkles, MessageCircle, RotateCcw } from "lucide-react"; +import ReactMarkdown from "react-markdown"; + +interface Message { + role: "user" | "assistant"; + content: string; +} + +interface RolePlayingPlayerProps { + id: string; + lessonId: string; + title?: string; + scenario?: string; + ai_persona?: string; + user_role?: string; + objectives?: string; + initial_message?: string; +} + +export default function RolePlayingPlayer({ + id, + lessonId, + title, + scenario = "", + ai_persona = "", + user_role = "", + objectives = "", + initial_message = "Hola, ¿cómo puedo ayudarte hoy?" +}: RolePlayingPlayerProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [sessionId, setSessionId] = useState(null); + const messagesEndRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + if (messages.length === 0 && initial_message) { + setMessages([{ role: "assistant", content: initial_message }]); + } + }, [initial_message, messages.length]); + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const handleSend = async () => { + if (!input.trim() || loading) return; + + const userMessage = input.trim(); + setInput(""); + setMessages(prev => [...prev, { role: "user", content: userMessage }]); + setLoading(true); + + try { + const res = await lmsApi.chatRolePlay(lessonId, id, userMessage, sessionId || undefined); + setMessages(prev => [...prev, { role: "assistant", content: res.response }]); + if (!sessionId) setSessionId(res.session_id); + } catch (error) { + console.error("Error in role-play chat:", error); + setMessages(prev => [...prev, { role: "assistant", content: "Lo siento, hubo un error procesando la simulación. Por favor, intenta de nuevo." }]); + } finally { + setLoading(false); + } + }; + + const handleReset = () => { + setMessages([{ role: "assistant", content: initial_message }]); + setSessionId(null); + }; + + return ( +

+
+
+
+ +
+

+ {title || "Simulación de Rol"} +

+
+
+ +
+ {/* Scenario Header */} +
+
+
+ +
+
+

Escenario Activo

+

{scenario}

+
+
+
+ + {/* Information Bar */} +
+
+ + Tu Rol: {user_role} +
+
+ + IA: {ai_persona} +
+
+ + {/* Chat Area */} +
+ {messages.map((msg, idx) => ( +
+
+
+ {msg.content} +
+
+
+ ))} + {loading && ( +
+
+
+
+
+
+
+
+
+ )} +
+
+ + {/* Input Area */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + placeholder="Escribe tu mensaje en la simulación..." + className="flex-1 bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-2xl px-6 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all text-gray-900 dark:text-white shadow-inner" + /> + + +
+
+
+ + IA Generativa Avanzada +
+
+ Objetivo: {objectives.slice(0, 40)}{objectives.length > 40 ? "..." : ""} +
+
+
+
+
+ ); +} diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 613d39d..d543086 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -84,7 +84,7 @@ export interface QuizQuestion { export interface Block { id: string; - type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review'; + type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing'; title: string; content?: string; url?: string; @@ -111,6 +111,12 @@ export interface Block { radius: number; label: string; }[]; + // Role-playing fields + scenario?: string; + ai_persona?: string; + user_role?: string; + objectives?: string; + initial_message?: string; metadata?: any; } @@ -139,6 +145,7 @@ export interface Lesson { metadata?: { blocks: Block[]; }; + content_blocks?: Block[]; is_graded: boolean; grading_category_id: string | null; max_attempts: number | null; @@ -621,6 +628,12 @@ export const lmsApi = { body: JSON.stringify({ message, session_id: sessionId }) }); }, + async chatRolePlay(lessonId: string, blockId: string, message: string, sessionId?: string): Promise<{ response: string, session_id: string }> { + return apiFetch(`/lessons/${lessonId}/chat-role-play`, { + method: 'POST', + body: JSON.stringify({ message, block_id: blockId, session_id: sessionId }) + }); + }, async getLessonFeedback(lessonId: string): Promise<{ response: string, session_id: string }> { return apiFetch(`/lessons/${lessonId}/feedback`); }, diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 0c5b917..d45f149 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -35,6 +35,7 @@ import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock"; import AudioResponseBlock from "@/components/blocks/AudioResponseBlock"; import HotspotBlock from "@/components/blocks/HotspotBlock"; import MemoryBlock from "@/components/blocks/MemoryBlock"; +import RolePlayingBlock from "@/components/blocks/RolePlayingBlock"; import PeerReviewBlock from "@/components/blocks/PeerReviewBlock"; import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal"; import LibraryPanel from "@/components/LibraryPanel"; @@ -215,7 +216,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI const addBlock = (type: Block['type']) => { const newBlock: Block = { - id: Math.random().toString(36).substr(2, 9), + id: crypto.randomUUID(), type, ...(type === 'description' && { content: "" }), ...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }), @@ -230,6 +231,14 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI ...(type === 'hotspot' && { imageUrl: "", description: "Find the following items...", hotspots: [] }), ...(type === 'memory-match' && { pairs: [{ id: "1", left: "Term A", right: "Match A" }] }), ...(type === 'peer-review' && { prompt: "Submit your work below.", reviewCriteria: "Evaluate based on clarity and completeness." }), + ...(type === 'role-playing' && { + title: "Simulación de Rol", + scenario: "Contexto de la simulación...", + ai_persona: "Personalidad de la IA...", + user_role: "Rol del estudiante...", + objectives: "Objetivos...", + initial_message: "Hola, ¿cómo puedo ayudarte?" + }), }; setBlocks([...blocks, newBlock]); }; @@ -323,14 +332,27 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI setIsAIQuizModalOpen(false); setIsGeneratingQuiz(true); try { - const newBlocks = await cmsApi.generateQuiz(lesson.id, { - context: aiQuizContext, - quiz_type: aiQuizType - }); - setBlocks([...blocks, ...newBlocks]); + if (aiQuizType === 'role-playing') { + const data = await cmsApi.generateRolePlay(lesson.id, { + prompt_hint: aiQuizContext + }); + const newBlock: Block = { + id: Math.random().toString(36).substr(2, 9), + type: 'role-playing', + ...data + }; + setBlocks([...blocks, newBlock]); + } else { + const newBlocks = await cmsApi.generateQuiz(lesson.id, { + prompt_hint: aiQuizContext, + quiz_type: aiQuizType + }); + setBlocks([...blocks, ...newBlocks]); + } setAiQuizContext(""); - } catch { - alert("Failed to generate quiz."); + } catch (err) { + console.error("AI Generation failed:", err); + alert("Failed to generate content."); } finally { setIsGeneratingQuiz(false); } @@ -1041,6 +1063,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI hotspots={block.hotspots || []} editMode={editMode} courseId={params.id} + lessonId={params.lessonId} onChange={(updates) => updateBlock(block.id, updates)} /> )} @@ -1063,6 +1086,13 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI onChange={(updates) => updateBlock(block.id, updates)} /> )} + {block.type === 'role-playing' && ( + updateBlock(block.id, updates)} + /> + )}
))} @@ -1102,9 +1132,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI { type: 'ordering', icon: '🔢', label: 'Sequence', color: 'blue' }, { type: 'short-answer', icon: '💬', label: 'Open-Ended', color: 'indigo' }, { type: 'hotspot', icon: '🔍', label: 'Hotspot', color: 'amber' }, - { type: 'audio-response', icon: '🎤', label: 'Phonetic', color: 'purple' }, - { type: 'memory-match', icon: '🧠', label: 'Cognitive', color: 'fuchsia' }, - { type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'rose' }, + { type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' }, + { type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' }, + { type: 'role-playing', icon: '🎭', label: 'Persona Play', color: 'violet' }, + { type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' }, ].map((item) => ( + )} +
) => void; + lessonId: string; +} + +export default function RolePlayingBlock({ block, onUpdate, lessonId }: RolePlayingBlockProps) { + const [isGenerating, setIsGenerating] = useState(false); + + const handleGenerateAI = async () => { + setIsGenerating(true); + try { + const data = await cmsApi.generateRolePlay(lessonId, {}); + onUpdate({ + title: data.title, + scenario: data.scenario, + ai_persona: data.ai_persona, + user_role: data.user_role, + objectives: data.objectives, + initial_message: data.initial_message + }); + } catch (error) { + console.error("AI Generation failed:", error); + alert("No se pudo generar el escenario con IA. Por favor, intenta de nuevo."); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+
+
+

+
+ +
+ Simulación de Rol Interactiva +

+

Configura un escenario para que el estudiante practique con la IA.

+
+ +
+ +
+
+
+ + onUpdate({ title: e.target.value })} + placeholder="Ej: Negociación con un Cliente Difícil" + className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none" + /> +
+ +
+ + onUpdate({ ai_persona: e.target.value })} + placeholder="Ej: Un cliente frustrado que busca un reembolso" + className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none" + /> +
+ +
+ + onUpdate({ user_role: e.target.value })} + placeholder="Ej: Representante de soporte técnico" + className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none" + /> +
+
+ +
+
+ +