feat: Implement AI-generated Role Playing and Hotspot interactive content blocks with UI and service integration.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user