feat: fix AI for generation questions with RAG

This commit is contained in:
2026-03-17 17:44:42 -03:00
parent 31939e31ad
commit 55f9a3196e
6 changed files with 376 additions and 196 deletions
@@ -294,8 +294,8 @@ 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"]))
} else if question_type == QuestionBankType::TrueFalse { } else if question_type == QuestionBankType::TrueFalse {
@@ -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,10 +695,12 @@ 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(
r#" r#"
INSERT INTO question_bank ( INSERT INTO question_bank (
@@ -605,13 +717,13 @@ 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)
.execute(&pool) .execute(&pool)
.await; .await;
imported_count += 1; imported_count += 1;
} }
} }
@@ -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,114 +655,83 @@ 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)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
}; };
mysql_pool.close().await; mysql_pool.close().await;
if mysql_questions.is_empty() && payload.course_id.is_some() { if mysql_questions.is_empty() && payload.course_id.is_some() {
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");
tracing::info!("RAG context built with {} questions", mysql_questions.len().min(15));
// 3. Call AI to generate new questions based on RAG context // 3. Call AI to generate new questions based on RAG context (Ollama only)
let provider = std::env::var("AI_PROVIDER").unwrap_or_else(|_| "local".to_string()); let base_url = std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let client = reqwest::Client::new(); let model = std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
let url = format!("{}/api/chat", base_url);
let (url, auth_header, model) = if provider == "local" {
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());
(
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())
); );
tracing::debug!("System prompt length: {} chars", system_prompt.len());
let mut request = client 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()
+4
View File
@@ -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",
+1 -1
View File
@@ -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")]
@@ -147,55 +147,44 @@ 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, ...questionsToAdd]);
alert(`Se generaron ${questionsToAdd.length} preguntas con IA`);
setQuestions([...questions, ...generatedQuestions]);
alert(`Se generaron ${generatedQuestions.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);
} }