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