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