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
+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(