Files
openccb/services/cms-service/src/handlers_test_templates.rs
T
2026-03-17 12:07:56 -03:00

855 lines
30 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 uuid::Uuid;
// ==================== Query Parameters ====================
#[derive(Debug, Deserialize)]
pub struct TestTemplateFilters {
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)> {
let template: TestTemplate = sqlx::query_as(
r#"
INSERT INTO test_templates (
organization_id, created_by, name, description, 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)
RETURNING 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
"#
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(&payload.name)
.bind(&payload.description)
.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.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 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(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, 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),
level = COALESCE($5, level),
course_type = COALESCE($6, course_type),
test_type = COALESCE($7, test_type),
duration_minutes = COALESCE($8, duration_minutes),
passing_score = COALESCE($9, passing_score),
total_points = COALESCE($10, total_points),
instructions = COALESCE($11, instructions),
template_data = COALESCE($12, template_data),
tags = COALESCE($13, tags),
is_active = COALESCE($14, is_active),
updated_at = NOW()
WHERE id = $1 AND organization_id = $2
RETURNING 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
"#
)
.bind(template_id)
.bind(org_ctx.id)
.bind(payload.name)
.bind(payload.description)
.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, 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 ====================
/// POST /api/test-templates/generate-with-rag - Generate questions using RAG from MySQL question bank
pub async fn generate_questions_with_rag(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Json(payload): Json<RagGenerationPayload>,
) -> Result<Json<Vec<TestTemplateQuestion>>, (StatusCode, String)> {
use serde_json::json;
// 1. Fetch questions from external MySQL database (RAG context)
let mysql_url = std::env::var("MYSQL_DATABASE_URL")
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "MYSQL_DATABASE_URL not configured".to_string()))?;
// Create MySQL pool connection
let mysql_pool = sqlx::MySqlPool::connect(&mysql_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?;
// Fetch questions from MySQL bank filtered by course if provided
let mysql_questions: Vec<MySqlQuestion> = if let Some(course_id) = payload.course_id {
sqlx::query_as(
r#"
SELECT bp.descripcion, bp.idTipoPregunta, c.NombreCurso, pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.idCursos = ? AND bp.activo = 1
LIMIT 50
"#
)
.bind(course_id)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
} else {
sqlx::query_as(
r#"
SELECT bp.descripcion, bp.idTipoPregunta, c.NombreCurso, pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.activo = 1
LIMIT 50
"#
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
};
mysql_pool.close().await;
if mysql_questions.is_empty() && payload.course_id.is_some() {
return Err((StatusCode::NOT_FOUND, "No questions found in MySQL bank for this course".to_string()));
}
// 2. Build RAG context from MySQL questions
let rag_context: String = mysql_questions
.iter()
.map(|q| format!("- {} (Tipo: {}, Curso: {})", q.descripcion, q.id_tipo_pregunta, q.nombre_curso))
.collect::<Vec<_>>()
.join("\n");
// 3. Call AI to generate new questions based on RAG context
let provider = std::env::var("AI_PROVIDER").unwrap_or_else(|_| "local".to_string());
let client = reqwest::Client::new();
let (url, auth_header, model) = if provider == "local" {
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());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
model,
)
} else {
let api_key = std::env::var("OPENAI_API_KEY")
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "OPENAI_API_KEY not configured".to_string()))?;
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", api_key),
"gpt-4o".to_string(),
)
};
let system_prompt = format!(
r#"You are an expert English Teacher creating ORIGINAL quiz questions that assess the FOUR KEY LANGUAGE SKILLS.
Below are EXAMPLE questions from our existing question bank. Use them as INSPIRATION ONLY:
- Understand the TOPICS, STYLE, and DIFFICULTY LEVEL
- Create COMPLETELY NEW questions with different wording, contexts, and answer options
- Do NOT copy any question directly
- Adapt the concepts to fresh scenarios
EXAMPLE QUESTIONS (for inspiration only):
{}
Task: Create {} ORIGINAL multiple-choice questions about: {}
IMPORTANT: Each question must assess ONE of these FOUR skills:
1. READING - Comprehension, vocabulary in context, text analysis
2. LISTENING - Audio comprehension, spoken dialogue understanding
3. SPEAKING - Oral production, pronunciation, conversational response
4. WRITING - Written production, grammar in writing, composition
For EACH question, you MUST:
- Specify which skill it assesses (reading/listening/speaking/writing)
- Ensure the question actually tests that skill (not just knowledge)
- Provide pedagogically sound content for English language learning
- Match the difficulty level appropriately
Return ONLY a JSON array of questions with this exact structure:
[
{{
"question_text": "Your ORIGINAL question text here",
"question_type": "multiple-choice",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_answer": 0,
"explanation": "Brief explanation of why this is correct. End with: 'Skill assessed: [READING|LISTENING|SPEAKING|WRITING]'",
"points": 1,
"skill_assessed": "reading"
}}
]
GUIDELINES:
- Each question must be UNIQUE - rephrase concepts from examples
- Use different vocabulary, scenarios, and contexts
- Maintain similar difficulty level to the examples
- Ensure correct_answer is the index (0-3) of the correct option
- Write clear explanations that teach, not just state the answer
- DISTRIBUTE questions across all 4 skills (don't focus on just one)
Example transformations by skill:
- READING: "Read this passage and answer..." or "What does the word X mean in context?"
- LISTENING: "After listening to the dialogue..." or "What would you hear in this situation?"
- SPEAKING: "Which response is most appropriate in this conversation?"
- WRITING: "Which sentence is grammatically correct?" or "Complete the sentence with..."
Be creative while maintaining educational value and ensuring all 4 skills are covered!"#,
rag_context,
payload.num_questions.unwrap_or(5),
payload.topic.unwrap_or_else(|| "English grammar and vocabulary".to_string())
);
let mut request = client
.post(&url)
.json(&json!({
"model": model,
"messages": [
{
"role": "system",
"content": system_prompt
}
],
"response_format": { "type": "json_object" }
}));
if !auth_header.is_empty() {
request = request.header("Authorization", auth_header);
}
let response = request.send().await.map_err(|e| {
tracing::error!("AI request failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "AI service unavailable".to_string())
})?;
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())
})?;
// Parse questions from AI response
let questions_data = response_json
.get("choices")
.and_then(|c| c.as_array())
.and_then(|choices| choices.first())
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|content| serde_json::from_str::<serde_json::Value>(content.as_str().unwrap_or("{}")).ok())
.and_then(|data| {
if let Some(questions) = data.get("questions").or(data.get("items")) {
questions.as_array().cloned()
} else if let Some(arr) = data.as_array() {
Some(arr.clone())
} else {
None
}
})
.unwrap_or_default();
// Convert to TestTemplateQuestion format
let generated_questions: Vec<TestTemplateQuestion> = questions_data
.iter()
.enumerate()
.map(|(idx, q)| {
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: q.get("options").cloned(),
correct_answer: q.get("correct_answer").or(q.get("correct")).cloned(),
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(),
})),
created_at: chrono::Utc::now(),
}
})
.collect();
if generated_questions.is_empty() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI failed to generate questions".to_string()));
}
tracing::info!("Generated {} questions using RAG from MySQL bank", generated_questions.len());
Ok(Json(generated_questions))
}
#[derive(Debug, Deserialize)]
pub struct RagGenerationPayload {
pub course_id: Option<i32>, // MySQL course ID
pub topic: Option<String>,
pub num_questions: Option<i32>,
}
#[derive(Debug, sqlx::FromRow)]
struct MySqlQuestion {
descripcion: String,
id_tipo_pregunta: i32,
nombre_curso: String,
plan_nombre: String,
}