From 55f9a3196e1f672bea7a7e733fdd9ea8ce66d764 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 17 Mar 2026 17:44:42 -0300 Subject: [PATCH] feat: fix AI for generation questions with RAG --- .../cms-service/src/handlers_question_bank.rs | 280 +++++++++++++++--- .../src/handlers_test_templates.rs | 161 +++++----- services/cms-service/src/main.rs | 4 + shared/common/src/models.rs | 2 +- .../QuestionBank/QuestionBankEditor.tsx | 57 ++-- .../TestTemplates/TestTemplateForm.tsx | 68 ++--- 6 files changed, 376 insertions(+), 196 deletions(-) diff --git a/services/cms-service/src/handlers_question_bank.rs b/services/cms-service/src/handlers_question_bank.rs index 242b352..9c071c1 100644 --- a/services/cms-service/src/handlers_question_bank.rs +++ b/services/cms-service/src/handlers_question_bank.rs @@ -294,8 +294,8 @@ pub async fn import_from_mysql( if let Some(question) = mq { // 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 { Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"])) } else if question_type == QuestionBankType::TrueFalse { @@ -374,7 +374,7 @@ pub async fn import_from_mysql( } // 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) let options = if question_type == QuestionBankType::MultipleChoice { @@ -431,16 +431,110 @@ pub async fn import_from_mysql( // ==================== 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 - // This depends on how tipos are defined in the MySQL database - match mysql_type { - 1 => QuestionBankType::MultipleChoice, - 2 => QuestionBankType::TrueFalse, - 3 => QuestionBankType::ShortAnswer, - 4 => QuestionBankType::Matching, - _ => QuestionBankType::MultipleChoice, // Default + // First try by name, then by ID as fallback + if let Some(nombre) = tipo_nombre { + let nombre_lower = nombre.to_lowercase(); + if nombre_lower.contains("selecc") || nombre_lower.contains("múltiple") || nombre_lower.contains("multiple") || nombre_lower.contains("alternativa") { + return QuestionBankType::MultipleChoice; + } + 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, Option) { + if let Some(json_str) = answers_json { + if let Ok(answers) = serde_json::from_str::>(json_str) { + if !answers.is_empty() { + // Extract options (all answer texts) + let options: Vec = 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 = 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 ==================== @@ -496,24 +590,41 @@ pub async fn import_all_from_mysql( .await .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 = sqlx::query_as( r#" - SELECT - bp.idPregunta, - bp.descripcion, - bp.idTipoPregunta, - bp.activo, - c.idCursos, - c.NombreCurso, - c.NivelCurso, - pe.idPlanDeEstudios, - pe.Nombre as PlanNombre + SELECT + bp.idPregunta AS id_pregunta, + bp.descripcion AS descripcion, + bp.idTipoPregunta AS id_tipo_pregunta, + bp.activo AS activo, + c.idCursos AS id_cursos, + c.NombreCurso AS nombre_curso, + c.NivelCurso AS nivel_curso, + pe.idPlanDeEstudios AS id_plan_de_estudios, + 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 JOIN curso c ON bp.idCursos = c.idCursos JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios + JOIN tipopregunta tp ON bp.idTipoPregunta = tp.idTipoPregunta WHERE bp.activo = 1 ORDER BY pe.Nombre, c.NombreCurso, bp.idPregunta + LIMIT 500 "# ) .fetch_all(&mysql_pool) @@ -566,15 +677,14 @@ pub async fn import_all_from_mysql( } } None => { - // New question - insert - let question_type = map_mysql_question_type(mq.id_tipo_pregunta); - let options = if question_type == QuestionBankType::MultipleChoice { - Some(serde_json::json!(["Opción A", "Opción B", "Opción C", "Opción D"])) - } else if question_type == QuestionBankType::TrueFalse { - Some(serde_json::json!(["Verdadero", "Falso"])) - } else { - None - }; + // New question - insert with answers from MySQL + let question_type = map_mysql_question_type(mq.id_tipo_pregunta, mq.tipo_pregunta_nombre.as_deref()); + + // Parse answers from MySQL + let (options, correct_answer) = parse_mysql_answers(mq.respuestas_json.as_deref(), question_type); + + let has_answers = mq.respuestas_json.is_some(); + let question_type_name = mq.tipo_pregunta_nombre.clone(); let source_metadata = serde_json::json!({ "mysql_table": "bancopreguntas", @@ -585,10 +695,12 @@ pub async fn import_all_from_mysql( "idPlanDeEstudios": mq.id_plan_de_estudios, "plan_nombre": mq.plan_nombre, "idTipoPregunta": mq.id_tipo_pregunta, + "tipo_pregunta_nombre": question_type_name, "imported_at": chrono::Utc::now().to_rfc3339(), "import_method": "bulk_import_all", + "has_answers": has_answers, }); - + let _ = sqlx::query( r#" INSERT INTO question_bank ( @@ -605,13 +717,13 @@ pub async fn import_all_from_mysql( .bind(&mq.descripcion) .bind(&question_type) .bind(&options) - .bind(&serde_json::Value::Null) + .bind(&correct_answer) .bind(&source_metadata) .bind(mq.id_pregunta) .bind(mq.id_cursos) .execute(&pool) .await; - + imported_count += 1; } } @@ -672,6 +784,8 @@ pub struct MySqlQuestionFull { pub nivel_curso: Option, pub id_plan_de_estudios: i32, pub plan_nombre: String, + pub respuestas_json: Option, // JSON array de respuestas + pub tipo_pregunta_nombre: Option, // Nombre del tipo de pregunta } #[derive(Debug, sqlx::FromRow)] @@ -685,3 +799,101 @@ struct MySqlQuestion { id_plan_de_estudios: i32, plan_nombre: String, } + +// ==================== AI Generation ==================== + +#[derive(Debug, Deserialize)] +pub struct AIGenerateQuestionPayload { + pub question_text: Option, + pub question_type: Option, + pub difficulty: Option, + pub skill: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AIQuestionResponse { + pub question_text: String, + pub options: Vec, + 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, +) -> Result, (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)) +} diff --git a/services/cms-service/src/handlers_test_templates.rs b/services/cms-service/src/handlers_test_templates.rs index 6a9fc6d..22cb04d 100644 --- a/services/cms-service/src/handlers_test_templates.rs +++ b/services/cms-service/src/handlers_test_templates.rs @@ -10,6 +10,7 @@ use common::models::{ use common::{auth::Claims, middleware::Org}; use serde::Deserialize; use sqlx::PgPool; +use std::time::Duration; use uuid::Uuid; // ==================== Query Parameters ==================== @@ -635,12 +636,16 @@ pub async fn generate_questions_with_rag( let mysql_questions: Vec = if let Some(course_id) = payload.course_id { sqlx::query_as( 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 JOIN curso c ON bp.idCursos = c.idCursos JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios WHERE bp.idCursos = ? AND bp.activo = 1 - LIMIT 50 + LIMIT 20 "# ) .bind(course_id) @@ -650,114 +655,83 @@ pub async fn generate_questions_with_rag( } else { sqlx::query_as( 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 JOIN curso c ON bp.idCursos = c.idCursos JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios WHERE bp.activo = 1 - LIMIT 50 + LIMIT 20 "# ) .fetch_all(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))? }; - + mysql_pool.close().await; - + 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())); } - - // 2. Build RAG context from MySQL questions + + // 2. Build RAG context from MySQL questions (lightweight format) let rag_context: String = mysql_questions .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::>() .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 - 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" { - 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(), - ) - }; + // 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 model = std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); + let url = format!("{}/api/chat", base_url); + // 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!( - r#"You are an expert English Teacher creating ORIGINAL quiz questions that assess the FOUR KEY LANGUAGE SKILLS. - - Below are EXAMPLE questions from our existing question bank. Use them as INSPIRATION ONLY: - - 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): + r#"You are an English Teacher creating quiz questions. + + Use these examples as inspiration (do NOT copy): {} - - Task: Create {} ORIGINAL multiple-choice questions about: {} - - IMPORTANT: Each question must assess ONE of these FOUR skills: - 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: + + Create {} ORIGINAL multiple-choice questions about: {} + + Return ONLY a JSON array with this structure: [ {{ - "question_text": "Your ORIGINAL question text here", + "question_text": "Question text", "question_type": "multiple-choice", - "options": ["Option A", "Option B", "Option C", "Option D"], + "options": ["A", "B", "C", "D"], "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, "skill_assessed": "reading" }} ] - - GUIDELINES: - - 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!"#, + + Skills: reading, listening, speaking, writing. Distribute across all 4."#, rag_context, 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) .json(&json!({ "model": model, @@ -765,33 +739,38 @@ pub async fn generate_questions_with_rag( { "role": "system", "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() { - request = request.header("Authorization", auth_header); - } + + tracing::info!("Sending request to Ollama (model: {}, prompt length: {} chars)", model, system_prompt.len()); let response = request.send().await.map_err(|e| { - tracing::error!("AI request failed: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, "AI service unavailable".to_string()) + tracing::error!("AI request failed after timeout: {}", e); + (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| { tracing::error!("Failed to parse AI response: {}", e); (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 - .get("choices") - .and_then(|c| c.as_array()) - .and_then(|choices| choices.first()) - .and_then(|c| c.get("message")) + .get("message") .and_then(|m| m.get("content")) - .and_then(|content| serde_json::from_str::(content.as_str().unwrap_or("{}")).ok()) + .and_then(|content| content.as_str()) + .and_then(|content| serde_json::from_str::(content).ok()) .and_then(|data| { if let Some(questions) = data.get("questions").or(data.get("items")) { questions.as_array().cloned() diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 0135966..348e815 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -351,6 +351,10 @@ async fn main() { "/question-bank/import-mysql-all", 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 // .route( // "/question-bank/import-excel", diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 0ff4d42..f989cd8 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -1322,7 +1322,7 @@ pub struct ApplyTemplatePayload { // ==================== 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")] pub enum QuestionBankType { #[sqlx(rename = "multiple-choice")] diff --git a/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx b/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx index 5ce5d5f..49f9f7a 100644 --- a/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx +++ b/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx @@ -147,55 +147,44 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu try { setGeneratingAI(true); - - // AI generation with skill verification - const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/lessons/dummy/generate-quiz`, { + + // AI generation con el nuevo endpoint de question bank + 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', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ - context: `Generate a question that assesses ${randomSkill.toUpperCase()} skill. ${skillPrompts[randomSkill as keyof typeof skillPrompts]} - - Base question context: ${formData.question_text} - - 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, + question_text: formData.question_text, + difficulty: formData.difficulty, + skill: randomSkill, }), }); 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(); - - if (data.blocks && data.blocks.length > 0) { - const block = data.blocks[0]; - if (block.quiz_data && block.quiz_data.questions && block.quiz_data.questions.length > 0) { - const aiQuestion = block.quiz_data.questions[0]; - setFormData({ - ...formData, - options: aiQuestion.options || formData.options, - correct_answer: aiQuestion.correct, - explanation: aiQuestion.explanation - ? `${aiQuestion.explanation}\n\n📊 Skill assessed: ${randomSkill.toUpperCase()}` - : `This question assesses ${randomSkill.toUpperCase()} skills.`, - tags: [...(formData.tags || []), randomSkill, 'ai-generated'], - }); - alert(`IA generó las opciones y explicación enfocadas en la habilidad: ${randomSkill.toUpperCase()}`); - } + + if (data.options && data.correct_answer !== undefined) { + setFormData({ + ...formData, + options: data.options, + correct_answer: data.correct_answer, + explanation: data.explanation + ? `${data.explanation}\n\n📊 Skill assessed: ${randomSkill.toUpperCase()}` + : `This question assesses ${randomSkill.toUpperCase()} skills.`, + tags: [...(formData.tags || []), randomSkill, 'ai-generated'], + }); + alert(`IA generó las opciones y explicación enfocadas en la habilidad: ${randomSkill.toUpperCase()}`); } } catch (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 { setGeneratingAI(false); } diff --git a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx index 554f5d1..cd843c6 100644 --- a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx +++ b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx @@ -163,65 +163,61 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo setExpandedQuestion(newQuestion.id); }; - const handleUpdateQuestion = (questionId: string, updates: Partial) => { - setQuestions(questions.map(q => q.id === questionId ? { ...q, ...updates } : q)); - }; - - const handleRemoveQuestion = (questionId: string) => { - setQuestions(questions.filter(q => q.id !== questionId)); - }; - const handleGenerateWithAI = async () => { if (!aiContext.trim()) { alert('Ingresa el contexto para generar las preguntas (ej: tema de la lección, contenido, etc.)'); return; } + const token = localStorage.getItem('studio_token'); + if (!token) { + alert('No hay sesión activa. Por favor inicia sesión nuevamente.'); + return; + } + try { setGeneratingAI(true); - - // Usar el endpoint de generación de quiz existente - const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/lessons/dummy/generate-quiz`, { + + // 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'}/test-templates/generate-with-rag`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ - context: aiContext, - quiz_type: 'multiple-choice', + topic: aiContext, + num_questions: 5, }), }); 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 - if (data.blocks && data.blocks.length > 0) { - const block = data.blocks[0]; - if (block.quiz_data && block.quiz_data.questions) { - const generatedQuestions: Question[] = block.quiz_data.questions.map((q: any, idx: number) => ({ - id: `q-${Date.now()}-${idx}`, - section_id: undefined, - question_order: idx, - question_type: q.type || 'multiple-choice', - question_text: q.question, - options: q.options, - correct_answer: q.correct, - explanation: q.explanation || '', - points: 1, - })); - - setQuestions([...questions, ...generatedQuestions]); - alert(`Se generaron ${generatedQuestions.length} preguntas con IA`); - } + if (Array.isArray(generatedQuestions) && generatedQuestions.length > 0) { + const questionsToAdd: Question[] = generatedQuestions.map((q: any, idx: number) => ({ + id: `q-${Date.now()}-${idx}`, + section_id: undefined, + question_order: questions.length + idx, + question_type: q.question_type || 'multiple-choice', + question_text: q.question_text || q.text, + options: q.options || [], + correct_answer: q.correct_answer || q.correct, + explanation: q.explanation || '', + points: q.points || 1, + })); + + setQuestions([...questions, ...questionsToAdd]); + alert(`Se generaron ${questionsToAdd.length} preguntas con IA`); } } catch (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 { setGeneratingAI(false); }