1881 lines
67 KiB
Rust
1881 lines
67 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
};
|
|
use common::models::{
|
|
CourseLevel, CourseType, CreateTestTemplatePayload, TestTemplate, TestTemplateQuestion,
|
|
TestTemplateSection, TestTemplateWithQuestions, TestType, UpdateTestTemplatePayload,
|
|
};
|
|
use common::{auth::Claims, middleware::Org};
|
|
use serde::Deserialize;
|
|
use sqlx::PgPool;
|
|
use std::time::Duration;
|
|
use uuid::Uuid;
|
|
|
|
// ==================== Query Parameters ====================
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct TestTemplateFilters {
|
|
pub mysql_course_id: Option<i32>, // Filter by MySQL course ID
|
|
pub level: Option<CourseLevel>,
|
|
pub course_type: Option<CourseType>,
|
|
pub test_type: Option<TestType>,
|
|
pub tags: Option<String>, // Comma-separated list
|
|
pub search: Option<String>,
|
|
}
|
|
|
|
// ==================== Create ====================
|
|
|
|
/// POST /api/test-templates - Create a new test template
|
|
pub async fn create_test_template(
|
|
Org(org_ctx): Org,
|
|
claims: Claims,
|
|
State(pool): State<PgPool>,
|
|
Json(payload): Json<CreateTestTemplatePayload>,
|
|
) -> Result<Json<TestTemplate>, (StatusCode, String)> {
|
|
if let Some(mysql_course_id) = payload.mysql_course_id {
|
|
ensure_mysql_course_metadata(&pool, org_ctx.id, mysql_course_id).await?;
|
|
}
|
|
|
|
let template: TestTemplate = sqlx::query_as(
|
|
r#"
|
|
INSERT INTO test_templates (
|
|
organization_id, created_by, name, description, mysql_course_id,
|
|
level, course_type, test_type, duration_minutes, passing_score, total_points,
|
|
instructions, template_data, tags
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING id, organization_id, mysql_course_id, name, description, level, course_type,
|
|
test_type, duration_minutes, passing_score, total_points, instructions,
|
|
template_data, tags, is_active, usage_count, created_by, created_at, updated_at
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.bind(claims.sub)
|
|
.bind(&payload.name)
|
|
.bind(&payload.description)
|
|
.bind(payload.mysql_course_id)
|
|
.bind(payload.level.as_ref())
|
|
.bind(payload.course_type.as_ref())
|
|
.bind(&payload.test_type)
|
|
.bind(payload.duration_minutes)
|
|
.bind(payload.passing_score)
|
|
.bind(payload.total_points)
|
|
.bind(&payload.instructions)
|
|
.bind(&payload.template_data)
|
|
.bind(payload.tags.as_deref())
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(template))
|
|
}
|
|
|
|
// ==================== Read ====================
|
|
|
|
/// GET /api/test-templates - List test templates with filters
|
|
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
|
|
let mut query = String::from("SELECT * FROM test_templates WHERE organization_id = $1");
|
|
let mut param_count = 1;
|
|
|
|
// Filter by 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
|
|
if filters.level.is_some() {
|
|
param_count += 1;
|
|
query.push_str(&format!(" AND level = ${}", param_count));
|
|
}
|
|
|
|
// Filter by course type
|
|
if filters.course_type.is_some() {
|
|
param_count += 1;
|
|
query.push_str(&format!(" AND course_type = ${}", param_count));
|
|
}
|
|
|
|
// Filter by test type
|
|
if filters.test_type.is_some() {
|
|
param_count += 1;
|
|
query.push_str(&format!(" AND test_type = ${}", param_count));
|
|
}
|
|
|
|
// Filter by tags (array overlap)
|
|
if filters.tags.is_some() {
|
|
param_count += 1;
|
|
query.push_str(&format!(" AND tags && ${}", param_count));
|
|
}
|
|
|
|
// Search in name and description
|
|
if filters.search.is_some() {
|
|
param_count += 1;
|
|
query.push_str(&format!(
|
|
" AND (name ILIKE ${0} OR description ILIKE ${0})",
|
|
param_count
|
|
));
|
|
}
|
|
|
|
query.push_str(" ORDER BY created_at DESC");
|
|
|
|
// Build query with dynamic binds
|
|
let mut sql_query = sqlx::query_as::<_, TestTemplate>(&query).bind(org_ctx.id);
|
|
|
|
if let Some(mysql_course_id) = &filters.mysql_course_id {
|
|
sql_query = sql_query.bind(mysql_course_id);
|
|
}
|
|
|
|
if let Some(level) = &filters.level {
|
|
sql_query = sql_query.bind(level);
|
|
}
|
|
|
|
if let Some(course_type) = &filters.course_type {
|
|
sql_query = sql_query.bind(course_type);
|
|
}
|
|
|
|
if let Some(test_type) = &filters.test_type {
|
|
sql_query = sql_query.bind(test_type);
|
|
}
|
|
|
|
if let Some(tags_str) = &filters.tags {
|
|
let tags: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
|
|
sql_query = sql_query.bind(tags);
|
|
}
|
|
|
|
let search_pattern = filters.search.as_ref().map(|s| format!("%{}%", s));
|
|
if let Some(ref pattern) = search_pattern {
|
|
sql_query = sql_query.bind(pattern);
|
|
}
|
|
|
|
let templates = sql_query
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(templates))
|
|
}
|
|
|
|
/// GET /api/test-templates/:id - Get a specific test template with questions
|
|
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
|
|
let template: TestTemplate = sqlx::query_as(
|
|
r#"
|
|
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
|
|
test_type, duration_minutes, passing_score, total_points, instructions,
|
|
template_data, tags, is_active, usage_count, created_at, updated_at
|
|
FROM test_templates
|
|
WHERE id = $1 AND organization_id = $2
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(org_ctx.id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
|
})?;
|
|
|
|
// Get sections
|
|
let sections: Vec<TestTemplateSection> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, template_id, title, description, section_order, points, instructions, section_data, created_at
|
|
FROM test_template_sections
|
|
WHERE template_id = $1
|
|
ORDER BY section_order
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
// Get questions
|
|
let questions: Vec<TestTemplateQuestion> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, template_id, section_id, question_order, question_type, question_text,
|
|
options, correct_answer, explanation, points, metadata, created_at
|
|
FROM test_template_questions
|
|
WHERE template_id = $1
|
|
ORDER BY section_id, question_order
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(TestTemplateWithQuestions {
|
|
template,
|
|
sections,
|
|
questions,
|
|
}))
|
|
}
|
|
|
|
// ==================== Update ====================
|
|
|
|
/// PUT /api/test-templates/:id - Update a test template
|
|
pub async fn update_test_template(
|
|
Org(org_ctx): Org,
|
|
Path(template_id): Path<Uuid>,
|
|
claims: Claims,
|
|
State(pool): State<PgPool>,
|
|
Json(payload): Json<UpdateTestTemplatePayload>,
|
|
) -> Result<Json<TestTemplate>, (StatusCode, String)> {
|
|
let template: TestTemplate = sqlx::query_as(
|
|
r#"
|
|
UPDATE test_templates
|
|
SET
|
|
name = COALESCE($3, name),
|
|
description = COALESCE($4, description),
|
|
mysql_course_id = COALESCE($5, mysql_course_id),
|
|
level = COALESCE($6, level),
|
|
course_type = COALESCE($7, course_type),
|
|
test_type = COALESCE($8, test_type),
|
|
duration_minutes = COALESCE($9, duration_minutes),
|
|
passing_score = COALESCE($10, passing_score),
|
|
total_points = COALESCE($11, total_points),
|
|
instructions = COALESCE($12, instructions),
|
|
template_data = COALESCE($13, template_data),
|
|
tags = COALESCE($14, tags),
|
|
is_active = COALESCE($15, is_active),
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND organization_id = $2
|
|
RETURNING id, organization_id, mysql_course_id, name, description, level, course_type,
|
|
test_type, duration_minutes, passing_score, total_points, instructions,
|
|
template_data, tags, is_active, usage_count, created_by, created_at, updated_at
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(org_ctx.id)
|
|
.bind(payload.name)
|
|
.bind(payload.description)
|
|
.bind(payload.mysql_course_id)
|
|
.bind(payload.level)
|
|
.bind(payload.course_type)
|
|
.bind(payload.test_type)
|
|
.bind(payload.duration_minutes)
|
|
.bind(payload.passing_score)
|
|
.bind(payload.total_points)
|
|
.bind(payload.instructions)
|
|
.bind(payload.template_data)
|
|
.bind(payload.tags)
|
|
.bind(payload.is_active)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
|
})?;
|
|
|
|
Ok(Json(template))
|
|
}
|
|
|
|
// ==================== Delete ====================
|
|
|
|
/// DELETE /api/test-templates/:id - Delete a test template
|
|
pub async fn delete_test_template(
|
|
Org(org_ctx): Org,
|
|
Path(template_id): Path<Uuid>,
|
|
State(pool): State<PgPool>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
let result = sqlx::query(
|
|
r#"
|
|
DELETE FROM test_templates
|
|
WHERE id = $1 AND organization_id = $2
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(org_ctx.id)
|
|
.execute(&pool)
|
|
.await
|
|
.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()));
|
|
}
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// ==================== Template Questions Management ====================
|
|
|
|
/// POST /api/test-templates/:id/questions - Add a question to a template
|
|
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
|
|
let exists: (bool,) = sqlx::query_as(
|
|
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(org_ctx.id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if !exists.0 {
|
|
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
|
}
|
|
|
|
let question: TestTemplateQuestion = sqlx::query_as(
|
|
r#"
|
|
INSERT INTO test_template_questions (
|
|
template_id, section_id, question_order, question_type, question_text,
|
|
options, correct_answer, explanation, points, metadata
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING id, template_id, section_id, question_order, question_type, question_text,
|
|
options, correct_answer, explanation, points, metadata, created_at
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(payload.section_id)
|
|
.bind(payload.question_order)
|
|
.bind(&payload.question_type)
|
|
.bind(&payload.question_text)
|
|
.bind(payload.options.as_ref())
|
|
.bind(payload.correct_answer.as_ref())
|
|
.bind(&payload.explanation)
|
|
.bind(payload.points)
|
|
.bind(payload.metadata.as_ref())
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(question))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateQuestionPayload {
|
|
pub section_id: Option<Uuid>,
|
|
pub question_order: i32,
|
|
pub question_type: String,
|
|
pub question_text: String,
|
|
pub options: Option<serde_json::Value>,
|
|
pub correct_answer: Option<serde_json::Value>,
|
|
pub explanation: Option<String>,
|
|
pub points: i32,
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// DELETE /api/test-templates/:template_id/questions/:question_id - Delete a question
|
|
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
|
|
let exists: (bool,) = sqlx::query_as(
|
|
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(org_ctx.id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if !exists.0 {
|
|
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
|
}
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
DELETE FROM test_template_questions
|
|
WHERE id = $1 AND template_id = $2
|
|
"#
|
|
)
|
|
.bind(question_id)
|
|
.bind(template_id)
|
|
.execute(&pool)
|
|
.await
|
|
.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()));
|
|
}
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// ==================== Template Sections Management ====================
|
|
|
|
/// POST /api/test-templates/:id/sections - Add a section to a template
|
|
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
|
|
let exists: (bool,) = sqlx::query_as(
|
|
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(org_ctx.id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if !exists.0 {
|
|
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
|
}
|
|
|
|
let section: TestTemplateSection = sqlx::query_as(
|
|
r#"
|
|
INSERT INTO test_template_sections (
|
|
template_id, title, description, section_order, points, instructions, section_data
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, template_id, title, description, section_order, points, instructions, section_data, created_at
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(&payload.title)
|
|
.bind(&payload.description)
|
|
.bind(payload.section_order)
|
|
.bind(payload.points)
|
|
.bind(&payload.instructions)
|
|
.bind(payload.section_data.as_ref())
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(section))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateSectionPayload {
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub section_order: i32,
|
|
pub points: i32,
|
|
pub instructions: Option<String>,
|
|
pub section_data: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// DELETE /api/test-templates/:template_id/sections/:section_id - Delete a section
|
|
pub async fn delete_template_section(
|
|
Org(org_ctx): Org,
|
|
Path((template_id, section_id)): Path<(Uuid, Uuid)>,
|
|
State(pool): State<PgPool>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
let result = sqlx::query(
|
|
r#"
|
|
DELETE FROM test_template_sections
|
|
WHERE id = $1 AND template_id = $2
|
|
"#
|
|
)
|
|
.bind(section_id)
|
|
.bind(template_id)
|
|
.execute(&pool)
|
|
.await
|
|
.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()));
|
|
}
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// ==================== Apply Template to Lesson ====================
|
|
|
|
/// POST /api/test-templates/:id/apply - Apply a template to a lesson
|
|
pub async fn apply_template_to_lesson(
|
|
Org(org_ctx): Org,
|
|
Path(template_id): Path<Uuid>,
|
|
claims: Claims,
|
|
State(pool): State<PgPool>,
|
|
Json(payload): Json<ApplyTemplatePayload>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
// Verify template exists and belongs to organization
|
|
let template: TestTemplate = sqlx::query_as(
|
|
r#"
|
|
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
|
|
test_type, duration_minutes, passing_score, total_points, instructions,
|
|
template_data, tags, is_active, usage_count, created_at, updated_at
|
|
FROM test_templates
|
|
WHERE id = $1 AND organization_id = $2
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.bind(org_ctx.id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
|
})?;
|
|
|
|
// Verify lesson exists and belongs to organization
|
|
let lesson_exists: (bool,) = sqlx::query_as(
|
|
r#"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)"#
|
|
)
|
|
.bind(payload.lesson_id)
|
|
.bind(org_ctx.id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if !lesson_exists.0 {
|
|
return Err((StatusCode::NOT_FOUND, "Lesson not found".to_string()));
|
|
}
|
|
|
|
// Get template questions with their sections
|
|
let template_questions: Vec<TestTemplateQuestion> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, template_id, section_id, question_order, question_type, question_text,
|
|
options, correct_answer, explanation, points, metadata, created_at
|
|
FROM test_template_questions
|
|
WHERE template_id = $1
|
|
ORDER BY section_id, question_order
|
|
"#
|
|
)
|
|
.bind(template_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.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()));
|
|
}
|
|
|
|
// Build quiz_data JSON from template questions
|
|
let questions_json: Vec<serde_json::Value> = template_questions
|
|
.iter()
|
|
.map(|q| {
|
|
serde_json::json!({
|
|
"id": q.id.to_string(),
|
|
"type": q.question_type,
|
|
"question": q.question_text,
|
|
"options": q.options.clone().unwrap_or(serde_json::Value::Null),
|
|
"correct": q.correct_answer.clone().unwrap_or(serde_json::Value::Null),
|
|
"explanation": q.explanation.clone().unwrap_or_default(),
|
|
"points": q.points,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let quiz_data = serde_json::json!({
|
|
"questions": questions_json,
|
|
"template_id": template_id.to_string(),
|
|
"template_name": template.name,
|
|
"test_type": template.test_type.to_string(),
|
|
"duration_minutes": template.duration_minutes,
|
|
"passing_score": template.passing_score,
|
|
"total_points": template.total_points,
|
|
"instructions": template.instructions,
|
|
"max_attempts": 1, // Single attempt as requested
|
|
"show_feedback": true, // Show explanations after answering
|
|
"permanent_history": true, // Student can always view their responses
|
|
});
|
|
|
|
// Update lesson with quiz data and configuration
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE lessons
|
|
SET content_type = 'quiz',
|
|
content_url = NULL,
|
|
metadata = $1,
|
|
is_graded = true,
|
|
max_attempts = 1,
|
|
allow_retry = false,
|
|
grading_category_id = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $3 AND organization_id = $4
|
|
"#
|
|
)
|
|
.bind(&quiz_data)
|
|
.bind(payload.grading_category_id)
|
|
.bind(payload.lesson_id)
|
|
.bind(org_ctx.id)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
// Increment template usage count
|
|
sqlx::query("SELECT increment_template_usage($1)")
|
|
.bind(template_id)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
tracing::info!(
|
|
"Applied template '{}' to lesson '{}' with {} questions",
|
|
template.name,
|
|
payload.lesson_id,
|
|
template_questions.len()
|
|
);
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ApplyTemplatePayload {
|
|
pub lesson_id: Uuid,
|
|
pub grading_category_id: Option<Uuid>,
|
|
}
|
|
|
|
// ==================== RAG Question Generation ====================
|
|
|
|
// Helper function to generate system prompt based on question type
|
|
fn get_system_prompt_for_question_type(
|
|
question_type: &str,
|
|
num_questions: i32,
|
|
topic: &str,
|
|
rag_context: &str,
|
|
) -> String {
|
|
match question_type {
|
|
"true-false" => {
|
|
format!(
|
|
r#"You are an English Teacher creating quiz questions.
|
|
|
|
Use these examples as inspiration (do NOT copy):
|
|
{}
|
|
|
|
Create {} ORIGINAL true-false questions about: {}
|
|
|
|
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
|
[
|
|
{{
|
|
"question_text": "The capital of France is Paris.",
|
|
"question_type": "true-false",
|
|
"correct_answer": true,
|
|
"explanation": "Paris is indeed the capital of France.",
|
|
"points": 1
|
|
}}
|
|
]
|
|
|
|
Rules:
|
|
- Each question must be a clear statement
|
|
- correct_answer must be true or false
|
|
- Explanations must be concise"#,
|
|
rag_context, num_questions, topic
|
|
)
|
|
}
|
|
"short-answer" => {
|
|
format!(
|
|
r#"You are an English Teacher creating quiz questions.
|
|
|
|
Use these examples as inspiration (do NOT copy):
|
|
{}
|
|
|
|
Create {} ORIGINAL short-answer questions about: {}
|
|
|
|
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
|
[
|
|
{{
|
|
"question_text": "What is the past tense of 'go'?",
|
|
"question_type": "short-answer",
|
|
"correct_answer": "went",
|
|
"keywords": ["went", "go's past tense"],
|
|
"explanation": "The irregular verb 'go' becomes 'went' in the past tense.",
|
|
"points": 1
|
|
}}
|
|
]
|
|
|
|
Rules:
|
|
- correct_answer should be the expected response
|
|
- keywords array contains acceptable variations or key concepts to check
|
|
- Questions should accept brief responses"#,
|
|
rag_context, num_questions, topic
|
|
)
|
|
}
|
|
"matching" => {
|
|
format!(
|
|
r#"You are an English Teacher creating quiz questions.
|
|
|
|
Use these examples as inspiration (do NOT copy):
|
|
{}
|
|
|
|
Create {} ORIGINAL matching question sets about: {}
|
|
|
|
IMPORTANT - Return ONLY a JSON array with matching questions. Each matching question should have this EXACT structure:
|
|
[
|
|
{{
|
|
"question_text": "Match each vocabulary term with its definition:",
|
|
"question_type": "matching",
|
|
"pairs": [
|
|
{{"left": "Verb", "right": "A word that describes an action"}},
|
|
{{"left": "Noun", "right": "A word that represents a person, place, or thing"}},
|
|
{{"left": "Adjective", "right": "A word that describes or modifies a noun"}}
|
|
],
|
|
"explanation": "These are the fundamental parts of speech in English.",
|
|
"points": 3
|
|
}}
|
|
]
|
|
|
|
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"#,
|
|
rag_context, num_questions, topic
|
|
)
|
|
}
|
|
"ordering" => {
|
|
format!(
|
|
r#"You are an English Teacher creating quiz questions.
|
|
|
|
Use these examples as inspiration (do NOT copy):
|
|
{}
|
|
|
|
Create {} ORIGINAL ordering questions about: {}
|
|
|
|
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
|
[
|
|
{{
|
|
"question_text": "Arrange these steps of the writing process in correct order:",
|
|
"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.",
|
|
"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"#,
|
|
rag_context, num_questions, topic
|
|
)
|
|
}
|
|
"fill-in-the-blanks" => {
|
|
format!(
|
|
r#"You are an English Teacher creating quiz questions.
|
|
|
|
Use these examples as inspiration (do NOT copy):
|
|
{}
|
|
|
|
Create {} ORIGINAL fill-in-the-blanks questions about: {}
|
|
|
|
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
|
[
|
|
{{
|
|
"question_text": "The ________ is the main character in a story, while the ________ opposes them.",
|
|
"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.",
|
|
"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"#,
|
|
rag_context, num_questions, topic
|
|
)
|
|
}
|
|
"audio-response" => {
|
|
format!(
|
|
r#"You are an English Teacher creating speaking exercises.
|
|
|
|
Use these examples as inspiration (do NOT copy):
|
|
{}
|
|
|
|
Create {} ORIGINAL audio-response speaking prompts about: {}
|
|
|
|
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
|
[
|
|
{{
|
|
"question_text": "Describe a memorable trip using at least three past tense verbs.",
|
|
"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.",
|
|
"points": 2
|
|
}}
|
|
]
|
|
|
|
Rules:
|
|
- Questions must require spoken production
|
|
- correct_answer should contain rubric guidance or expected response criteria
|
|
- No options array is needed"#,
|
|
rag_context, num_questions, topic
|
|
)
|
|
}
|
|
_ => {
|
|
// Default to multiple-choice
|
|
format!(
|
|
r#"You are an English Teacher creating quiz questions.
|
|
|
|
Use these examples as inspiration (do NOT copy):
|
|
{}
|
|
|
|
Create {} ORIGINAL multiple-choice questions about: {}
|
|
|
|
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
|
[
|
|
{{
|
|
"question_text": "The tourist got lost in the ______ of the city.",
|
|
"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.",
|
|
"points": 1,
|
|
"skill_assessed": "reading"
|
|
}}
|
|
]
|
|
|
|
Rules:
|
|
- Option text only, no prefixes like A. or 1)
|
|
- Skills must be one of: reading, listening, speaking, writing"#,
|
|
rag_context, num_questions, topic
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to parse AI response based on question type
|
|
fn parse_ai_response_for_question_type(
|
|
questions_data: &serde_json::Value,
|
|
question_type: &str,
|
|
) -> Vec<serde_json::Value> {
|
|
match question_type {
|
|
"true-false" | "short-answer" | "matching" | "ordering" | "fill-in-the-blanks" => {
|
|
// These types should return an array of question objects
|
|
if let Some(arr) = questions_data.as_array() {
|
|
arr.clone()
|
|
} else if let Some(wrapped) = questions_data
|
|
.get("questions")
|
|
.or(questions_data.get("items"))
|
|
{
|
|
wrapped.as_array().cloned().unwrap_or_default()
|
|
} else if let Some(obj) = questions_data.as_object() {
|
|
obj.values().cloned().collect()
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
_ => {
|
|
// Default parsing for multiple-choice and others
|
|
if let Some(arr) = questions_data.as_array() {
|
|
arr.clone()
|
|
} else if let Some(questions) = questions_data
|
|
.get("questions")
|
|
.or(questions_data.get("items"))
|
|
{
|
|
questions.as_array().cloned().unwrap_or_default()
|
|
} else if let Some(obj) = questions_data.as_object() {
|
|
let questions: Vec<serde_json::Value> = obj.values().cloned().collect();
|
|
if !questions.is_empty() {
|
|
return questions;
|
|
}
|
|
vec![]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
pub async fn generate_questions_with_rag(
|
|
Org(org_ctx): Org,
|
|
claims: Claims,
|
|
State(pool): State<PgPool>,
|
|
Json(payload): Json<RagGenerationPayload>,
|
|
) -> Result<Json<Vec<TestTemplateQuestion>>, (StatusCode, String)> {
|
|
use common::ai::{self, generate_embedding};
|
|
use serde_json::json;
|
|
|
|
let mut mysql_questions: Vec<QuestionBankForRAG>;
|
|
|
|
// If topic is provided, use semantic search; otherwise use course_id filtering
|
|
if let Some(topic) = &payload.topic {
|
|
// Try semantic search with embeddings
|
|
// Create client that accepts invalid certificates (for dev with self-signed certs)
|
|
let client = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.danger_accept_invalid_hostnames(true)
|
|
.build()
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
|
|
|
let ollama_url = ai::get_ollama_url();
|
|
let model = ai::get_embedding_model();
|
|
|
|
match generate_embedding(&client, &ollama_url, &model, topic).await {
|
|
Ok(response) => {
|
|
let pgvector = ai::embedding_to_pgvector(&response.embedding);
|
|
|
|
// Semantic search in question_bank
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso,
|
|
1 - (qb.embedding <=> $1::vector) AS similarity
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $2
|
|
AND qb.source = 'imported-mysql'
|
|
AND ($3::integer IS NULL OR (qb.source_metadata->>'idCursos')::integer = $3)
|
|
AND qb.embedding IS NOT NULL
|
|
ORDER BY qb.embedding <=> $1::vector
|
|
LIMIT $4
|
|
"#
|
|
)
|
|
.bind(&pgvector)
|
|
.bind(org_ctx.id)
|
|
.bind(payload.course_id)
|
|
.bind(payload.num_questions.unwrap_or(5) * 3) // Get more for diversity
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Semantic search failed: {}", e)))?;
|
|
|
|
tracing::info!("Semantic search found {} similar questions", 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
|
|
);
|
|
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $1
|
|
AND qb.source = 'imported-mysql'
|
|
AND ($2::integer IS NULL OR (qb.source_metadata->>'idCursos')::integer = $2)
|
|
AND (
|
|
qb.question_text ILIKE $3
|
|
OR COALESCE(qb.options::text, '') ILIKE $3
|
|
)
|
|
LIMIT $4
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.bind(payload.course_id)
|
|
.bind(&format!("%{}%", topic))
|
|
.bind(payload.num_questions.unwrap_or(5) * 3)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword fallback failed: {}", e)))?;
|
|
|
|
if mysql_questions.is_empty() {
|
|
tracing::info!(
|
|
"Keyword fallback returned no rows; falling back to imported MySQL questions for course {:?}",
|
|
payload.course_id
|
|
);
|
|
|
|
if let Some(course_id) = payload.course_id {
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $1
|
|
AND qb.source = 'imported-mysql'
|
|
AND (qb.source_metadata->>'idCursos')::integer = $2
|
|
ORDER BY qb.created_at DESC
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.bind(course_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Course fallback failed: {}", e),
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Semantic search failed, falling back to keyword search: {}", e);
|
|
// Fall back to text search
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $1
|
|
AND qb.source = 'imported-mysql'
|
|
AND ($2::integer IS NULL OR (qb.source_metadata->>'idCursos')::integer = $2)
|
|
AND (
|
|
qb.question_text ILIKE $3
|
|
OR COALESCE(qb.options::text, '') ILIKE $3
|
|
)
|
|
LIMIT $4
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.bind(payload.course_id)
|
|
.bind(&format!("%{}%", topic))
|
|
.bind(payload.num_questions.unwrap_or(5) * 3)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword search failed: {}", 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
|
|
);
|
|
|
|
if let Some(course_id) = payload.course_id {
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $1
|
|
AND qb.source = 'imported-mysql'
|
|
AND (qb.source_metadata->>'idCursos')::integer = $2
|
|
ORDER BY qb.created_at DESC
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.bind(course_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Course fallback failed: {}", e),
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if let Some(course_id) = payload.course_id {
|
|
// Fetch questions from imported MySQL questions in PostgreSQL question_bank
|
|
// Filter by course_id if provided (mysql_course_id from imported metadata)
|
|
// NO LIMIT - fetch all questions for better RAG context
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $1
|
|
AND qb.source = 'imported-mysql'
|
|
AND (qb.source_metadata->>'idCursos')::integer = $2
|
|
ORDER BY qb.created_at DESC
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.bind(course_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
|
|
} else {
|
|
// Fetch all imported MySQL questions for this organization
|
|
// NO LIMIT - fetch all questions for better RAG context
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $1
|
|
AND qb.source = 'imported-mysql'
|
|
ORDER BY qb.created_at DESC
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", 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
|
|
);
|
|
|
|
mysql_questions = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
qb.question_text as descripcion,
|
|
qb.options,
|
|
COALESCE(
|
|
(qb.source_metadata->>'idPlanDeEstudios')::integer,
|
|
0
|
|
) as id_plan_de_estudios,
|
|
COALESCE(
|
|
qb.source_metadata->>'plan_nombre',
|
|
''
|
|
) as plan_nombre,
|
|
COALESCE(
|
|
(qb.source_metadata->>'nivel_curso')::integer,
|
|
NULL
|
|
) as nivel_curso
|
|
FROM question_bank qb
|
|
WHERE qb.organization_id = $1
|
|
AND qb.source = 'imported-mysql'
|
|
ORDER BY qb.created_at DESC
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch organization-wide fallback questions: {}", e),
|
|
)
|
|
})?;
|
|
|
|
if mysql_questions.is_empty() {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
"No se encontraron preguntas importadas de MySQL para la organización. Importa preguntas del banco MySQL desde Question Bank antes de generar con IA.".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Determine course_type and level from imported data
|
|
let course_type = mysql_questions
|
|
.first()
|
|
.map(|q| get_course_type_from_plan(&q.plan_nombre))
|
|
.unwrap_or(CourseType::Regular);
|
|
|
|
let level = mysql_questions
|
|
.first()
|
|
.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);
|
|
|
|
// 2. Build RAG context from MySQL questions (lightweight format)
|
|
let rag_context: String = mysql_questions
|
|
.iter()
|
|
.take(8) // Keep context short so Ollama starts responding sooner behind the HTTPS proxy
|
|
.map(|q| format!("* {}", q.descripcion))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
tracing::info!("RAG context built with {} questions", 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());
|
|
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);
|
|
|
|
// Save topic for later use
|
|
let topic = payload.topic.clone().unwrap_or_else(|| "English grammar".to_string());
|
|
let num_questions = payload.num_questions.unwrap_or(5);
|
|
let requested_question_type = match payload.question_type.as_deref() {
|
|
Some("multiple-choice") => "multiple-choice".to_string(),
|
|
Some("true-false") => "true-false".to_string(),
|
|
Some("short-answer") => "short-answer".to_string(),
|
|
Some("essay") => "essay".to_string(),
|
|
Some("matching") => "matching".to_string(),
|
|
Some("ordering") => "ordering".to_string(),
|
|
Some("fill-in-the-blanks") => "fill-in-the-blanks".to_string(),
|
|
Some("audio-response") => "audio-response".to_string(),
|
|
Some("hotspot") | Some("code-lab") => {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"Los tipos hotspot y code-lab se crean manualmente por el instructor".to_string(),
|
|
));
|
|
}
|
|
Some(_) | None => "multiple-choice".to_string(),
|
|
};
|
|
|
|
// Keep the prompt compact so the upstream Ollama proxy can receive the first bytes quickly.
|
|
let system_prompt = get_system_prompt_for_question_type(
|
|
&requested_question_type,
|
|
num_questions,
|
|
&topic,
|
|
&rag_context,
|
|
);
|
|
|
|
tracing::debug!("System prompt length: {} chars", system_prompt.len());
|
|
|
|
let request = client
|
|
.post(&url)
|
|
.json(&json!({
|
|
"model": model,
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": system_prompt
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": "Generate the questions in valid JSON format."
|
|
}
|
|
],
|
|
"stream": true,
|
|
"format": "json",
|
|
"options": {
|
|
"temperature": 0.3,
|
|
"num_predict": (num_questions * 160).clamp(160, 900)
|
|
}
|
|
}));
|
|
|
|
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 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());
|
|
|
|
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);
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Ollama returned {}. El proxy de IA agotó el tiempo de espera.", status),
|
|
));
|
|
}
|
|
|
|
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())
|
|
})?;
|
|
|
|
let aggregated_content = response_text
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
serde_json::from_str::<serde_json::Value>(trimmed)
|
|
.ok()
|
|
.and_then(|chunk| {
|
|
chunk.get("message")
|
|
.and_then(|m| m.get("content"))
|
|
.and_then(|content| content.as_str())
|
|
.map(str::to_string)
|
|
})
|
|
})
|
|
.collect::<String>();
|
|
|
|
let response_json = json!({
|
|
"message": {
|
|
"content": aggregated_content
|
|
}
|
|
});
|
|
|
|
tracing::debug!("Ollama response: {:?}", response_json);
|
|
|
|
// Parse questions from Ollama response
|
|
let ai_payload = response_json
|
|
.get("message")
|
|
.and_then(|m| m.get("content"))
|
|
.and_then(|content| content.as_str())
|
|
.and_then(|content| serde_json::from_str::<serde_json::Value>(content).ok())
|
|
.unwrap_or_else(|| json!([]));
|
|
|
|
let questions_data = parse_ai_response_for_question_type(&ai_payload, &requested_question_type);
|
|
|
|
// Helper function to clean options (remove "A.", "B.", "a)", etc.)
|
|
let clean_option = |opt: &str| -> String {
|
|
let opt = opt.trim();
|
|
// Remove patterns like "A.", "B.", "a)", "b)", "1.", "1)", "A)", "B)"
|
|
let patterns = [
|
|
(r"^[A-Za-z]\.\s*", ""), // "A. ", "B. "
|
|
(r"^[A-Za-z]\)\s*", ""), // "A) ", "B) "
|
|
(r"^\d+\.\s*", ""), // "1. ", "2. "
|
|
(r"^\d+\)\s*", ""), // "1) ", "2) "
|
|
(r"^Option\s+[A-Za-z]\.?\s*", ""), // "Option A. ", "Option B "
|
|
(r"^Answer\s*[:\.]?\s*", ""), // "Answer: ", "Answer. "
|
|
];
|
|
|
|
let mut cleaned = opt.to_string();
|
|
for (pattern, replacement) in patterns.iter() {
|
|
if let Ok(re) = regex::Regex::new(pattern) {
|
|
cleaned = re.replace(&cleaned, *replacement).to_string();
|
|
}
|
|
}
|
|
cleaned.trim().to_string()
|
|
};
|
|
|
|
// Helper function to shuffle options and adjust correct_answer index
|
|
let shuffle_options = |options: Vec<String>, correct_answer: Option<i64>| -> (Vec<String>, Option<i64>) {
|
|
use rand::seq::SliceRandom;
|
|
use rand::thread_rng;
|
|
|
|
if options.is_empty() || correct_answer.is_none() {
|
|
return (options, correct_answer);
|
|
}
|
|
|
|
let correct_idx = correct_answer.unwrap() as usize;
|
|
if correct_idx >= options.len() {
|
|
return (options, correct_answer);
|
|
}
|
|
|
|
// Store the correct answer text
|
|
let correct_answer_text = options[correct_idx].clone();
|
|
|
|
// Create a vector of indices and shuffle it
|
|
let mut indices: Vec<usize> = (0..options.len()).collect();
|
|
let mut rng = thread_rng();
|
|
indices.shuffle(&mut rng);
|
|
|
|
// Reorder options according to shuffled indices
|
|
let shuffled_options: Vec<String> = indices.iter().map(|&i| options[i].clone()).collect();
|
|
|
|
// Find the new position of the correct answer
|
|
let new_correct_idx = shuffled_options
|
|
.iter()
|
|
.position(|opt| opt == &correct_answer_text)
|
|
.map(|idx| idx as i64);
|
|
|
|
(shuffled_options, new_correct_idx)
|
|
};
|
|
|
|
// Convert to TestTemplateQuestion format
|
|
let generated_questions: Vec<TestTemplateQuestion> = questions_data
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, q)| {
|
|
let question_type_value = q
|
|
.get("question_type")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&requested_question_type)
|
|
.to_string();
|
|
|
|
// Get original options and correct answer
|
|
let original_options: Vec<String> = q
|
|
.get("options")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.map(|s| clean_option(s))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let original_correct_idx: Option<usize> = q
|
|
.get("correct_answer")
|
|
.or(q.get("correct"))
|
|
.and_then(|v| v.as_i64())
|
|
.map(|idx| idx as usize);
|
|
|
|
let (options, correct_answer, options_shuffled) = match question_type_value.as_str() {
|
|
"multiple-choice" => {
|
|
if !original_options.is_empty() && original_correct_idx.is_some() {
|
|
let correct_idx = original_correct_idx.unwrap();
|
|
if correct_idx < original_options.len() {
|
|
let (shuffled, new_correct_idx) =
|
|
shuffle_options(original_options.clone(), Some(correct_idx as i64));
|
|
(Some(json!(shuffled)), new_correct_idx.map(|idx| json!(idx)), true)
|
|
} else {
|
|
(
|
|
Some(json!(original_options)),
|
|
q.get("correct_answer").or(q.get("correct")).cloned(),
|
|
false,
|
|
)
|
|
}
|
|
} else {
|
|
(
|
|
Some(json!(original_options)),
|
|
q.get("correct_answer").or(q.get("correct")).cloned(),
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
"true-false" => {
|
|
let bool_answer = q
|
|
.get("correct_answer")
|
|
.or(q.get("correct"))
|
|
.and_then(|v| v.as_bool())
|
|
.map(|v| if v { json!(0) } else { json!(1) });
|
|
(Some(json!(["True", "False"])), bool_answer, false)
|
|
}
|
|
"matching" => {
|
|
let pairs = q.get("pairs").cloned().or_else(|| q.get("options").cloned());
|
|
(pairs.clone(), pairs, false)
|
|
}
|
|
"ordering" => {
|
|
let items = q.get("items").cloned().or_else(|| q.get("options").cloned());
|
|
let order = q
|
|
.get("correct_order")
|
|
.cloned()
|
|
.or_else(|| q.get("correct_answer").cloned())
|
|
.or_else(|| q.get("correct").cloned());
|
|
(items, order, false)
|
|
}
|
|
"fill-in-the-blanks" => {
|
|
let blanks = q.get("blanks").cloned();
|
|
(blanks.clone(), blanks, false)
|
|
}
|
|
_ => (
|
|
None,
|
|
q.get("correct_answer").or(q.get("correct")).cloned(),
|
|
false,
|
|
),
|
|
};
|
|
|
|
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(),
|
|
options,
|
|
correct_answer,
|
|
explanation: q.get("explanation").and_then(|v| v.as_str()).map(String::from),
|
|
points: q.get("points").and_then(|v| v.as_i64()).unwrap_or(1) as i32,
|
|
metadata: Some(json!({
|
|
"generated_by": "rag-ai",
|
|
"source": "mysql-bank",
|
|
"generated_at": chrono::Utc::now().to_rfc3339(),
|
|
"question_type_requested": requested_question_type.clone(),
|
|
"options_shuffled": options_shuffled,
|
|
})),
|
|
created_at: chrono::Utc::now(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if generated_questions.is_empty() {
|
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI failed to generate questions".to_string()));
|
|
}
|
|
|
|
// Save generated questions to question bank
|
|
let mut saved_count = 0;
|
|
for question in &generated_questions {
|
|
let question_type = match question.question_type.as_str() {
|
|
"true-false" => common::models::QuestionBankType::TrueFalse,
|
|
"short-answer" => common::models::QuestionBankType::ShortAnswer,
|
|
"essay" => common::models::QuestionBankType::Essay,
|
|
"matching" => common::models::QuestionBankType::Matching,
|
|
"ordering" => common::models::QuestionBankType::Ordering,
|
|
"fill-in-the-blanks" => common::models::QuestionBankType::FillInTheBlanks,
|
|
"audio-response" => common::models::QuestionBankType::AudioResponse,
|
|
_ => common::models::QuestionBankType::MultipleChoice,
|
|
};
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
INSERT INTO question_bank (
|
|
organization_id, created_by, question_text, question_type,
|
|
options, correct_answer, explanation, points, difficulty,
|
|
source, source_metadata, is_active
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, true)
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.bind(claims.sub)
|
|
.bind(&question.question_text)
|
|
.bind(&question_type)
|
|
.bind(&question.options)
|
|
.bind(&question.correct_answer)
|
|
.bind(&question.explanation)
|
|
.bind(question.points)
|
|
.bind("medium")
|
|
.bind("rag-ai")
|
|
.bind(&json!({
|
|
"generated_by": "rag-ai",
|
|
"source": "mysql-bank",
|
|
"topic": topic,
|
|
"question_type": requested_question_type.clone(),
|
|
"generated_at": chrono::Utc::now().to_rfc3339(),
|
|
}))
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
if result.is_ok() {
|
|
saved_count += 1;
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
"Generated {} questions using RAG from MySQL bank, saved {} to question bank",
|
|
generated_questions.len(),
|
|
saved_count
|
|
);
|
|
|
|
Ok(Json(generated_questions))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct RagGenerationPayload {
|
|
pub course_id: Option<i32>, // MySQL course ID from imported metadata
|
|
pub topic: Option<String>,
|
|
pub num_questions: Option<i32>,
|
|
pub question_type: Option<String>, // Type of question to generate: multiple-choice, true-false, short-answer, matching, ordering, fill-in-the-blanks
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct QuestionBankForRAG {
|
|
descripcion: String,
|
|
options: Option<serde_json::Value>,
|
|
id_plan_de_estudios: i32,
|
|
plan_nombre: String,
|
|
nivel_curso: Option<i32>,
|
|
#[sqlx(default)]
|
|
similarity: Option<f32>,
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct MySqlQuestion {
|
|
descripcion: String,
|
|
id_tipo_pregunta: i32,
|
|
nombre_curso: String,
|
|
plan_nombre: String,
|
|
nivel_curso: Option<i32>,
|
|
id_plan_de_estudios: i32,
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct MySqlTemplateCourseMetadata {
|
|
id_cursos: i32,
|
|
nombre_curso: String,
|
|
nivel_curso: Option<i32>,
|
|
id_plan_de_estudios: i32,
|
|
nombre_plan: String,
|
|
duracion: Option<f64>,
|
|
}
|
|
|
|
async fn ensure_mysql_course_metadata(
|
|
pool: &PgPool,
|
|
org_id: Uuid,
|
|
mysql_course_id: i32,
|
|
) -> Result<(), (StatusCode, String)> {
|
|
let exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS(SELECT 1 FROM mysql_courses WHERE organization_id = $1 AND mysql_id = $2)"
|
|
)
|
|
.bind(org_id)
|
|
.bind(mysql_course_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to check MySQL course metadata: {}", e),
|
|
)
|
|
})?;
|
|
|
|
if exists {
|
|
return Ok(());
|
|
}
|
|
|
|
let mysql_url = std::env::var("MYSQL_DATABASE_URL").map_err(|_| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"MYSQL_DATABASE_URL not configured".to_string(),
|
|
)
|
|
})?;
|
|
|
|
let mysql_pool = sqlx::mysql::MySqlPoolOptions::new()
|
|
.max_connections(2)
|
|
.min_connections(0)
|
|
.acquire_timeout(Duration::from_secs(15))
|
|
.idle_timeout(Duration::from_secs(30))
|
|
.max_lifetime(Duration::from_secs(300))
|
|
.connect(&mysql_url)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to external MySQL: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let course: MySqlTemplateCourseMetadata = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
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 nombre_plan,
|
|
c.Duracion AS duracion
|
|
FROM curso c
|
|
JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios
|
|
WHERE c.idCursos = ?
|
|
AND c.Activo = 1
|
|
AND pe.Activo = 1
|
|
"#
|
|
)
|
|
.bind(mysql_course_id)
|
|
.fetch_one(&mysql_pool)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::RowNotFound => (
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Curso MySQL {} no existe o no está activo", mysql_course_id),
|
|
),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch course metadata from MySQL: {}", e),
|
|
),
|
|
})?;
|
|
|
|
let plan_course_type = calculate_course_type_from_plan_name(&course.nombre_plan);
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO mysql_study_plans (mysql_id, organization_id, name, course_type)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (mysql_id) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
course_type = EXCLUDED.course_type,
|
|
updated_at = NOW()
|
|
"#
|
|
)
|
|
.bind(course.id_plan_de_estudios)
|
|
.bind(org_id)
|
|
.bind(&course.nombre_plan)
|
|
.bind(&plan_course_type)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to upsert MySQL study plan metadata: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let study_plan_id: i32 = sqlx::query_scalar(
|
|
"SELECT id FROM mysql_study_plans WHERE mysql_id = $1 AND organization_id = $2"
|
|
)
|
|
.bind(course.id_plan_de_estudios)
|
|
.bind(org_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to resolve MySQL study plan metadata: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let course_type = calculate_course_type_from_duration(course.duracion);
|
|
let level_calculated = calculate_course_level_for_storage(course.nivel_curso);
|
|
let duracion = course.duracion.map(|value| value.round() as i32);
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO mysql_courses (
|
|
mysql_id, organization_id, study_plan_id, name, level, duracion,
|
|
course_type, level_calculated
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (mysql_id) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
level = EXCLUDED.level,
|
|
duracion = EXCLUDED.duracion,
|
|
course_type = EXCLUDED.course_type,
|
|
level_calculated = EXCLUDED.level_calculated,
|
|
updated_at = NOW()
|
|
"#
|
|
)
|
|
.bind(course.id_cursos)
|
|
.bind(org_id)
|
|
.bind(study_plan_id)
|
|
.bind(&course.nombre_curso)
|
|
.bind(course.nivel_curso)
|
|
.bind(duracion)
|
|
.bind(&course_type)
|
|
.bind(&level_calculated)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to upsert MySQL course metadata: {}", e),
|
|
)
|
|
})?;
|
|
|
|
mysql_pool.close().await;
|
|
Ok(())
|
|
}
|
|
|
|
fn calculate_course_type_from_plan_name(plan_name: &str) -> String {
|
|
let plan_lower = plan_name.to_lowercase();
|
|
if plan_lower.contains("intensive") || plan_lower.contains("intensivo") {
|
|
"intensive".to_string()
|
|
} else {
|
|
"regular".to_string()
|
|
}
|
|
}
|
|
|
|
fn calculate_course_type_from_duration(duracion: Option<f64>) -> String {
|
|
match duracion {
|
|
Some(value) if value >= 70.0 => "intensive".to_string(),
|
|
_ => "regular".to_string(),
|
|
}
|
|
}
|
|
|
|
fn calculate_course_level_for_storage(nivel: Option<i32>) -> String {
|
|
match nivel {
|
|
None => "intermediate".to_string(),
|
|
Some(n) if n <= 2 => "beginner".to_string(),
|
|
Some(n) if n <= 4 => "beginner_1".to_string(),
|
|
Some(n) if n <= 6 => "beginner_2".to_string(),
|
|
Some(n) if n <= 8 => "intermediate".to_string(),
|
|
Some(n) if n <= 10 => "intermediate_1".to_string(),
|
|
Some(n) if n <= 12 => "intermediate_2".to_string(),
|
|
Some(_) => "advanced".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Helper function to determine course type from plan name
|
|
fn get_course_type_from_plan(plan_name: &str) -> CourseType {
|
|
let plan_lower = plan_name.to_lowercase();
|
|
if plan_lower.contains("intensive") || plan_lower.contains("intensivo") {
|
|
CourseType::Intensive
|
|
} else {
|
|
CourseType::Regular
|
|
}
|
|
}
|
|
|
|
/// Helper function to determine course level from MySQL data
|
|
fn get_course_level_from_mysql(nivel_curso: Option<i32>, plan_nombre: &str, _nombre_curso: &str) -> CourseLevel {
|
|
// Try to determine level from nivel_curso field first
|
|
if let Some(nivel) = nivel_curso {
|
|
return match nivel {
|
|
1..=2 => CourseLevel::Beginner,
|
|
3..=4 => CourseLevel::Beginner_1,
|
|
5..=6 => CourseLevel::Beginner_2,
|
|
7..=8 => CourseLevel::Intermediate,
|
|
9..=10 => CourseLevel::Intermediate_1,
|
|
11..=12 => CourseLevel::Intermediate_2,
|
|
_ => CourseLevel::Advanced,
|
|
};
|
|
}
|
|
|
|
// Fallback: try to extract level from plan name
|
|
let plan_lower = plan_nombre.to_lowercase();
|
|
if plan_lower.contains("basic") || plan_lower.contains("beginner") {
|
|
CourseLevel::Beginner
|
|
} else if plan_lower.contains("intermediate") || plan_lower.contains("intermedio") {
|
|
CourseLevel::Intermediate
|
|
} else {
|
|
CourseLevel::Advanced
|
|
}
|
|
}
|