feat: Implement AI-generated Role Playing and Hotspot interactive content blocks with UI and service integration.
This commit is contained in:
Generated
+1
@@ -298,6 +298,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"common",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -27,3 +27,4 @@ openidconnect.workspace = true
|
||||
anyhow.workspace = true
|
||||
zip = "0.6"
|
||||
mime_guess = "2.0"
|
||||
base64 = "0.22.1"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user