feat: Implement AI-generated Role Playing and Hotspot interactive content blocks with UI and service integration.

This commit is contained in:
2026-03-09 13:46:47 -03:00
parent c292efdc28
commit bc5b240984
20 changed files with 947 additions and 42 deletions
Generated
+1
View File
@@ -298,6 +298,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"bcrypt",
"chrono",
"common",
+1 -1
View File
@@ -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.
+6 -6
View File
@@ -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.
+1
View File
@@ -27,3 +27,4 @@ openidconnect.workspace = true
anyhow.workspace = true
zip = "0.6"
mime_guess = "2.0"
base64 = "0.22.1"
+244 -1
View File
@@ -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<String>,
}
pub async fn generate_hotspots(
Org(org_ctx): Org,
_claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
Json(payload): Json<GenerateHotspotsPayload>,
) -> Result<Json<serde_json::Value>, 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<String>,
}
pub async fn generate_role_play(
Org(org_ctx): Org,
_claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
Json(payload): Json<GenerateRolePlayPayload>,
) -> Result<Json<serde_json::Value>, 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,
+2
View File
@@ -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))
@@ -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);
@@ -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;
+188 -3
View File
@@ -2336,6 +2336,13 @@ pub struct ChatPayload {
pub session_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct ChatRolePlayPayload {
pub message: String,
pub session_id: Option<Uuid>,
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<PgPool>,
Path(lesson_id): Path<Uuid>,
Json(payload): Json<ChatRolePlayPayload>,
) -> Result<Json<ChatResponse>, (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::<Uuid, _>(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<serde_json::Value>) -> 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));
+1
View File
@@ -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(
+2
View File
@@ -78,6 +78,7 @@ pub struct Lesson {
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
pub transcription_status: Option<String>,
pub is_previewable: bool,
pub content_blocks: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
}
@@ -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,
@@ -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 (
<RolePlayingPlayer
id={block.id}
lessonId={params.lessonId}
title={block.title}
scenario={block.scenario}
ai_persona={block.ai_persona}
user_role={block.user_role}
objectives={block.objectives}
initial_message={block.initial_message}
/>
);
case 'peer-review':
return (
<PeerReviewPlayer
@@ -37,7 +37,7 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
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 && (
<div className="absolute top-4 right-4 text-primary-400">
@@ -55,8 +55,8 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
)}
</div>
<div>
<h4 className="font-semibold text-white">{announcement.author_name}</h4>
<p className="text-sm text-gray-400">
<h4 className="font-semibold text-slate-900 dark:text-white">{announcement.author_name}</h4>
<p className="text-sm text-slate-500 dark:text-gray-400">
{formatDistanceToNow(new Date(announcement.created_at), { addSuffix: true, locale: es })}
</p>
</div>
@@ -77,8 +77,8 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
)}
</div>
<h3 id={`ann-title-${announcement.id}`} className="text-xl font-bold text-white mb-2">{announcement.title}</h3>
<div className="text-gray-300 whitespace-pre-wrap leading-relaxed">
<h3 id={`ann-title-${announcement.id}`} className="text-xl font-bold text-slate-900 dark:text-white mb-2">{announcement.title}</h3>
<div className="text-slate-600 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
{announcement.content}
</div>
</article>
@@ -44,8 +44,8 @@ export const AnnouncementsList: React.FC<AnnouncementsListProps> = ({ courseId,
<Megaphone className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold text-white">Anuncios del Curso</h2>
<p className="text-gray-400 italic">Mantente al día con las últimas noticias</p>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Anuncios del Curso</h2>
<p className="text-slate-500 dark:text-gray-400 italic">Mantente al día con las últimas noticias</p>
</div>
</div>
@@ -57,7 +57,7 @@ export const AnnouncementsList: React.FC<AnnouncementsListProps> = ({ 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"
/>
</div>
{isInstructor && (
@@ -89,12 +89,12 @@ export const AnnouncementsList: React.FC<AnnouncementsListProps> = ({ courseId,
))}
</div>
) : (
<div className="bg-white/5 border border-white/10 rounded-2xl p-12 text-center">
<div className="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-gray-500" />
<div className="bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-2xl p-12 text-center">
<div className="w-16 h-16 bg-slate-100 dark:bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-slate-400 dark:text-gray-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">No hay anuncios</h3>
<p className="text-gray-400 max-w-md mx-auto">
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">No hay anuncios</h3>
<p className="text-slate-500 dark:text-gray-400 max-w-md mx-auto">
{searchTerm
? `No se encontraron anuncios que coincidan con "${searchTerm}"`
: "Aún no se han publicado anuncios en este curso."}
@@ -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<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white">
<MessageCircle size={18} />
</div>
<h3 className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
{title || "Simulación de Rol"}
</h3>
</div>
</div>
<div className="glass border-black/5 dark:border-white/5 rounded-[2.5rem] overflow-hidden flex flex-col h-[600px] shadow-2xl bg-white/50 dark:bg-black/50 backdrop-blur-xl">
{/* Scenario Header */}
<div className="p-6 bg-gradient-to-r from-indigo-500/10 to-blue-500/10 border-b border-black/5 dark:border-white/5">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center text-white shrink-0 shadow-lg shadow-indigo-500/20">
<Sparkles size={20} />
</div>
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400">Escenario Activo</p>
<p className="text-sm font-bold text-gray-800 dark:text-gray-200 leading-tight">{scenario}</p>
</div>
</div>
</div>
{/* Information Bar */}
<div className="px-6 py-4 bg-black/5 dark:bg-white/5 flex flex-wrap gap-4 text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400 border-b border-black/5 dark:border-white/5">
<div className="flex items-center gap-2">
<User size={12} className="text-indigo-500" />
<span>Tu Rol: <span className="text-gray-900 dark:text-white">{user_role}</span></span>
</div>
<div className="flex items-center gap-2">
<Bot size={12} className="text-blue-500" />
<span>IA: <span className="text-gray-900 dark:text-white">{ai_persona}</span></span>
</div>
</div>
{/* Chat Area */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 scrollbar-thin scrollbar-thumb-indigo-500/20">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} animate-in fade-in slide-in-from-bottom-2 duration-300`}>
<div className={`max-w-[85%] p-4 rounded-2xl ${msg.role === "user"
? "bg-indigo-600 text-white shadow-lg shadow-indigo-500/20"
: "bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 text-gray-800 dark:text-gray-200"
}`}>
<div className="text-sm prose dark:prose-invert max-w-none">
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
</div>
</div>
))}
{loading && (
<div className="flex justify-start animate-pulse">
<div className="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-4 rounded-2xl">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500/50"></div>
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500/50"></div>
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500/50"></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-6 bg-black/5 dark:bg-white/5 border-t border-black/5 dark:border-white/5 space-y-4">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => 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"
/>
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="p-4 bg-indigo-600 text-white rounded-2xl hover:bg-indigo-700 transition-all disabled:opacity-50 disabled:grayscale shadow-lg shadow-indigo-500/20 active:scale-95"
>
<Send size={20} />
</button>
<button
onClick={handleReset}
title="Reiniciar Simulación"
className="p-4 bg-black/10 dark:bg-white/10 text-gray-600 dark:text-gray-400 rounded-2xl hover:bg-black/20 dark:hover:bg-white/20 transition-all active:scale-95"
>
<RotateCcw size={20} />
</button>
</div>
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-[8px] font-black uppercase tracking-widest text-gray-400">
<Sparkles size={10} className="text-indigo-500" />
<span>IA Generativa Avanzada</span>
</div>
<div className="text-[8px] font-black uppercase tracking-widest text-gray-400 truncate max-w-[200px]">
Objetivo: {objectives.slice(0, 40)}{objectives.length > 40 ? "..." : ""}
</div>
</div>
</div>
</div>
</div>
);
}
+14 -1
View File
@@ -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`);
},
@@ -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' && (
<RolePlayingBlock
block={block}
lessonId={params.lessonId}
onUpdate={(updates) => updateBlock(block.id, updates)}
/>
)}
</div>
</div>
))}
@@ -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) => (
<button
key={item.type}
@@ -1197,6 +1228,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<option value="vocabulary">Lexical Focus / Vocab</option>
<option value="grammar">Structural / Grammar Focus</option>
<option value="memory-match">Conceptual Memory Match</option>
<option value="role-playing">AI Role-Playing Simulation</option>
</select>
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
<ChevronDown size={18} />
@@ -2,9 +2,9 @@
import { useState, useRef } from "react";
import Image from "next/image";
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair } from "lucide-react";
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair, Wand2, Loader2 } from "lucide-react";
import AssetPickerModal from "../AssetPickerModal";
import { Asset, getImageUrl } from "@/lib/api";
import { Asset, getImageUrl, cmsApi } from "@/lib/api";
interface Hotspot {
id: string;
@@ -22,6 +22,7 @@ interface HotspotBlockProps {
hotspots?: Hotspot[];
editMode: boolean;
courseId: string;
lessonId: string;
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
}
@@ -33,11 +34,34 @@ export default function HotspotBlock({
hotspots = [],
editMode,
courseId,
lessonId,
onChange
}: HotspotBlockProps) {
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleGenerateAI = async () => {
if (!imageUrl) return;
setIsGenerating(true);
try {
const data = await cmsApi.generateHotspots(lessonId, { image_url: imageUrl });
const newHotspots = data.map(h => ({
id: crypto.randomUUID(),
x: h.x,
y: h.y,
radius: 5,
label: h.label
}));
onChange({ hotspots: [...hotspots, ...newHotspots] });
} catch (error) {
console.error("AI Hotspot Generation failed:", error);
alert("No se pudieron generar los puntos de interés con IA. Por favor, intenta de nuevo.");
} finally {
setIsGenerating(false);
}
};
const handleImageSelect = (asset: Asset) => {
const url = asset.storage_path.replace('uploads/', '/assets/');
onChange({ imageUrl: url });
@@ -52,7 +76,7 @@ export default function HotspotBlock({
const y = ((e.clientY - rect.top) / rect.height) * 100;
const newHotspot: Hotspot = {
id: Math.random().toString(36).substr(2, 9),
id: crypto.randomUUID(),
x,
y,
radius: 5,
@@ -123,7 +147,19 @@ export default function HotspotBlock({
/>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 pl-1">Tactical Instructions</label>
<div className="flex items-center justify-between">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 pl-1">Tactical Instructions</label>
{imageUrl && (
<button
onClick={handleGenerateAI}
disabled={isGenerating}
className="flex items-center gap-2 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-[9px] font-black uppercase tracking-widest hover:bg-amber-700 transition-all disabled:opacity-50 shadow-lg shadow-amber-500/20 active:scale-95"
>
{isGenerating ? <Loader2 className="animate-spin" size={12} /> : <Wand2 size={12} />}
{isGenerating ? "Analyzing..." : "Auto-Detect with AI"}
</button>
)}
</div>
<input
type="text"
value={description || ""}
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { Block, cmsApi } from "@/lib/api";
import { Sparkles, MessageSquare, User, Bot, Target, Wand2, Loader2 } from "lucide-react";
interface RolePlayingBlockProps {
block: Block;
onUpdate: (updates: Partial<Block>) => 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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="text-sm font-bold flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
<MessageSquare size={16} />
</div>
Simulación de Rol Interactiva
</h4>
<p className="text-xs text-muted-foreground">Configura un escenario para que el estudiante practique con la IA.</p>
</div>
<button
onClick={handleGenerateAI}
disabled={isGenerating}
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-bold hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-lg shadow-indigo-500/20"
>
{isGenerating ? <Loader2 className="animate-spin" size={14} /> : <Wand2 size={14} />}
Generar con IA
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Título de la Simulación</label>
<input
type="text"
value={block.title || ""}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Bot size={12} className="text-indigo-500" />
Persona de la IA
</label>
<input
type="text"
value={block.ai_persona || ""}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<User size={12} className="text-blue-500" />
Rol del Estudiante
</label>
<input
type="text"
value={block.user_role || ""}
onChange={(e) => 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"
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Sparkles size={12} className="text-amber-500" />
Escenario y Contexto
</label>
<textarea
value={block.scenario || ""}
onChange={(e) => onUpdate({ scenario: e.target.value })}
placeholder="Describe detalladamente dónde ocurre la acción y cuál es el problema..."
rows={4}
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 resize-none"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Target size={12} className="text-green-500" />
Objetivos de Aprendizaje
</label>
<textarea
value={block.objectives || ""}
onChange={(e) => onUpdate({ objectives: e.target.value })}
placeholder="¿Qué debe conseguir el estudiante al final de la charla?"
rows={3}
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 resize-none"
/>
</div>
</div>
</div>
<div className="p-4 bg-indigo-500/5 dark:bg-indigo-500/10 rounded-2xl border border-indigo-500/20 space-y-3 shadow-inner">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-indigo-600/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400">
<MessageSquare size={12} />
</div>
<label className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400">Mensaje Inicial de la IA</label>
</div>
<input
type="text"
value={block.initial_message || ""}
onChange={(e) => onUpdate({ initial_message: e.target.value })}
placeholder="Escribe la primera línea de la IA para romper el hielo..."
className="w-full bg-white/50 dark:bg-black/20 border-none rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none"
/>
<p className="text-[9px] text-muted-foreground italic">Este mensaje aparecerá automáticamente al iniciar la simulación para dar el primer paso.</p>
</div>
</div>
);
}
+38 -2
View File
@@ -68,7 +68,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot' | 'peer-review';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot' | 'peer-review' | 'role-playing';
title?: string;
content?: string;
url?: string;
@@ -99,6 +99,12 @@ export interface Block {
}[];
imageUrl?: string;
reviewCriteria?: string;
// Role-playing fields
scenario?: string;
ai_persona?: string;
user_role?: string;
objectives?: string;
initial_message?: string;
}
export interface Lesson {
@@ -110,6 +116,7 @@ export interface Lesson {
metadata?: {
blocks: Block[];
};
content_blocks?: Block[];
is_graded: boolean;
grading_category_id: string | null;
max_attempts: number | null;
@@ -641,7 +648,36 @@ export const cmsApi = {
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
async generateQuiz(lessonId: string, payload: { prompt_hint?: string, quiz_type?: string }): Promise<any> {
return apiFetch(`/lessons/${lessonId}/generate-quiz`, {
method: 'POST',
body: JSON.stringify(payload)
});
},
async generateRolePlay(lessonId: string, payload: { prompt_hint?: string }): Promise<{
title: string;
scenario: string;
ai_persona: string;
user_role: string;
objectives: string;
initial_message: string;
}> {
return apiFetch(`/lessons/${lessonId}/generate-role-play`, {
method: 'POST',
body: JSON.stringify(payload)
});
},
async generateHotspots(lessonId: string, payload: { image_url: string, prompt_hint?: string }): Promise<{
label: string;
description: string;
x: number;
y: number;
}[]> {
return apiFetch(`/lessons/${lessonId}/generate-hotspots`, {
method: 'POST',
body: JSON.stringify(payload)
});
},
reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }),
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),