feat: Mejorar la gestión de plantillas de prueba y agregar validaciones para la composición de preguntas
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user