feat: fix AI for generation questions with RAG
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user