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)> { 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_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, 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_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, 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 ==================== /// 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 reqwest::Client; use serde_json::json; let mut mysql_questions: Vec = Vec::new(); // 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.embedding IS NOT NULL ORDER BY qb.embedding <=> $1::vector LIMIT $3 "# ) .bind(&pgvector) .bind(org_ctx.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()); } 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.question_text ILIKE $2 LIMIT $3 "# ) .bind(org_ctx.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)))?; } } } 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() && payload.course_id.is_some() { return Err((StatusCode::NOT_FOUND, "No questions found in imported question bank for this course. Please import questions from MySQL first.".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(15) // Use only first 15 for context .map(|q| format!("* {}", q.descripcion)) .collect::>() .join("\n"); tracing::info!("RAG context built with {} questions", mysql_questions.len().min(15)); // 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); // Simplified system prompt for better performance on slower machines let system_prompt = 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 FOR OPTIONS: - Each option must be ONLY the answer text (1-3 words max) - Do NOT include letters like "A.", "B.", "a)", "b)" - Do NOT include "Option 1:", "Answer:", or any prefix - Just the pure answer text (e.g., "downtown", "Paris", "True") Skills: reading, listening, speaking, writing. Distribute across all 4."#, rag_context, num_questions, topic ); 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": false, "format": "json" // Ollama native JSON mode })); 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()); let response_json: serde_json::Value = response.json().await.map_err(|e| { tracing::error!("Failed to parse AI response: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response".to_string()) })?; tracing::debug!("Ollama response: {:?}", response_json); // Parse questions from Ollama response let questions_data = 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()) .and_then(|data| { // Try multiple formats: // 1. Standard array format: [...] if let Some(arr) = data.as_array() { return Some(arr.clone()); } // 2. Wrapped format: {questions: [...]} or {items: [...]} if let Some(questions) = data.get("questions").or(data.get("items")) { return questions.as_array().cloned(); } // 3. Object format with numbered keys: {q1: {...}, q2: {...}, ...} if let Some(obj) = data.as_object() { let questions: Vec = obj.values().cloned().collect(); if !questions.is_empty() { return Some(questions); } } None }) .unwrap_or_default(); // 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)| { // 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); // Shuffle options if we have valid data let (options, correct_answer) = 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))) } else { (Some(json!(original_options)), q.get("correct_answer").or(q.get("correct")).cloned()) } } else { (Some(json!(original_options)), q.get("correct_answer").or(q.get("correct")).cloned()) }; TestTemplateQuestion { id: Uuid::new_v4(), template_id: Uuid::nil(), section_id: None, question_order: idx as i32, question_type: q.get("question_type").and_then(|v| v.as_str()).unwrap_or("multiple-choice").to_string(), 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(), "options_shuffled": true, })), 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, _ => 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, "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, } #[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, } /// 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 } }