feat: Translate various strings and comments to Spanish for better localization

- Updated error messages and comments in main.rs, openapi.rs, portfolio.rs, predictive.rs, ai.rs, health.rs, middleware.rs, models.rs, token_limits.rs, and webhooks.rs to Spanish.
- Enhanced user experience by providing localized content for Spanish-speaking users.
This commit is contained in:
2026-04-10 10:26:26 -04:00
parent 7c48b3b1a9
commit 53e5ef4d0b
35 changed files with 1135 additions and 1144 deletions
@@ -58,17 +58,17 @@ fn normalize_question_bank_payload_values(
#[derive(Debug, Deserialize)]
pub struct TestTemplateFilters {
pub mysql_course_id: Option<i32>, // Filter by MySQL course ID
pub mysql_course_id: Option<i32>, // Filtrar por ID de curso MySQL
pub level: Option<CourseLevel>,
pub course_type: Option<CourseType>,
pub test_type: Option<TestType>,
pub tags: Option<String>, // Comma-separated list
pub tags: Option<String>, // Lista separada por comas
pub search: Option<String>,
}
// ==================== Create ====================
/// POST /api/test-templates - Create a new test template
/// POST /api/test-templates - Crear una nueva plantilla de test
pub async fn create_test_template(
Org(org_ctx): Org,
claims: Claims,
@@ -115,47 +115,47 @@ pub async fn create_test_template(
// ==================== Read ====================
/// GET /api/test-templates - List test templates with filters
/// GET /api/test-templates - Listar plantillas de test con filtros
pub async fn list_test_templates(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Query(filters): Query<TestTemplateFilters>,
) -> Result<Json<Vec<TestTemplate>>, (StatusCode, String)> {
// Base query
// Consulta base
let mut query = String::from("SELECT * FROM test_templates WHERE organization_id = $1");
let mut param_count = 1;
// Filter by mysql_course_id
// Filtrar por mysql_course_id
if filters.mysql_course_id.is_some() {
param_count += 1;
query.push_str(&format!(" AND mysql_course_id = ${}", param_count));
}
// Filter by level
// Filtrar por nivel
if filters.level.is_some() {
param_count += 1;
query.push_str(&format!(" AND level = ${}", param_count));
}
// Filter by course type
// Filtrar por tipo de curso
if filters.course_type.is_some() {
param_count += 1;
query.push_str(&format!(" AND course_type = ${}", param_count));
}
// Filter by test type
// Filtrar por tipo de test
if filters.test_type.is_some() {
param_count += 1;
query.push_str(&format!(" AND test_type = ${}", param_count));
}
// Filter by tags (array overlap)
// Filtrar por etiquetas (solapamiento de array)
if filters.tags.is_some() {
param_count += 1;
query.push_str(&format!(" AND tags && ${}", param_count));
}
// Search in name and description
// Buscar en nombre y descripción
if filters.search.is_some() {
param_count += 1;
query.push_str(&format!(
@@ -166,7 +166,7 @@ pub async fn list_test_templates(
query.push_str(" ORDER BY created_at DESC");
// Build query with dynamic binds
// Construir consulta con binds dinámicos
let mut sql_query = sqlx::query_as::<_, TestTemplate>(&query).bind(org_ctx.id);
if let Some(mysql_course_id) = &filters.mysql_course_id {
@@ -203,13 +203,13 @@ pub async fn list_test_templates(
Ok(Json(templates))
}
/// GET /api/test-templates/:id - Get a specific test template with questions
/// GET /api/test-templates/:id - Obtener una plantilla de test específica con preguntas
pub async fn get_test_template(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<TestTemplateWithQuestions>, (StatusCode, String)> {
// Get template
// Obtener plantilla
let template: TestTemplate = sqlx::query_as(
r#"
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
@@ -224,11 +224,11 @@ pub async fn get_test_template(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
// Get sections
// Obtener secciones
let sections: Vec<TestTemplateSection> = sqlx::query_as(
r#"
SELECT id, template_id, title, description, section_order, points, instructions, section_data, created_at
@@ -242,7 +242,7 @@ pub async fn get_test_template(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Get questions
// Obtener preguntas
let questions: Vec<TestTemplateQuestion> = sqlx::query_as(
r#"
SELECT id, template_id, section_id, question_order, question_type, question_text,
@@ -266,7 +266,7 @@ pub async fn get_test_template(
// ==================== Update ====================
/// PUT /api/test-templates/:id - Update a test template
/// PUT /api/test-templates/:id - Actualizar una plantilla de test
pub async fn update_test_template(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
@@ -316,7 +316,7 @@ pub async fn update_test_template(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
@@ -325,7 +325,7 @@ pub async fn update_test_template(
// ==================== Delete ====================
/// DELETE /api/test-templates/:id - Delete a test template
/// DELETE /api/test-templates/:id - Eliminar una plantilla de test
pub async fn delete_test_template(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
@@ -344,22 +344,22 @@ pub async fn delete_test_template(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Template Questions Management ====================
// ==================== Gestión de Preguntas de la Plantilla ====================
/// POST /api/test-templates/:id/questions - Add a question to a template
/// POST /api/test-templates/:id/questions - Añadir una pregunta a una plantilla
pub async fn create_template_question(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<CreateQuestionPayload>,
) -> Result<Json<TestTemplateQuestion>, (StatusCode, String)> {
// Verify template exists and belongs to organization
// Verificar que la plantilla existe y pertenece a la organización
let exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
)
@@ -370,7 +370,7 @@ pub async fn create_template_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists.0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
let question: TestTemplateQuestion = sqlx::query_as(
@@ -414,13 +414,13 @@ pub struct CreateQuestionPayload {
pub metadata: Option<serde_json::Value>,
}
/// DELETE /api/test-templates/:template_id/questions/:question_id - Delete a question
/// DELETE /api/test-templates/:template_id/questions/:question_id - Eliminar una pregunta
pub async fn delete_template_question(
Org(org_ctx): Org,
Path((template_id, question_id)): Path<(Uuid, Uuid)>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Verify template exists and belongs to organization
// Verificar que la plantilla existe y pertenece a la organización
let exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
)
@@ -431,7 +431,7 @@ pub async fn delete_template_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists.0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
let result = sqlx::query(
@@ -447,22 +447,22 @@ pub async fn delete_template_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Question not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Template Sections Management ====================
// ==================== Gestión de Secciones de la Plantilla ====================
/// POST /api/test-templates/:id/sections - Add a section to a template
/// POST /api/test-templates/:id/sections - Añadir una sección a una plantilla
pub async fn create_template_section(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<CreateSectionPayload>,
) -> Result<Json<TestTemplateSection>, (StatusCode, String)> {
// Verify template exists
// Verificar que la plantilla existe
let exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
)
@@ -473,7 +473,7 @@ pub async fn create_template_section(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists.0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
let section: TestTemplateSection = sqlx::query_as(
@@ -509,7 +509,7 @@ pub struct CreateSectionPayload {
pub section_data: Option<serde_json::Value>,
}
/// DELETE /api/test-templates/:template_id/sections/:section_id - Delete a section
/// DELETE /api/test-templates/:template_id/sections/:section_id - Eliminar una sección
pub async fn delete_template_section(
Org(org_ctx): Org,
Path((template_id, section_id)): Path<(Uuid, Uuid)>,
@@ -528,15 +528,15 @@ pub async fn delete_template_section(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Section not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Sección no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Apply Template to Lesson ====================
// ==================== Aplicar Plantilla a la Lección ====================
/// POST /api/test-templates/:id/apply - Apply a template to a lesson
/// POST /api/test-templates/:id/apply - Aplicar una plantilla a una lección
pub async fn apply_template_to_lesson(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
@@ -544,7 +544,7 @@ pub async fn apply_template_to_lesson(
State(pool): State<PgPool>,
Json(payload): Json<ApplyTemplatePayload>,
) -> Result<StatusCode, (StatusCode, String)> {
// Verify template exists and belongs to organization
// Verificar que la plantilla existe y pertenece a la organización
let template: TestTemplate = sqlx::query_as(
r#"
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
@@ -559,11 +559,11 @@ pub async fn apply_template_to_lesson(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
// Verify lesson exists and belongs to organization
// Verificar que la lección existe y pertenece a la organización
let lesson_exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)"#
)
@@ -574,10 +574,10 @@ pub async fn apply_template_to_lesson(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !lesson_exists.0 {
return Err((StatusCode::NOT_FOUND, "Lesson not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".to_string()));
}
// Get template questions with their sections
// Obtener las preguntas de la plantilla con sus secciones
let template_questions: Vec<TestTemplateQuestion> = sqlx::query_as(
r#"
SELECT id, template_id, section_id, question_order, question_type, question_text,
@@ -593,10 +593,10 @@ pub async fn apply_template_to_lesson(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if template_questions.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string()));
return Err((StatusCode::BAD_REQUEST, "La plantilla no tiene preguntas".to_string()));
}
// Build quiz_data JSON from template questions
// Construir el JSON quiz_data a partir de las preguntas de la plantilla
let questions_json: Vec<serde_json::Value> = template_questions
.iter()
.map(|q| {
@@ -621,12 +621,12 @@ pub async fn apply_template_to_lesson(
"passing_score": template.passing_score,
"total_points": template.total_points,
"instructions": template.instructions,
"max_attempts": 1, // Single attempt as requested
"max_attempts": 1, // Intento único según lo solicitado
"show_feedback": true, // Show explanations after answering
"permanent_history": true, // Student can always view their responses
"permanent_history": true, // El estudiante siempre puede ver sus respuestas
});
// Update lesson with quiz data and configuration
// Actualizar lección con datos del cuestionario y configuración
sqlx::query(
r#"
UPDATE lessons
@@ -649,7 +649,7 @@ pub async fn apply_template_to_lesson(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Increment template usage count
// Incrementar el contador de uso de la plantilla
sqlx::query("SELECT increment_template_usage($1)")
.bind(template_id)
.execute(&pool)
@@ -657,7 +657,7 @@ pub async fn apply_template_to_lesson(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!(
"Applied template '{}' to lesson '{}' with {} questions",
"Plantilla '{}' aplicada a la lección '{}' con {} preguntas",
template.name,
payload.lesson_id,
template_questions.len()
@@ -672,9 +672,9 @@ pub struct ApplyTemplatePayload {
pub grading_category_id: Option<Uuid>,
}
// ==================== RAG Question Generation ====================
// ==================== Generación de Preguntas RAG ====================
// Helper function to generate system prompt based on question type
// Función auxiliar para generar el system prompt basado en el tipo de pregunta
fn get_system_prompt_for_question_type(
question_type: &str,
num_questions: i32,
@@ -686,12 +686,12 @@ fn get_system_prompt_for_question_type(
format!(
r#"You are an English Teacher creating quiz questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL true-false questions about: {}
Crea {} preguntas ORIGINALES de verdadero-falso sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "The capital of France is Paris.",
@@ -702,10 +702,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
}}
]
Rules:
- Each question must be a clear statement
- correct_answer must be true or false
- Explanations must be concise"#,
Reglas:
- Cada pregunta debe ser una afirmación clara
- correct_answer debe ser true o false
- Las explicaciones deben ser concisas"#,
rag_context, num_questions, topic
)
}
@@ -713,12 +713,12 @@ Rules:
format!(
r#"You are an English Teacher creating quiz questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL short-answer questions about: {}
Crea {} preguntas ORIGINALES de respuesta corta sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "What is the past tense of 'go'?",
@@ -730,10 +730,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
}}
]
Rules:
- correct_answer should be the expected response
- keywords array contains acceptable variations or key concepts to check
- Questions should accept brief responses"#,
Reglas:
- correct_answer debe ser la respuesta esperada
- el array de palabras clave (keywords) contiene variaciones aceptables o conceptos clave para verificar
- Las preguntas deben aceptar respuestas breves"#,
rag_context, num_questions, topic
)
}
@@ -741,12 +741,12 @@ Rules:
format!(
r#"You are an English Teacher creating essay questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL essay questions about: {}
Crea {} preguntas ORIGINALES de ensayo sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "Explain how setting influences the mood of a short story.",
@@ -757,10 +757,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
}}
]
Rules:
- Questions must require extended written responses
- correct_answer should contain rubric guidance or expected criteria
- No options array is needed"#,
Reglas:
- Las preguntas deben requerir respuestas escritas extensas
- correct_answer debe contener una guía de rúbrica o criterios esperados
- No se necesita un array de opciones (options)"#,
rag_context, num_questions, topic
)
}
@@ -768,15 +768,15 @@ Rules:
format!(
r#"You are an English Teacher creating quiz questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL matching question sets about: {}
Crea {} conjuntos de preguntas ORIGINALES de emparejamiento sobre: {}
IMPORTANT - Return ONLY a JSON array with matching questions. Each matching question should have this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con preguntas de emparejamiento. Cada pregunta de emparejamiento debe tener esta estructura EXACTA:
[
{{
"question_text": "Match each vocabulary term with its definition:",
"question_text": "Empareje cada término de vocabulario con su definición:",
"question_type": "matching",
"pairs": [
{{"left": "Verb", "right": "A word that describes an action"}},
@@ -788,128 +788,128 @@ IMPORTANT - Return ONLY a JSON array with matching questions. Each matching ques
}}
]
Rules:
- Create 3-5 matching pairs per question
- left/right items must be clear and distinct
- All items in pairs array must follow the same structure
- One question per array element"#,
Reglas:
- Crear 3-5 pares de emparejamiento por pregunta
- los elementos izquierda/derecha (left/right) deben ser claros y distintos
- Todos los elementos del array de pares deben seguir la misma estructura
- Una pregunta por elemento del array"#,
rag_context, num_questions, topic
)
}
"ordering" => {
format!(
r#"You are an English Teacher creating quiz questions.
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL ordering questions about: {}
Crea {} preguntas ORIGINALES de ordenación sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "Arrange these steps of the writing process in correct order:",
"question_text": "Organice estos pasos del proceso de escritura en el orden correcto:",
"question_type": "ordering",
"items": ["Revise", "Draft", "Prewrite", "Publish", "Edit"],
"correct_order": [2, 1, 3, 4, 0],
"explanation": "The writing process starts with prewriting, then drafting, revising, editing, and finally publishing.",
"explanation": "El proceso de escritura comienza con la pre-escritura, luego el borrador, la revisión, la edición y finalmente la publicación.",
"points": 3
}}
]
Rules:
- items array contains the items to order
- correct_order is an array of indices showing the proper sequence (0-based)
- Must have at least 4 items to order
- Questions should have a clear logical sequence"#,
Reglas:
- el array de elementos (items) contiene los elementos a ordenar
- correct_order es un array de índices que muestran la secuencia correcta (basado en 0)
- Debe tener al menos 4 elementos para ordenar
- Las preguntas deben tener una secuencia lógica clara"#,
rag_context, num_questions, topic
)
}
"fill-in-the-blanks" => {
format!(
r#"You are an English Teacher creating quiz questions.
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL fill-in-the-blanks questions about: {}
Crea {} preguntas ORIGINALES de completar espacios en blanco sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "The ________ is the main character in a story, while the ________ opposes them.",
"question_text": "El ________ es el personaje principal de una historia, mientras que el ________ se le opone.",
"question_type": "fill-in-the-blanks",
"blanks": [
{{"answer": "protagonist", "keywords": ["protagonist", "hero", "main character"]}},
{{"answer": "antagonist", "keywords": ["antagonist", "villain", "opponent"]}}
],
"explanation": "These are key literary terms describing characters in stories.",
"explanation": "Estos son términos literarios clave que describen personajes en las historias.",
"points": 2
}}
]
Rules:
- question_text should have ________ for each blank
- blanks array has one object per blank
- Each blank object must have 'answer' and 'keywords' array
- keywords should include the main answer plus acceptable variations
- Questions can have 1-3 blanks"#,
Reglas:
- question_text debe tener ________ para cada espacio en blanco
- el array de espacios (blanks) tiene un objeto por espacio en blanco
- Cada objeto de espacio en blanco debe tener 'answer' y un array 'keywords'
- keywords debe incluir la respuesta principal más variaciones aceptables
- Las preguntas pueden tener de 1 a 3 espacios en blanco"#,
rag_context, num_questions, topic
)
}
"audio-response" => {
format!(
r#"You are an English Teacher creating speaking exercises.
r#"Eres un profesor de inglés creando ejercicios de expresión oral.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL audio-response speaking prompts about: {}
Crea {} consignas ORIGINALES de respuesta de audio sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "Describe a memorable trip using at least three past tense verbs.",
"question_text": "Describa un viaje memorable utilizando al menos tres verbos en pasado.",
"question_type": "audio-response",
"correct_answer": "Use clear past tense forms and relevant travel vocabulary in a coherent answer.",
"explanation": "This prompt checks fluency and grammatical control in spoken production.",
"correct_answer": "Utilice formas claras en tiempo pasado y vocabulario de viaje relevante en una respuesta coherente.",
"explanation": "Esta consigna comprueba la fluidez y el control gramatical en la producción oral.",
"points": 2
}}
]
Rules:
- Questions must require spoken production
- correct_answer should contain rubric guidance or expected response criteria
- No options array is needed"#,
Reglas:
- Las preguntas deben requerir producción oral
- correct_answer debe contener una guía de rúbrica o criterios de respuesta esperados
- No se necesita un array de opciones (options)"#,
rag_context, num_questions, topic
)
}
_ => {
// Default to multiple-choice
format!(
r#"You are an English Teacher creating quiz questions.
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL multiple-choice questions about: {}
Crea {} preguntas ORIGINALES de opción múltiple sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "The tourist got lost in the ______ of the city.",
"question_text": "El turista se perdió en el ______ de la ciudad.",
"question_type": "multiple-choice",
"options": ["downtown", "countryside", "mountains", "desert"],
"correct_answer": 0,
"explanation": "Downtown is the main area of a city where tourists typically visit.",
"explanation": "El centro (downtown) es la zona principal de una ciudad que los turistas suelen visitar.",
"points": 1,
"skill_assessed": "reading"
}}
]
Rules:
- Option text only, no prefixes like A. or 1)
- Skills must be one of: reading, listening, speaking, writing"#,
Reglas:
- Solo texto de la opción, sin prefijos como A. o 1)
- Las habilidades (skills) deben ser una de: lectura, escucha, habla, escritura"#,
rag_context, num_questions, topic
)
}
@@ -997,8 +997,8 @@ fn parse_ai_response_for_question_type(
flatten_into_questions(questions_data)
}
/// POST /test-templates/generate-with-rag - Generate questions using RAG from imported MySQL question bank
/// Uses semantic search with pgvector embeddings when available, falls back to course_id filtering
/// POST /test-templates/generate-with-rag - Generar preguntas usando RAG del banco de preguntas MySQL importado
/// Usa búsqueda semántica con embeddings de pgvector cuando están disponibles, de lo contrario recurre al filtrado por course_id
pub async fn generate_questions_with_rag(
Org(org_ctx): Org,
claims: Claims,
@@ -1019,7 +1019,7 @@ pub async fn generate_questions_with_rag(
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
@@ -1070,13 +1070,11 @@ pub async fn generate_questions_with_rag(
.bind(requested_num_questions * 3) // Get more for diversity
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Semantic search failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda semántica falló: {}", e)))?;
tracing::info!("Semantic search found {} similar questions", mysql_questions.len());
tracing::info!("La búsqueda semántica encontró {} preguntas similares", mysql_questions.len());
if mysql_questions.is_empty() {
tracing::info!(
"Semantic search returned no rows; falling back to keyword search for topic {:?} and course {:?}",
topic,
payload.course_id
);
@@ -1123,11 +1121,9 @@ pub async fn generate_questions_with_rag(
.bind(requested_num_questions * 3)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword fallback failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("El recurso a palabras clave falló: {}", e)))?;
if mysql_questions.is_empty() {
tracing::info!(
"Keyword fallback returned no rows; falling back to imported MySQL questions for course {:?}",
payload.course_id
);
@@ -1168,7 +1164,7 @@ pub async fn generate_questions_with_rag(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Course fallback failed: {}", e),
format!("El recurso por curso falló: {}", e),
)
})?;
}
@@ -1176,7 +1172,7 @@ pub async fn generate_questions_with_rag(
}
}
Err(e) => {
tracing::warn!("Semantic search failed, falling back to keyword search: {}", e);
tracing::warn!("La búsqueda semántica falló, recurriendo a búsqueda por palabras clave: {}", e);
// Fall back to text search
mysql_questions = sqlx::query_as(
r#"
@@ -1220,11 +1216,9 @@ pub async fn generate_questions_with_rag(
.bind(requested_num_questions * 3)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword search failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda por palabras clave falló: {}", e)))?;
if mysql_questions.is_empty() {
tracing::info!(
"No semantic or keyword matches for topic; falling back to imported MySQL questions for course {:?}",
payload.course_id
);
@@ -1265,7 +1259,7 @@ pub async fn generate_questions_with_rag(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Course fallback failed: {}", e),
format!("El recurso por curso falló: {}", e),
)
})?;
}
@@ -1309,7 +1303,7 @@ pub async fn generate_questions_with_rag(
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?;
} else {
// Fetch all imported MySQL questions for this organization
// NO LIMIT - fetch all questions for better RAG context
@@ -1339,12 +1333,10 @@ pub async fn generate_questions_with_rag(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?;
}
if mysql_questions.is_empty() {
tracing::warn!(
"No imported MySQL questions found for org={} course={:?} topic={:?}; falling back to organization-wide imported questions",
org_ctx.id,
payload.course_id,
payload.topic
@@ -1379,7 +1371,7 @@ pub async fn generate_questions_with_rag(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to fetch organization-wide fallback questions: {}", e),
format!("Error al obtener preguntas de recurso de toda la organización: {}", e),
)
})?;
@@ -1405,7 +1397,7 @@ pub async fn generate_questions_with_rag(
.map(|q| get_course_level_from_mysql(q.nivel_curso, &q.plan_nombre, ""))
.unwrap_or(CourseLevel::Intermediate);
tracing::info!("Determined course_type: {:?}, level: {:?} from imported data", course_type, level);
tracing::info!("Tipo de curso determinado: {:?}, nivel: {:?} a partir de los datos importados", course_type, level);
// 2. Build RAG context from MySQL questions (lightweight format)
let rag_context: String = mysql_questions
@@ -1415,7 +1407,7 @@ pub async fn generate_questions_with_rag(
.collect::<Vec<_>>()
.join("\n");
tracing::info!("RAG context built with {} questions", mysql_questions.len().min(8));
tracing::info!("Contexto RAG construido con {} preguntas", mysql_questions.len().min(8));
// 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());
@@ -1428,7 +1420,7 @@ pub async fn generate_questions_with_rag(
.build()
.unwrap_or_else(|_| reqwest::Client::new());
tracing::info!("Calling Ollama at {} with model {}", url, model);
tracing::info!("Llamando a Ollama en {} con el modelo {}", url, model);
// Save topic for later use
let topic = payload.topic.clone().unwrap_or_else(|| "English grammar".to_string());
@@ -1459,7 +1451,7 @@ pub async fn generate_questions_with_rag(
&rag_context,
);
tracing::debug!("System prompt length: {} chars", system_prompt.len());
tracing::debug!("Longitud del prompt del sistema: {} caracteres", system_prompt.len());
let request = client
.post(&url)
@@ -1483,19 +1475,19 @@ pub async fn generate_questions_with_rag(
}
}));
tracing::info!("Sending request to Ollama (model: {}, prompt length: {} chars)", model, system_prompt.len());
tracing::info!("Enviando solicitud a Ollama (modelo: {}, longitud del prompt: {} caracteres)", model, system_prompt.len());
let response = request.send().await.map_err(|e| {
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());
tracing::info!("Estado de la respuesta de Ollama: {}", response.status());
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::error!("Ollama returned non-success status {}: {}", status, body);
tracing::error!("Ollama devolvió un estado de error {}: {}", status, body);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Ollama returned {}. El proxy de IA agotó el tiempo de espera.", status),
@@ -1503,8 +1495,8 @@ pub async fn generate_questions_with_rag(
}
let response_text = response.text().await.map_err(|e| {
tracing::error!("Failed to read AI stream response: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response".to_string())
tracing::error!("Error al leer la respuesta del flujo de la IA: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Respuesta de IA inválida".to_string())
})?;
let aggregated_content = response_text
@@ -1532,7 +1524,7 @@ pub async fn generate_questions_with_rag(
}
});
tracing::debug!("Ollama response: {:?}", response_json);
tracing::debug!("Respuesta de Ollama: {:?}", response_json);
// Parse questions from Ollama response
let ai_payload = response_json
@@ -1619,7 +1611,7 @@ pub async fn generate_questions_with_rag(
.to_string();
if question_text.is_empty() || question_text.eq_ignore_ascii_case("question") {
tracing::warn!("Skipping invalid generated question with empty placeholder text: {:?}", q);
tracing::warn!("Omitiendo pregunta generada inválida con texto de marcador vacío: {:?}", q);
return None;
}
@@ -1644,7 +1636,7 @@ pub async fn generate_questions_with_rag(
let (options, correct_answer, options_shuffled) = match question_type_value.as_str() {
"multiple-choice" => {
if original_options.len() < 2 {
tracing::warn!("Skipping invalid multiple-choice question without enough options: {:?}", q);
tracing::warn!("Omitiendo pregunta de opción múltiple inválida sin suficientes opciones: {:?}", q);
return None;
}
if !original_options.is_empty() && original_correct_idx.is_some() {
@@ -1675,7 +1667,7 @@ pub async fn generate_questions_with_rag(
.and_then(|v| v.as_bool())
.map(|v| if v { json!(0) } else { json!(1) });
if bool_answer.is_none() {
tracing::warn!("Skipping invalid true-false question without boolean correct answer: {:?}", q);
tracing::warn!("Omitiendo pregunta de verdadero-falso inválida sin respuesta correcta booleana: {:?}", q);
return None;
}
(Some(json!(["True", "False"])), bool_answer, false)
@@ -1688,7 +1680,7 @@ pub async fn generate_questions_with_rag(
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !is_valid {
tracing::warn!("Skipping invalid matching question without pairs: {:?}", q);
tracing::warn!("Omitiendo pregunta de emparejamiento inválida sin pares: {:?}", q);
return None;
}
(pairs.clone(), pairs, false)
@@ -1711,7 +1703,7 @@ pub async fn generate_questions_with_rag(
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !has_items || !has_order {
tracing::warn!("Skipping invalid ordering question without items/order: {:?}", q);
tracing::warn!("Omitiendo pregunta de ordenación inválida sin elementos/orden: {:?}", q);
return None;
}
(items, order, false)
@@ -1724,7 +1716,7 @@ pub async fn generate_questions_with_rag(
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !has_blanks {
tracing::warn!("Skipping invalid fill-in-the-blanks question without blanks array: {:?}", q);
tracing::warn!("Omitiendo pregunta de completar espacios en blanco inválida sin array de espacios: {:?}", q);
return None;
}
(blanks.clone(), blanks, false)
@@ -1760,7 +1752,7 @@ pub async fn generate_questions_with_rag(
.collect();
if generated_questions.is_empty() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI failed to generate questions".to_string()));
return Err((StatusCode::INTERNAL_SERVER_ERROR, "La IA no pudo generar las preguntas".to_string()));
}
// Save generated questions to question bank
@@ -1819,7 +1811,7 @@ pub async fn generate_questions_with_rag(
}
tracing::info!(
"Generated {} questions using RAG from MySQL bank, saved {} to question bank",
"Generadas {} preguntas usando RAG del banco MySQL, guardadas {} al banco de preguntas",
generated_questions.len(),
saved_count
);
@@ -1892,7 +1884,7 @@ async fn ensure_mysql_course_metadata(
let mysql_url = std::env::var("MYSQL_DATABASE_URL").map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"MYSQL_DATABASE_URL not configured".to_string(),
"MYSQL_DATABASE_URL no configurada".to_string(),
)
})?;
@@ -1907,7 +1899,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to external MySQL: {}", e),
format!("Error al conectar con MySQL externo: {}", e),
)
})?;
@@ -1962,7 +1954,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to upsert MySQL study plan metadata: {}", e),
format!("Error al actualizar/insertar los metadatos del plan de estudios MySQL: {}", e),
)
})?;
@@ -1976,7 +1968,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to resolve MySQL study plan metadata: {}", e),
format!("Error al resolver los metadatos del plan de estudios MySQL: {}", e),
)
})?;
@@ -2013,7 +2005,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to upsert MySQL course metadata: {}", e),
format!("Error al actualizar/insertar los metadatos del curso MySQL: {}", e),
)
})?;