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 {
// 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<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 ====================
@@ -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<MySqlQuestionFull> = 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<i32>,
pub id_plan_de_estudios: i32,
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)]
@@ -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<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 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<MySqlQuestion> = 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::<Vec<_>>()
.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::<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| {
if let Some(questions) = data.get("questions").or(data.get("items")) {
questions.as_array().cloned()
+4
View File
@@ -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",
+1 -1
View File
@@ -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")]
@@ -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);
}
@@ -163,65 +163,61 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
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 () => {
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);
}