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, // Filter by MySQL course ID pub level: Option, pub course_type: Option, pub test_type: Option, pub tags: Option, // Comma-separated list pub search: Option, } // ==================== 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, Json(payload): Json, ) -> Result, (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, Query(filters): Query, ) -> Result>, (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 = 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, State(pool): State, ) -> Result, (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 = 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 = 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, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result, (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, State(pool): State, ) -> Result { 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, State(pool): State, Json(payload): Json, ) -> Result, (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, pub question_order: i32, pub question_type: String, pub question_text: String, pub options: Option, pub correct_answer: Option, pub explanation: Option, pub points: i32, pub metadata: Option, } /// 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, ) -> Result { // 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, State(pool): State, Json(payload): Json, ) -> Result, (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, pub section_order: i32, pub points: i32, pub instructions: Option, pub section_data: Option, } /// 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, ) -> Result { 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, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result { // 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 = 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 = 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, } // ==================== 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 { 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 = 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, Json(payload): Json, ) -> Result>, (StatusCode, String)> { use common::ai::{self, generate_embedding}; use serde_json::json; let mut mysql_questions: Vec; // 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::>() .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::(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::(); 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::(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, correct_answer: Option| -> (Vec, Option) { 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 = (0..options.len()).collect(); let mut rng = thread_rng(); indices.shuffle(&mut rng); // Reorder options according to shuffled indices let shuffled_options: Vec = 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 = 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 = 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 = 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, // MySQL course ID from imported metadata pub topic: Option, pub num_questions: Option, pub question_type: Option, // 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, id_plan_de_estudios: i32, plan_nombre: String, nivel_curso: Option, #[sqlx(default)] similarity: Option, } #[derive(Debug, sqlx::FromRow)] struct MySqlQuestion { descripcion: String, id_tipo_pregunta: i32, nombre_curso: String, plan_nombre: String, nivel_curso: Option, id_plan_de_estudios: i32, } #[derive(Debug, sqlx::FromRow)] struct MySqlTemplateCourseMetadata { id_cursos: i32, nombre_curso: String, nivel_curso: Option, id_plan_de_estudios: i32, nombre_plan: String, duracion: Option, } 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) -> String { match duracion { Some(value) if value >= 70.0 => "intensive".to_string(), _ => "regular".to_string(), } } fn calculate_course_level_for_storage(nivel: Option) -> 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, 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 } }