feat: Mejorar la gestión de plantillas de prueba y agregar validaciones para la composición de preguntas

This commit is contained in:
2026-04-02 14:08:48 -03:00
parent 4470e3d20b
commit 2b01d5d3f4
4 changed files with 527 additions and 169 deletions
+233 -6
View File
@@ -4,6 +4,7 @@ use axum::{
http::{StatusCode, HeaderMap},
};
use common::models::Course;
use serde::Deserialize;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
@@ -26,16 +27,19 @@ async fn validate_api_key(headers: &HeaderMap, pool: &PgPool) -> Result<Uuid, St
pub async fn create_course_external(
State(pool): State<PgPool>,
headers: HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Course>, StatusCode> {
Json(payload): Json<ExternalCreateCoursePayload>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let org_id = validate_api_key(&headers, &pool).await?;
// We reuse the internal logic but with the org_id from the API key
// We need to provide a mock claims for handlers::create_course or refactor it.
// Simplifying for now: direct DB call or calling handlers with constructed context.
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
let description = payload.get("description").and_then(|d| d.as_str());
let title = payload.title.trim();
if title.is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let description = payload.description.as_deref();
let course = sqlx::query_as::<_, Course>(
"INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode)
@@ -44,7 +48,7 @@ pub async fn create_course_external(
.bind(org_id)
.bind(title)
.bind(description)
.bind(payload.get("pacing_mode").and_then(|p| p.as_str()).unwrap_or("self_paced"))
.bind(payload.pacing_mode.as_deref().unwrap_or("self_paced"))
.fetch_one(&pool)
.await
.map_err(|e| {
@@ -52,7 +56,230 @@ pub async fn create_course_external(
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(course))
let selected_template_id = resolve_template_id(
&pool,
org_id,
payload.template_id,
payload.template_level.as_deref(),
payload.template_course_type.as_deref(),
payload.template_test_type.as_deref(),
)
.await?;
let mut lesson_id: Option<Uuid> = None;
if let Some(template_id) = selected_template_id {
lesson_id = Some(
create_course_lesson_and_apply_template(
&pool,
org_id,
course.id,
template_id,
payload.module_title.as_deref().unwrap_or("Evaluaciones"),
payload.lesson_title.as_deref().unwrap_or("Evaluación inicial"),
)
.await?,
);
}
Ok(Json(json!({
"course": course,
"template_applied": selected_template_id.is_some(),
"template_id": selected_template_id,
"lesson_id": lesson_id,
})))
}
#[derive(Debug, Deserialize)]
pub struct ExternalCreateCoursePayload {
pub title: String,
pub description: Option<String>,
pub pacing_mode: Option<String>,
// Optional direct template selection
pub template_id: Option<Uuid>,
// Optional fallback selection by level/course type/test type
pub template_level: Option<String>,
pub template_course_type: Option<String>,
pub template_test_type: Option<String>,
pub module_title: Option<String>,
pub lesson_title: Option<String>,
}
async fn resolve_template_id(
pool: &PgPool,
org_id: Uuid,
direct_template_id: Option<Uuid>,
level: Option<&str>,
course_type: Option<&str>,
test_type: Option<&str>,
) -> Result<Option<Uuid>, StatusCode> {
if let Some(template_id) = direct_template_id {
let exists: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM test_templates WHERE id = $1 AND organization_id = $2 AND is_active = true"
)
.bind(template_id)
.bind(org_id)
.fetch_optional(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if exists.is_none() {
return Err(StatusCode::BAD_REQUEST);
}
return Ok(Some(template_id));
}
if level.is_none() && course_type.is_none() && test_type.is_none() {
return Ok(None);
}
let selected: Option<Uuid> = sqlx::query_scalar(
r#"
SELECT id
FROM test_templates
WHERE organization_id = $1
AND is_active = true
AND ($2::course_level IS NULL OR level = $2::course_level)
AND ($3::course_type IS NULL OR course_type = $3::course_type)
AND ($4::test_type IS NULL OR test_type = $4::test_type)
ORDER BY updated_at DESC
LIMIT 1
"#,
)
.bind(org_id)
.bind(level)
.bind(course_type)
.bind(test_type)
.fetch_optional(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(selected)
}
async fn create_course_lesson_and_apply_template(
pool: &PgPool,
org_id: Uuid,
course_id: Uuid,
template_id: Uuid,
module_title: &str,
lesson_title: &str,
) -> Result<Uuid, StatusCode> {
let module_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO modules (course_id, organization_id, title, position)
VALUES ($1, $2, $3, 1)
RETURNING id
"#,
)
.bind(course_id)
.bind(org_id)
.bind(module_title)
.fetch_one(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let lesson_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO lessons (module_id, organization_id, title, content_type, position, is_graded, max_attempts, allow_retry)
VALUES ($1, $2, $3, 'quiz', 1, true, 1, false)
RETURNING id
"#,
)
.bind(module_id)
.bind(org_id)
.bind(lesson_title)
.fetch_one(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let template: (Uuid, String, String, i32, i32, i32, Option<String>) = sqlx::query_as(
r#"
SELECT id, name, test_type::text, duration_minutes, passing_score, total_points, instructions
FROM test_templates
WHERE id = $1 AND organization_id = $2
"#,
)
.bind(template_id)
.bind(org_id)
.fetch_one(pool)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
let template_questions: Vec<(Uuid, String, String, Option<serde_json::Value>, Option<serde_json::Value>, Option<String>, i32)> = sqlx::query_as(
r#"
SELECT id, question_type, question_text, options, correct_answer, explanation, points
FROM test_template_questions
WHERE template_id = $1
ORDER BY section_id, question_order
"#,
)
.bind(template_id)
.fetch_all(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if template.2 == "CA" && template_questions.len() < 4 {
return Err(StatusCode::BAD_REQUEST);
}
if template.2 != "CA" && template_questions.len() != 1 {
return Err(StatusCode::BAD_REQUEST);
}
let questions_json: Vec<serde_json::Value> = template_questions
.iter()
.map(|q| {
json!({
"id": q.0.to_string(),
"type": q.1,
"question": q.2,
"options": q.3,
"correct": q.4,
"explanation": q.5,
"points": q.6,
})
})
.collect();
let quiz_data = json!({
"questions": questions_json,
"template_id": template.0.to_string(),
"template_name": template.1,
"test_type": template.2,
"duration_minutes": template.3,
"passing_score": template.4,
"total_points": template.5,
"instructions": template.6,
"max_attempts": 1,
"show_feedback": true,
"permanent_history": true,
});
sqlx::query(
r#"
UPDATE lessons
SET content_type = 'quiz',
metadata = $1,
is_graded = true,
max_attempts = 1,
allow_retry = false,
updated_at = NOW()
WHERE id = $2 AND organization_id = $3
"#,
)
.bind(&quiz_data)
.bind(lesson_id)
.bind(org_id)
.execute(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = sqlx::query("SELECT increment_template_usage($1)")
.bind(template_id)
.execute(pool)
.await;
Ok(lesson_id)
}
pub async fn get_course_external(
@@ -555,6 +555,21 @@ pub async fn apply_template_to_lesson(
return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string()));
}
// Business rules for template composition.
if matches!(template.test_type, TestType::CA) && template_questions.len() < 4 {
return Err((
StatusCode::BAD_REQUEST,
"Las plantillas CA deben tener minimo 4 preguntas".to_string(),
));
}
if !matches!(template.test_type, TestType::CA) && template_questions.len() != 1 {
return Err((
StatusCode::BAD_REQUEST,
"Las plantillas MWT, MOT, FOT y FWT deben tener exactamente 1 pregunta".to_string(),
));
}
// Build quiz_data JSON from template questions
let questions_json: Vec<serde_json::Value> = template_questions
.iter()
@@ -1452,17 +1467,29 @@ pub async fn generate_questions_with_rag(
(shuffled_options, new_correct_idx)
};
// Convert to TestTemplateQuestion format
// Convert to TestTemplateQuestion format and skip invalid LLM entries
let generated_questions: Vec<TestTemplateQuestion> = questions_data
.iter()
.enumerate()
.map(|(idx, q)| {
.filter_map(|(idx, q)| {
let question_type_value = q
.get("question_type")
.and_then(|v| v.as_str())
.unwrap_or(&requested_question_type)
.to_string();
let question_text = q
.get("question_text")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.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);
return None;
}
// Get original options and correct answer
let original_options: Vec<String> = q
.get("options")
@@ -1483,6 +1510,10 @@ 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);
return None;
}
if !original_options.is_empty() && original_correct_idx.is_some() {
let correct_idx = original_correct_idx.unwrap();
if correct_idx < original_options.len() {
@@ -1510,10 +1541,23 @@ pub async fn generate_questions_with_rag(
.or(q.get("correct"))
.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);
return None;
}
(Some(json!(["True", "False"])), bool_answer, false)
}
"matching" => {
let pairs = q.get("pairs").cloned().or_else(|| q.get("options").cloned());
let is_valid = pairs
.as_ref()
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !is_valid {
tracing::warn!("Skipping invalid matching question without pairs: {:?}", q);
return None;
}
(pairs.clone(), pairs, false)
}
"ordering" => {
@@ -1523,10 +1567,33 @@ pub async fn generate_questions_with_rag(
.cloned()
.or_else(|| q.get("correct_answer").cloned())
.or_else(|| q.get("correct").cloned());
let has_items = items
.as_ref()
.and_then(|v| v.as_array())
.map(|arr| arr.len() >= 2)
.unwrap_or(false);
let has_order = order
.as_ref()
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !has_items || !has_order {
tracing::warn!("Skipping invalid ordering question without items/order: {:?}", q);
return None;
}
(items, order, false)
}
"fill-in-the-blanks" => {
let blanks = q.get("blanks").cloned();
let has_blanks = blanks
.as_ref()
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !has_blanks {
tracing::warn!("Skipping invalid fill-in-the-blanks question without blanks array: {:?}", q);
return None;
}
(blanks.clone(), blanks, false)
}
_ => (
@@ -1536,13 +1603,13 @@ pub async fn generate_questions_with_rag(
),
};
TestTemplateQuestion {
Some(TestTemplateQuestion {
id: Uuid::new_v4(),
template_id: Uuid::nil(),
section_id: None,
question_order: idx as i32,
question_type: question_type_value,
question_text: q.get("question_text").and_then(|v| v.as_str()).unwrap_or("Question").to_string(),
question_text,
options,
correct_answer,
explanation: q.get("explanation").and_then(|v| v.as_str()).map(String::from),
@@ -1555,7 +1622,7 @@ pub async fn generate_questions_with_rag(
"options_shuffled": options_shuffled,
})),
created_at: chrono::Utc::now(),
}
})
})
.collect();