feat: fix AI for generation questions with RAG
This commit is contained in:
@@ -294,7 +294,7 @@ pub async fn import_from_mysql(
|
|||||||
|
|
||||||
if let Some(question) = mq {
|
if let Some(question) = mq {
|
||||||
// Map MySQL question type to platform question type
|
// Map MySQL question type to platform question type
|
||||||
let question_type = map_mysql_question_type(question.id_tipo_pregunta);
|
let question_type = map_mysql_question_type(question.id_tipo_pregunta, None);
|
||||||
|
|
||||||
let options = if question_type == QuestionBankType::MultipleChoice {
|
let options = if question_type == QuestionBankType::MultipleChoice {
|
||||||
Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"]))
|
Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"]))
|
||||||
@@ -374,7 +374,7 @@ pub async fn import_from_mysql(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map MySQL question type to platform question type
|
// Map MySQL question type to platform question type
|
||||||
let question_type = map_mysql_question_type(mq.id_tipo_pregunta);
|
let question_type = map_mysql_question_type(mq.id_tipo_pregunta, None);
|
||||||
|
|
||||||
// Create options for multiple choice (if applicable)
|
// Create options for multiple choice (if applicable)
|
||||||
let options = if question_type == QuestionBankType::MultipleChoice {
|
let options = if question_type == QuestionBankType::MultipleChoice {
|
||||||
@@ -431,16 +431,110 @@ pub async fn import_from_mysql(
|
|||||||
|
|
||||||
// ==================== Helpers ====================
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
fn map_mysql_question_type(mysql_type: i32) -> QuestionBankType {
|
fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> QuestionBankType {
|
||||||
// Map MySQL question types to platform types
|
// Map MySQL question types to platform types
|
||||||
// This depends on how tipos are defined in the MySQL database
|
// First try by name, then by ID as fallback
|
||||||
match mysql_type {
|
if let Some(nombre) = tipo_nombre {
|
||||||
1 => QuestionBankType::MultipleChoice,
|
let nombre_lower = nombre.to_lowercase();
|
||||||
2 => QuestionBankType::TrueFalse,
|
if nombre_lower.contains("selecc") || nombre_lower.contains("múltiple") || nombre_lower.contains("multiple") || nombre_lower.contains("alternativa") {
|
||||||
3 => QuestionBankType::ShortAnswer,
|
return QuestionBankType::MultipleChoice;
|
||||||
4 => QuestionBankType::Matching,
|
}
|
||||||
_ => QuestionBankType::MultipleChoice, // Default
|
if nombre_lower.contains("verdadero") || nombre_lower.contains("falso") {
|
||||||
|
return QuestionBankType::TrueFalse;
|
||||||
|
}
|
||||||
|
if nombre_lower.contains("emparej") || nombre_lower.contains("match") {
|
||||||
|
return QuestionBankType::Matching;
|
||||||
|
}
|
||||||
|
if nombre_lower.contains("orden") {
|
||||||
|
return QuestionBankType::Ordering;
|
||||||
|
}
|
||||||
|
if nombre_lower.contains("complet") {
|
||||||
|
return QuestionBankType::FillInTheBlanks;
|
||||||
|
}
|
||||||
|
if nombre_lower.contains("ensayo") || nombre_lower.contains("essay") {
|
||||||
|
return QuestionBankType::Essay;
|
||||||
|
}
|
||||||
|
if nombre_lower.contains("corta") || nombre_lower.contains("short") || nombre_lower.contains("texto") {
|
||||||
|
return QuestionBankType::ShortAnswer;
|
||||||
|
}
|
||||||
|
// Audio type in MySQL is typically listening comprehension with multiple choice answers
|
||||||
|
if nombre_lower.contains("audio") || nombre_lower.contains("listening") {
|
||||||
|
return QuestionBankType::MultipleChoice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to ID mapping
|
||||||
|
match mysql_type {
|
||||||
|
1 => QuestionBankType::MultipleChoice, // Alternativa
|
||||||
|
2 => QuestionBankType::MultipleChoice, // Audio (listening comprehension)
|
||||||
|
3 => QuestionBankType::ShortAnswer, // Texto
|
||||||
|
4 => QuestionBankType::Matching,
|
||||||
|
5 => QuestionBankType::Ordering,
|
||||||
|
6 => QuestionBankType::FillInTheBlanks,
|
||||||
|
7 => QuestionBankType::Essay,
|
||||||
|
_ => QuestionBankType::MultipleChoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse MySQL answers JSON and extract options and correct answer
|
||||||
|
fn parse_mysql_answers(
|
||||||
|
answers_json: Option<&str>,
|
||||||
|
question_type: QuestionBankType,
|
||||||
|
) -> (Option<serde_json::Value>, Option<serde_json::Value>) {
|
||||||
|
if let Some(json_str) = answers_json {
|
||||||
|
if let Ok(answers) = serde_json::from_str::<Vec<serde_json::Value>>(json_str) {
|
||||||
|
if !answers.is_empty() {
|
||||||
|
// Extract options (all answer texts)
|
||||||
|
let options: Vec<String> = answers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| a.get("texto").and_then(|t| t.as_str()).map(String::from))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Extract correct answer(s)
|
||||||
|
let correct_indices: Vec<usize> = answers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, a)| a.get("es_correcta").and_then(|c| c.as_bool()).unwrap_or(false))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let options_json = if !options.is_empty() {
|
||||||
|
Some(serde_json::json!(options))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let correct_answer = if question_type == QuestionBankType::TrueFalse ||
|
||||||
|
question_type == QuestionBankType::MultipleChoice {
|
||||||
|
// For multiple choice, store index/indices
|
||||||
|
if correct_indices.len() == 1 {
|
||||||
|
Some(serde_json::json!(correct_indices[0]))
|
||||||
|
} else if correct_indices.len() > 1 {
|
||||||
|
Some(serde_json::json!(correct_indices))
|
||||||
|
} else {
|
||||||
|
Some(serde_json::json!(0)) // Default to first
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other types, store the text
|
||||||
|
correct_indices.first()
|
||||||
|
.and_then(|&i| answers.get(i))
|
||||||
|
.and_then(|a| a.get("texto").and_then(|t| t.as_str()))
|
||||||
|
.map(|t| serde_json::json!(t))
|
||||||
|
};
|
||||||
|
|
||||||
|
return (options_json, correct_answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values if no answers provided
|
||||||
|
let default_options = match question_type {
|
||||||
|
QuestionBankType::MultipleChoice => Some(serde_json::json!(["Opción A", "Opción B", "Opción C", "Opción D"])),
|
||||||
|
QuestionBankType::TrueFalse => Some(serde_json::json!(["Verdadero", "Falso"])),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(default_options, Some(serde_json::json!(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== MySQL Integration ====================
|
// ==================== MySQL Integration ====================
|
||||||
@@ -496,24 +590,41 @@ pub async fn import_all_from_mysql(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?;
|
||||||
|
|
||||||
// Fetch ALL questions from MySQL
|
// Fetch ALL questions from MySQL with answers (using JSON aggregation for answers)
|
||||||
let mysql_questions: Vec<MySqlQuestionFull> = sqlx::query_as(
|
let mysql_questions: Vec<MySqlQuestionFull> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
bp.idPregunta,
|
bp.idPregunta AS id_pregunta,
|
||||||
bp.descripcion,
|
bp.descripcion AS descripcion,
|
||||||
bp.idTipoPregunta,
|
bp.idTipoPregunta AS id_tipo_pregunta,
|
||||||
bp.activo,
|
bp.activo AS activo,
|
||||||
c.idCursos,
|
c.idCursos AS id_cursos,
|
||||||
c.NombreCurso,
|
c.NombreCurso AS nombre_curso,
|
||||||
c.NivelCurso,
|
c.NivelCurso AS nivel_curso,
|
||||||
pe.idPlanDeEstudios,
|
pe.idPlanDeEstudios AS id_plan_de_estudios,
|
||||||
pe.Nombre as PlanNombre
|
pe.Nombre AS plan_nombre,
|
||||||
|
tp.descripcion AS tipo_pregunta_nombre,
|
||||||
|
CAST(
|
||||||
|
(
|
||||||
|
SELECT JSON_ARRAYAGG(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'idRespuesta', br.idRespuesta,
|
||||||
|
'texto', br.descripcion,
|
||||||
|
'es_correcta', br.resultado = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM bancorespuestas br
|
||||||
|
WHERE br.idPregunta = bp.idPregunta
|
||||||
|
AND br.activo = 1
|
||||||
|
) AS CHAR
|
||||||
|
) AS respuestas_json
|
||||||
FROM bancopreguntas bp
|
FROM bancopreguntas bp
|
||||||
JOIN curso c ON bp.idCursos = c.idCursos
|
JOIN curso c ON bp.idCursos = c.idCursos
|
||||||
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
|
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
|
||||||
|
JOIN tipopregunta tp ON bp.idTipoPregunta = tp.idTipoPregunta
|
||||||
WHERE bp.activo = 1
|
WHERE bp.activo = 1
|
||||||
ORDER BY pe.Nombre, c.NombreCurso, bp.idPregunta
|
ORDER BY pe.Nombre, c.NombreCurso, bp.idPregunta
|
||||||
|
LIMIT 500
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.fetch_all(&mysql_pool)
|
.fetch_all(&mysql_pool)
|
||||||
@@ -566,15 +677,14 @@ pub async fn import_all_from_mysql(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// New question - insert
|
// New question - insert with answers from MySQL
|
||||||
let question_type = map_mysql_question_type(mq.id_tipo_pregunta);
|
let question_type = map_mysql_question_type(mq.id_tipo_pregunta, mq.tipo_pregunta_nombre.as_deref());
|
||||||
let options = if question_type == QuestionBankType::MultipleChoice {
|
|
||||||
Some(serde_json::json!(["Opción A", "Opción B", "Opción C", "Opción D"]))
|
// Parse answers from MySQL
|
||||||
} else if question_type == QuestionBankType::TrueFalse {
|
let (options, correct_answer) = parse_mysql_answers(mq.respuestas_json.as_deref(), question_type);
|
||||||
Some(serde_json::json!(["Verdadero", "Falso"]))
|
|
||||||
} else {
|
let has_answers = mq.respuestas_json.is_some();
|
||||||
None
|
let question_type_name = mq.tipo_pregunta_nombre.clone();
|
||||||
};
|
|
||||||
|
|
||||||
let source_metadata = serde_json::json!({
|
let source_metadata = serde_json::json!({
|
||||||
"mysql_table": "bancopreguntas",
|
"mysql_table": "bancopreguntas",
|
||||||
@@ -585,8 +695,10 @@ pub async fn import_all_from_mysql(
|
|||||||
"idPlanDeEstudios": mq.id_plan_de_estudios,
|
"idPlanDeEstudios": mq.id_plan_de_estudios,
|
||||||
"plan_nombre": mq.plan_nombre,
|
"plan_nombre": mq.plan_nombre,
|
||||||
"idTipoPregunta": mq.id_tipo_pregunta,
|
"idTipoPregunta": mq.id_tipo_pregunta,
|
||||||
|
"tipo_pregunta_nombre": question_type_name,
|
||||||
"imported_at": chrono::Utc::now().to_rfc3339(),
|
"imported_at": chrono::Utc::now().to_rfc3339(),
|
||||||
"import_method": "bulk_import_all",
|
"import_method": "bulk_import_all",
|
||||||
|
"has_answers": has_answers,
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
@@ -605,7 +717,7 @@ pub async fn import_all_from_mysql(
|
|||||||
.bind(&mq.descripcion)
|
.bind(&mq.descripcion)
|
||||||
.bind(&question_type)
|
.bind(&question_type)
|
||||||
.bind(&options)
|
.bind(&options)
|
||||||
.bind(&serde_json::Value::Null)
|
.bind(&correct_answer)
|
||||||
.bind(&source_metadata)
|
.bind(&source_metadata)
|
||||||
.bind(mq.id_pregunta)
|
.bind(mq.id_pregunta)
|
||||||
.bind(mq.id_cursos)
|
.bind(mq.id_cursos)
|
||||||
@@ -672,6 +784,8 @@ pub struct MySqlQuestionFull {
|
|||||||
pub nivel_curso: Option<i32>,
|
pub nivel_curso: Option<i32>,
|
||||||
pub id_plan_de_estudios: i32,
|
pub id_plan_de_estudios: i32,
|
||||||
pub plan_nombre: String,
|
pub plan_nombre: String,
|
||||||
|
pub respuestas_json: Option<String>, // JSON array de respuestas
|
||||||
|
pub tipo_pregunta_nombre: Option<String>, // Nombre del tipo de pregunta
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
@@ -685,3 +799,101 @@ struct MySqlQuestion {
|
|||||||
id_plan_de_estudios: i32,
|
id_plan_de_estudios: i32,
|
||||||
plan_nombre: String,
|
plan_nombre: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== AI Generation ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AIGenerateQuestionPayload {
|
||||||
|
pub question_text: Option<String>,
|
||||||
|
pub question_type: Option<String>,
|
||||||
|
pub difficulty: Option<String>,
|
||||||
|
pub skill: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AIQuestionResponse {
|
||||||
|
pub question_text: String,
|
||||||
|
pub options: Vec<String>,
|
||||||
|
pub correct_answer: serde_json::Value,
|
||||||
|
pub explanation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /question-bank/ai-generate - Generate question options/answers using AI (Ollama only)
|
||||||
|
pub async fn ai_generate_question(
|
||||||
|
_org_ctx: Org,
|
||||||
|
_claims: Claims,
|
||||||
|
Json(payload): Json<AIGenerateQuestionPayload>,
|
||||||
|
) -> Result<Json<AIQuestionResponse>, (StatusCode, String)> {
|
||||||
|
use std::env;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let question_text = payload.question_text.unwrap_or_else(|| "English grammar question".to_string());
|
||||||
|
let difficulty = payload.difficulty.unwrap_or_else(|| "medium".to_string());
|
||||||
|
let skill = payload.skill.unwrap_or_else(|| "grammar".to_string());
|
||||||
|
|
||||||
|
// Build prompt for AI
|
||||||
|
let system_prompt = format!(
|
||||||
|
r#"You are an expert English Teacher creating quiz questions.
|
||||||
|
|
||||||
|
Create a multiple-choice question with the following parameters:
|
||||||
|
- Topic/Context: {}
|
||||||
|
- Difficulty: {}
|
||||||
|
- Skill assessed: {}
|
||||||
|
|
||||||
|
Return ONLY a JSON object with this exact structure:
|
||||||
|
{{
|
||||||
|
"question_text": "The question text here",
|
||||||
|
"options": ["Option A", "Option B", "Option C", "Option D"],
|
||||||
|
"correct_answer": 0,
|
||||||
|
"explanation": "Detailed explanation of why this is correct"
|
||||||
|
}}"#,
|
||||||
|
question_text, difficulty, skill
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call Ollama AI with extended timeout
|
||||||
|
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.2:3b".to_string());
|
||||||
|
let url = format!("{}/api/chat", base_url);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(600)) // 10 minutes timeout for slower machines
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
|
|
||||||
|
tracing::info!("Calling Ollama at {} with model {}", url, model);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{ "role": "system", "content": system_prompt },
|
||||||
|
{ "role": "user", "content": "Generate the question in JSON format" }
|
||||||
|
],
|
||||||
|
"stream": false,
|
||||||
|
"format": "json"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", response.status())));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = response.json().await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?;
|
||||||
|
|
||||||
|
// Extract content from Ollama response
|
||||||
|
let content = result
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response format".to_string()))?;
|
||||||
|
|
||||||
|
// Parse AI response as JSON
|
||||||
|
let ai_question: AIQuestionResponse = serde_json::from_str(content)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse question JSON: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(ai_question))
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use common::models::{
|
|||||||
use common::{auth::Claims, middleware::Org};
|
use common::{auth::Claims, middleware::Org};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::time::Duration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// ==================== Query Parameters ====================
|
// ==================== Query Parameters ====================
|
||||||
@@ -635,12 +636,16 @@ pub async fn generate_questions_with_rag(
|
|||||||
let mysql_questions: Vec<MySqlQuestion> = if let Some(course_id) = payload.course_id {
|
let mysql_questions: Vec<MySqlQuestion> = if let Some(course_id) = payload.course_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT bp.descripcion, bp.idTipoPregunta, c.NombreCurso, pe.Nombre as PlanNombre
|
SELECT
|
||||||
|
bp.descripcion,
|
||||||
|
bp.idTipoPregunta AS id_tipo_pregunta,
|
||||||
|
c.NombreCurso AS nombre_curso,
|
||||||
|
pe.Nombre as plan_nombre
|
||||||
FROM bancopreguntas bp
|
FROM bancopreguntas bp
|
||||||
JOIN curso c ON bp.idCursos = c.idCursos
|
JOIN curso c ON bp.idCursos = c.idCursos
|
||||||
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
|
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
|
||||||
WHERE bp.idCursos = ? AND bp.activo = 1
|
WHERE bp.idCursos = ? AND bp.activo = 1
|
||||||
LIMIT 50
|
LIMIT 20
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
@@ -650,12 +655,16 @@ pub async fn generate_questions_with_rag(
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT bp.descripcion, bp.idTipoPregunta, c.NombreCurso, pe.Nombre as PlanNombre
|
SELECT
|
||||||
|
bp.descripcion,
|
||||||
|
bp.idTipoPregunta AS id_tipo_pregunta,
|
||||||
|
c.NombreCurso AS nombre_curso,
|
||||||
|
pe.Nombre as plan_nombre
|
||||||
FROM bancopreguntas bp
|
FROM bancopreguntas bp
|
||||||
JOIN curso c ON bp.idCursos = c.idCursos
|
JOIN curso c ON bp.idCursos = c.idCursos
|
||||||
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
|
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
|
||||||
WHERE bp.activo = 1
|
WHERE bp.activo = 1
|
||||||
LIMIT 50
|
LIMIT 20
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.fetch_all(&mysql_pool)
|
.fetch_all(&mysql_pool)
|
||||||
@@ -669,95 +678,60 @@ pub async fn generate_questions_with_rag(
|
|||||||
return Err((StatusCode::NOT_FOUND, "No questions found in MySQL bank for this course".to_string()));
|
return Err((StatusCode::NOT_FOUND, "No questions found in MySQL bank for this course".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build RAG context from MySQL questions
|
// 2. Build RAG context from MySQL questions (lightweight format)
|
||||||
let rag_context: String = mysql_questions
|
let rag_context: String = mysql_questions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|q| format!("- {} (Tipo: {}, Curso: {})", q.descripcion, q.id_tipo_pregunta, q.nombre_curso))
|
.take(15) // Use only first 15 for context
|
||||||
|
.map(|q| format!("* {}", q.descripcion))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
// 3. Call AI to generate new questions based on RAG context
|
tracing::info!("RAG context built with {} questions", mysql_questions.len().min(15));
|
||||||
let provider = std::env::var("AI_PROVIDER").unwrap_or_else(|_| "local".to_string());
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let (url, auth_header, model) = if provider == "local" {
|
// 3. Call AI to generate new questions based on RAG context (Ollama only)
|
||||||
let base_url = std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
let base_url = std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
let model = std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
let model = std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||||
(
|
let url = format!("{}/api/chat", base_url);
|
||||||
format!("{}/v1/chat/completions", base_url),
|
|
||||||
"".to_string(),
|
|
||||||
model,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let api_key = std::env::var("OPENAI_API_KEY")
|
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "OPENAI_API_KEY not configured".to_string()))?;
|
|
||||||
(
|
|
||||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
|
||||||
format!("Bearer {}", api_key),
|
|
||||||
"gpt-4o".to_string(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Create client with extended timeout for slower Ollama instances
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(600)) // 10 minutes timeout for slower machines
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
|
|
||||||
|
tracing::info!("Calling Ollama at {} with model {}", url, model);
|
||||||
|
|
||||||
|
// Simplified system prompt for better performance on slower machines
|
||||||
let system_prompt = format!(
|
let system_prompt = format!(
|
||||||
r#"You are an expert English Teacher creating ORIGINAL quiz questions that assess the FOUR KEY LANGUAGE SKILLS.
|
r#"You are an English Teacher creating quiz questions.
|
||||||
|
|
||||||
Below are EXAMPLE questions from our existing question bank. Use them as INSPIRATION ONLY:
|
Use these examples as inspiration (do NOT copy):
|
||||||
- Understand the TOPICS, STYLE, and DIFFICULTY LEVEL
|
|
||||||
- Create COMPLETELY NEW questions with different wording, contexts, and answer options
|
|
||||||
- Do NOT copy any question directly
|
|
||||||
- Adapt the concepts to fresh scenarios
|
|
||||||
|
|
||||||
EXAMPLE QUESTIONS (for inspiration only):
|
|
||||||
{}
|
{}
|
||||||
|
|
||||||
Task: Create {} ORIGINAL multiple-choice questions about: {}
|
Create {} ORIGINAL multiple-choice questions about: {}
|
||||||
|
|
||||||
IMPORTANT: Each question must assess ONE of these FOUR skills:
|
Return ONLY a JSON array with this structure:
|
||||||
1. READING - Comprehension, vocabulary in context, text analysis
|
|
||||||
2. LISTENING - Audio comprehension, spoken dialogue understanding
|
|
||||||
3. SPEAKING - Oral production, pronunciation, conversational response
|
|
||||||
4. WRITING - Written production, grammar in writing, composition
|
|
||||||
|
|
||||||
For EACH question, you MUST:
|
|
||||||
- Specify which skill it assesses (reading/listening/speaking/writing)
|
|
||||||
- Ensure the question actually tests that skill (not just knowledge)
|
|
||||||
- Provide pedagogically sound content for English language learning
|
|
||||||
- Match the difficulty level appropriately
|
|
||||||
|
|
||||||
Return ONLY a JSON array of questions with this exact structure:
|
|
||||||
[
|
[
|
||||||
{{
|
{{
|
||||||
"question_text": "Your ORIGINAL question text here",
|
"question_text": "Question text",
|
||||||
"question_type": "multiple-choice",
|
"question_type": "multiple-choice",
|
||||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
"options": ["A", "B", "C", "D"],
|
||||||
"correct_answer": 0,
|
"correct_answer": 0,
|
||||||
"explanation": "Brief explanation of why this is correct. End with: 'Skill assessed: [READING|LISTENING|SPEAKING|WRITING]'",
|
"explanation": "Why this is correct",
|
||||||
"points": 1,
|
"points": 1,
|
||||||
"skill_assessed": "reading"
|
"skill_assessed": "reading"
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
|
|
||||||
GUIDELINES:
|
Skills: reading, listening, speaking, writing. Distribute across all 4."#,
|
||||||
- Each question must be UNIQUE - rephrase concepts from examples
|
|
||||||
- Use different vocabulary, scenarios, and contexts
|
|
||||||
- Maintain similar difficulty level to the examples
|
|
||||||
- Ensure correct_answer is the index (0-3) of the correct option
|
|
||||||
- Write clear explanations that teach, not just state the answer
|
|
||||||
- DISTRIBUTE questions across all 4 skills (don't focus on just one)
|
|
||||||
|
|
||||||
Example transformations by skill:
|
|
||||||
- READING: "Read this passage and answer..." or "What does the word X mean in context?"
|
|
||||||
- LISTENING: "After listening to the dialogue..." or "What would you hear in this situation?"
|
|
||||||
- SPEAKING: "Which response is most appropriate in this conversation?"
|
|
||||||
- WRITING: "Which sentence is grammatically correct?" or "Complete the sentence with..."
|
|
||||||
|
|
||||||
Be creative while maintaining educational value and ensuring all 4 skills are covered!"#,
|
|
||||||
rag_context,
|
rag_context,
|
||||||
payload.num_questions.unwrap_or(5),
|
payload.num_questions.unwrap_or(5),
|
||||||
payload.topic.unwrap_or_else(|| "English grammar and vocabulary".to_string())
|
payload.topic.unwrap_or_else(|| "English grammar".to_string())
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut request = client
|
tracing::debug!("System prompt length: {} chars", system_prompt.len());
|
||||||
|
|
||||||
|
let request = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -765,33 +739,38 @@ pub async fn generate_questions_with_rag(
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": system_prompt
|
"content": system_prompt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Generate the questions in valid JSON format."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"response_format": { "type": "json_object" }
|
"stream": false,
|
||||||
|
"format": "json" // Ollama native JSON mode
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if !auth_header.is_empty() {
|
tracing::info!("Sending request to Ollama (model: {}, prompt length: {} chars)", model, system_prompt.len());
|
||||||
request = request.header("Authorization", auth_header);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = request.send().await.map_err(|e| {
|
let response = request.send().await.map_err(|e| {
|
||||||
tracing::error!("AI request failed: {}", e);
|
tracing::error!("AI request failed after timeout: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "AI service unavailable".to_string())
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("Ollama timeout - el equipo t-800 está tardando en responder. Intenta nuevamente: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Ollama response status: {}", response.status());
|
||||||
|
|
||||||
let response_json: serde_json::Value = response.json().await.map_err(|e| {
|
let response_json: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
tracing::error!("Failed to parse AI response: {}", e);
|
tracing::error!("Failed to parse AI response: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response".to_string())
|
(StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Parse questions from AI response
|
tracing::debug!("Ollama response: {:?}", response_json);
|
||||||
|
|
||||||
|
// Parse questions from Ollama response
|
||||||
let questions_data = response_json
|
let questions_data = response_json
|
||||||
.get("choices")
|
.get("message")
|
||||||
.and_then(|c| c.as_array())
|
|
||||||
.and_then(|choices| choices.first())
|
|
||||||
.and_then(|c| c.get("message"))
|
|
||||||
.and_then(|m| m.get("content"))
|
.and_then(|m| m.get("content"))
|
||||||
.and_then(|content| serde_json::from_str::<serde_json::Value>(content.as_str().unwrap_or("{}")).ok())
|
.and_then(|content| content.as_str())
|
||||||
|
.and_then(|content| serde_json::from_str::<serde_json::Value>(content).ok())
|
||||||
.and_then(|data| {
|
.and_then(|data| {
|
||||||
if let Some(questions) = data.get("questions").or(data.get("items")) {
|
if let Some(questions) = data.get("questions").or(data.get("items")) {
|
||||||
questions.as_array().cloned()
|
questions.as_array().cloned()
|
||||||
|
|||||||
@@ -351,6 +351,10 @@ async fn main() {
|
|||||||
"/question-bank/import-mysql-all",
|
"/question-bank/import-mysql-all",
|
||||||
post(handlers_question_bank::import_all_from_mysql),
|
post(handlers_question_bank::import_all_from_mysql),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/question-bank/ai-generate",
|
||||||
|
post(handlers_question_bank::ai_generate_question),
|
||||||
|
)
|
||||||
// Excel import - pendiente de fix
|
// Excel import - pendiente de fix
|
||||||
// .route(
|
// .route(
|
||||||
// "/question-bank/import-excel",
|
// "/question-bank/import-excel",
|
||||||
|
|||||||
@@ -1322,7 +1322,7 @@ pub struct ApplyTemplatePayload {
|
|||||||
|
|
||||||
// ==================== Question Bank ====================
|
// ==================== Question Bank ====================
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, Clone, Copy, sqlx::Type, PartialEq)]
|
||||||
#[sqlx(type_name = "question_bank_type")]
|
#[sqlx(type_name = "question_bank_type")]
|
||||||
pub enum QuestionBankType {
|
pub enum QuestionBankType {
|
||||||
#[sqlx(rename = "multiple-choice")]
|
#[sqlx(rename = "multiple-choice")]
|
||||||
|
|||||||
@@ -148,54 +148,43 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
|
|||||||
try {
|
try {
|
||||||
setGeneratingAI(true);
|
setGeneratingAI(true);
|
||||||
|
|
||||||
// AI generation with skill verification
|
// AI generation con el nuevo endpoint de question bank
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/lessons/dummy/generate-quiz`, {
|
const token = localStorage.getItem('studio_token');
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/ai-generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
context: `Generate a question that assesses ${randomSkill.toUpperCase()} skill. ${skillPrompts[randomSkill as keyof typeof skillPrompts]}
|
question_text: formData.question_text,
|
||||||
|
difficulty: formData.difficulty,
|
||||||
Base question context: ${formData.question_text}
|
skill: randomSkill,
|
||||||
|
|
||||||
IMPORTANT: The question must:
|
|
||||||
1. Test the ${randomSkill} skill specifically
|
|
||||||
2. Be pedagogically sound for English language learning
|
|
||||||
3. Include clear options and a thorough explanation
|
|
||||||
4. Be appropriate for the difficulty level: ${formData.difficulty}
|
|
||||||
|
|
||||||
Return the question with options and explanation.`,
|
|
||||||
quiz_type: formData.question_type,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error al generar con IA');
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Error ${response.status}: ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.blocks && data.blocks.length > 0) {
|
if (data.options && data.correct_answer !== undefined) {
|
||||||
const block = data.blocks[0];
|
setFormData({
|
||||||
if (block.quiz_data && block.quiz_data.questions && block.quiz_data.questions.length > 0) {
|
...formData,
|
||||||
const aiQuestion = block.quiz_data.questions[0];
|
options: data.options,
|
||||||
setFormData({
|
correct_answer: data.correct_answer,
|
||||||
...formData,
|
explanation: data.explanation
|
||||||
options: aiQuestion.options || formData.options,
|
? `${data.explanation}\n\n📊 Skill assessed: ${randomSkill.toUpperCase()}`
|
||||||
correct_answer: aiQuestion.correct,
|
: `This question assesses ${randomSkill.toUpperCase()} skills.`,
|
||||||
explanation: aiQuestion.explanation
|
tags: [...(formData.tags || []), randomSkill, 'ai-generated'],
|
||||||
? `${aiQuestion.explanation}\n\n📊 Skill assessed: ${randomSkill.toUpperCase()}`
|
});
|
||||||
: `This question assesses ${randomSkill.toUpperCase()} skills.`,
|
alert(`IA generó las opciones y explicación enfocadas en la habilidad: ${randomSkill.toUpperCase()}`);
|
||||||
tags: [...(formData.tags || []), randomSkill, 'ai-generated'],
|
|
||||||
});
|
|
||||||
alert(`IA generó las opciones y explicación enfocadas en la habilidad: ${randomSkill.toUpperCase()}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI generation error:', error);
|
console.error('AI generation error:', error);
|
||||||
alert('Error al generar con IA. Asegúrate de tener Ollama configurado.');
|
alert(`Error al generar con IA: ${error instanceof Error ? error.message : 'Verifica que Ollama esté configurado'}`);
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingAI(false);
|
setGeneratingAI(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,65 +163,61 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
setExpandedQuestion(newQuestion.id);
|
setExpandedQuestion(newQuestion.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateQuestion = (questionId: string, updates: Partial<Question>) => {
|
|
||||||
setQuestions(questions.map(q => q.id === questionId ? { ...q, ...updates } : q));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveQuestion = (questionId: string) => {
|
|
||||||
setQuestions(questions.filter(q => q.id !== questionId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateWithAI = async () => {
|
const handleGenerateWithAI = async () => {
|
||||||
if (!aiContext.trim()) {
|
if (!aiContext.trim()) {
|
||||||
alert('Ingresa el contexto para generar las preguntas (ej: tema de la lección, contenido, etc.)');
|
alert('Ingresa el contexto para generar las preguntas (ej: tema de la lección, contenido, etc.)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('studio_token');
|
||||||
|
if (!token) {
|
||||||
|
alert('No hay sesión activa. Por favor inicia sesión nuevamente.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setGeneratingAI(true);
|
setGeneratingAI(true);
|
||||||
|
|
||||||
// Usar el endpoint de generación de quiz existente
|
// Usar el endpoint RAG de generación de preguntas desde banco MySQL
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/lessons/dummy/generate-quiz`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/test-templates/generate-with-rag`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
context: aiContext,
|
topic: aiContext,
|
||||||
quiz_type: 'multiple-choice',
|
num_questions: 5,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error al generar con IA');
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Error ${response.status}: ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const generatedQuestions = await response.json();
|
||||||
|
|
||||||
// Parsear las preguntas generadas
|
// Parsear las preguntas generadas
|
||||||
if (data.blocks && data.blocks.length > 0) {
|
if (Array.isArray(generatedQuestions) && generatedQuestions.length > 0) {
|
||||||
const block = data.blocks[0];
|
const questionsToAdd: Question[] = generatedQuestions.map((q: any, idx: number) => ({
|
||||||
if (block.quiz_data && block.quiz_data.questions) {
|
id: `q-${Date.now()}-${idx}`,
|
||||||
const generatedQuestions: Question[] = block.quiz_data.questions.map((q: any, idx: number) => ({
|
section_id: undefined,
|
||||||
id: `q-${Date.now()}-${idx}`,
|
question_order: questions.length + idx,
|
||||||
section_id: undefined,
|
question_type: q.question_type || 'multiple-choice',
|
||||||
question_order: idx,
|
question_text: q.question_text || q.text,
|
||||||
question_type: q.type || 'multiple-choice',
|
options: q.options || [],
|
||||||
question_text: q.question,
|
correct_answer: q.correct_answer || q.correct,
|
||||||
options: q.options,
|
explanation: q.explanation || '',
|
||||||
correct_answer: q.correct,
|
points: q.points || 1,
|
||||||
explanation: q.explanation || '',
|
}));
|
||||||
points: 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setQuestions([...questions, ...generatedQuestions]);
|
setQuestions([...questions, ...questionsToAdd]);
|
||||||
alert(`Se generaron ${generatedQuestions.length} preguntas con IA`);
|
alert(`Se generaron ${questionsToAdd.length} preguntas con IA`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI generation error:', error);
|
console.error('AI generation error:', error);
|
||||||
alert('Error al generar preguntas con IA. Asegúrate de tener Ollama configurado.');
|
alert(`Error al generar preguntas con IA: ${error instanceof Error ? error.message : 'Verifica que Ollama esté configurado y el banco de preguntas MySQL tenga datos'}`);
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingAI(false);
|
setGeneratingAI(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user