diff --git a/services/cms-service/src/external_handlers.rs b/services/cms-service/src/external_handlers.rs index aebfb89..f137865 100644 --- a/services/cms-service/src/external_handlers.rs +++ b/services/cms-service/src/external_handlers.rs @@ -31,9 +31,9 @@ pub async fn create_course_external( ) -> Result, StatusCode> { let org_id = validate_api_key(&headers, &pool).await?; - // We reuse the internal logic but with the org_id from the API key - // We need to provide a mock claims for handlers::create_course or refactor it. - // Simplifying for now: direct DB call or calling handlers with constructed context. + // Reutilizamos la lógica interna pero con el org_id de la clave API + // Necesitamos proporcionar un reclamo ficticio (mock claims) para handlers::create_course o refactorizarlo. + // Simplificando por ahora: llamada directa a la BD o llamando a manejadores con contexto construido. let title = payload.title.trim(); if title.is_empty() { @@ -52,7 +52,7 @@ pub async fn create_course_external( .fetch_one(&pool) .await .map_err(|e| { - tracing::error!("External course creation failed: {}", e); + tracing::error!("La creación del curso externo falló: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -94,9 +94,9 @@ pub struct ExternalCreateCoursePayload { pub title: String, pub description: Option, pub pacing_mode: Option, - // Optional direct template selection + // Selección directa de plantilla opcional pub template_id: Option, - // Optional fallback selection by level/course type/test type + // Selección de respaldo (fallback) opcional por nivel/tipo de curso/tipo de test pub template_level: Option, pub template_course_type: Option, pub template_test_type: Option, @@ -298,7 +298,7 @@ pub async fn trigger_transcription_external( ) -> Result { let org_id = validate_api_key(&headers, &pool).await?; - // Verify lesson belongs to org + // Verificar que la lección pertenece a la organización let _ = sqlx::query("SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2") .bind(id) .bind(org_id) @@ -306,7 +306,7 @@ pub async fn trigger_transcription_external( .await .map_err(|_| StatusCode::NOT_FOUND)?; - // Queue transcription + // Encolar transcripción sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1") .bind(id) .execute(&pool) diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 3f9b625..e7dfcbf 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -54,12 +54,12 @@ fn get_ai_url(var_base: &str, default: &str) -> String { } } -/// Simple token counter (approximate: 1 token ≈ 4 characters in English) +/// Contador de tokens simple (aproximado: 1 token ≈ 4 caracteres en inglés) fn count_tokens(text: &str) -> i32 { - // More accurate for English: split by whitespace and count words * 1.3 - // For Spanish/other languages, character-based is more reliable + // Más preciso para inglés: dividir por espacios en blanco y contar palabras * 1.3 + // Para el español y otros idiomas, el conteo basado en caracteres es más fiable let char_count = text.len(); - // OpenAI estimate: ~4 chars per token + // Estimación de OpenAI: ~4 caracteres por token ((char_count as f64) / 4.0).ceil() as i32 } @@ -73,7 +73,7 @@ pub async fn publish_course( let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); - // 1. Fetch Course (Super admin can publish any course, others only their org's) + // 1. Obtener curso (El superadministrador puede publicar cualquier curso, otros solo los de su organización) let course = if is_super_admin { sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") .bind(id) @@ -88,14 +88,14 @@ pub async fn publish_course( } .map_err(|_| StatusCode::NOT_FOUND)?; - // Determine target organization + // Determinar la organización de destino let target_org_id = if is_super_admin && payload_params.target_organization_id.is_some() { payload_params.target_organization_id.unwrap() } else { course.organization_id }; - // 2. Fetch Modules + // 2. Obtener módulos let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") .bind(id) @@ -105,7 +105,7 @@ pub async fn publish_course( let mut pub_modules = Vec::new(); - // 3. Fetch Grading Categories + // 3. Obtener categorías de calificación let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( "SELECT * FROM grading_categories WHERE course_id = $1", ) @@ -114,7 +114,7 @@ pub async fn publish_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 4. Fetch Target Organization + // 4. Obtener organización de destino let organization = sqlx::query_as::<_, common::models::Organization>( "SELECT * FROM organizations WHERE id = $1", ) @@ -123,7 +123,7 @@ pub async fn publish_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 5. Fetch Lessons for each Module + // 5. Obtener lecciones de cada módulo for module in modules { let lessons = sqlx::query_as::<_, Lesson>( "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", @@ -136,11 +136,11 @@ pub async fn publish_course( pub_modules.push(PublishedModule { module, lessons }); } - // Overwrite the course's organization_id in the payload if publishing to a different org + // Sobrescribir el organization_id del curso en la carga útil si se publica en una organización diferente let mut course_for_pub = course.clone(); course_for_pub.organization_id = target_org_id; - // 5. Fetch Course Team + // 5. Obtener equipo del curso let instructors = sqlx::query_as::<_, CourseInstructor>( "SELECT ci.*, u.email, u.full_name FROM course_instructors ci JOIN users u ON ci.user_id = u.id @@ -160,7 +160,7 @@ pub async fn publish_course( dependencies: None, }; - // 4. Send to LMS + // 4. Enviar al LMS let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let client = reqwest::Client::new(); @@ -170,12 +170,12 @@ pub async fn publish_course( .send() .await .map_err(|e| { - tracing::error!("Failed to reach LMS service: {}", e); + tracing::error!("Error al contactar con el servicio LMS: {}", e); StatusCode::BAD_GATEWAY })?; if !res.status().is_success() { - tracing::error!("LMS ingestion failed with status: {}", res.status()); + tracing::error!("La ingesta del LMS falló con estado: {}", res.status()); return Err(StatusCode::INTERNAL_SERVER_ERROR); } @@ -190,7 +190,7 @@ pub async fn publish_course( ) .await; - // 5. Trigger Webhook + // 5. Ejecutar webhook let webhook_service = WebhookService::new(pool.clone()); webhook_service .dispatch( @@ -225,7 +225,7 @@ pub struct GradingPayload { pub name: String, pub weight: i32, pub drop_count: i32, - pub tipo_nota_id: Option, // idTipoNota from tiponota table + pub tipo_nota_id: Option, // idTipoNota de la tabla tiponota } #[derive(Deserialize)] @@ -258,11 +258,11 @@ pub async fn create_course( .unwrap_or("self_paced"); let mut tx = pool.begin().await.map_err(|e| { - tracing::error!("Failed to start transaction: {}", e); + tracing::error!("Error al iniciar la transacción: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - // Set session context for the DB trigger to find + // Establecer el contexto de la sesión para que el disparador de la BD lo encuentre let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) @@ -284,7 +284,7 @@ pub async fn create_course( ) .await .map_err(|e| { - tracing::error!("Failed to set session context: {}", e); + tracing::error!("Error al establecer el contexto de la sesión: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -320,12 +320,12 @@ pub async fn create_course( .fetch_one(&mut *tx) .await .map_err(|e| { - tracing::error!("Create course failed: {}", e); + tracing::error!("Error al crear el curso: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit().await.map_err(|e| { - tracing::error!("Failed to commit transaction: {}", e); + tracing::error!("Error al confirmar la transacción: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -339,7 +339,7 @@ pub async fn get_courses( let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); - // Check if user is a SAM student + // Comprobar si el usuario es un estudiante de SAM let is_sam_student: bool = sqlx::query_scalar( "SELECT COALESCE(is_sam_student, false) FROM users WHERE id = $1" ) @@ -349,18 +349,18 @@ pub async fn get_courses( .unwrap_or(false); let courses: Vec = if is_super_admin { - // Super admin sees all courses + // El superadministrador ve todos los cursos sqlx::query_as::<_, Course>("SELECT * FROM courses") .fetch_all(&pool) .await } else if claims.role == "admin" || claims.role == "instructor" { - // Admins and instructors see all courses in their organization + // Los administradores e instructores ven todos los cursos de su organización sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1") .bind(org_ctx.id) .fetch_all(&pool) .await } else if is_sam_student { - // SAM students only see courses they are enrolled in via SAM + // Los estudiantes de SAM solo ven los cursos en los que están matriculados a través de SAM sqlx::query_as::<_, Course>( "SELECT c.* FROM courses c INNER JOIN sam_course_assignments sca ON c.id = sca.course_id @@ -373,7 +373,7 @@ pub async fn get_courses( .fetch_all(&pool) .await } else { - // Non-SAM students see NO courses (empty list) + // Los estudiantes que no son de SAM no ven NINGÚN curso (lista vacía) return Ok(Json(Vec::new())); } .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -464,7 +464,7 @@ pub async fn update_course( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Set auditing context + // Establecer contexto de auditoría sqlx::query( "SELECT set_config('app.current_user_id', $1, true), set_config('app.org_id', $2, true)", ) @@ -562,7 +562,7 @@ pub async fn create_module( .fetch_one(&mut *tx) .await .map_err(|e| { - tracing::error!("Create module failed: {}", e); + tracing::error!("Error al crear el módulo: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -678,7 +678,7 @@ pub async fn create_lesson( .fetch_one(&mut *tx) .await .map_err(|e| { - tracing::error!("Create lesson failed: {}", e); + tracing::error!("Error al crear la lección: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -686,7 +686,7 @@ pub async fn create_lesson( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Trigger auto-transcription if it's a video or audio lesson with a URL + // Activar la autotranscripción si es una lección de vídeo o audio con una URL if (content_type == "video" || content_type == "audio") && content_url.is_some() { trigger_transcription(pool, lesson.id).await; } @@ -695,16 +695,16 @@ pub async fn create_lesson( } async fn trigger_transcription(pool: PgPool, lesson_id: Uuid) { - // Set status to queued + // Establecer estado como en cola let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1") .bind(lesson_id) .execute(&pool) .await; - // Spawn background task + // Iniciar tarea en segundo plano tokio::spawn(async move { if let Err(e) = run_transcription_task(pool, lesson_id).await { - tracing::error!("Auto-transcription task failed for lesson {}: {}", lesson_id, e); + tracing::error!("La tarea de autotranscripción falló para la lección {}: {}", lesson_id, e); } }); } @@ -715,8 +715,8 @@ pub async fn process_transcription( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - tracing::info!("Received transcription request for lesson: {}", id); - // 1. Fetch lesson + tracing::info!("Recibida solicitud de transcripción para la lección: {}", id); + // 1. Obtener lección let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") .bind(id) @@ -724,7 +724,7 @@ pub async fn process_transcription( .fetch_one(&pool) .await .map_err(|e| { - tracing::error!("Lesson fetch failed: {}", e); + tracing::error!("Error al obtener la lección: {}", e); StatusCode::NOT_FOUND })?; @@ -734,13 +734,13 @@ pub async fn process_transcription( let url = lesson.content_url.ok_or(StatusCode::BAD_REQUEST)?; - // 2. Validate media is reachable (local /assets or absolute URL) + // 2. Validar que el medio sea accesible (assets locales o URL absoluta) if read_lesson_media_bytes(&url).await.is_err() { - tracing::error!("Media not accessible for transcription: {}", url); + tracing::error!("Medio no accesible para transcripción: {}", url); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - // 3. Set status to queued + // 3. Establecer estado como en cola let updated_lesson = sqlx::query_as::<_, Lesson>( "UPDATE lessons SET transcription_status = 'queued' WHERE id = $1 RETURNING *", ) @@ -748,7 +748,7 @@ pub async fn process_transcription( .fetch_one(&pool) .await .map_err(|e| { - tracing::error!("Database update failed (queued): {}", e); + tracing::error!("Error en la actualización de la base de datos (en cola): {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -763,11 +763,11 @@ pub async fn process_transcription( ) .await; - // 4. Spawn background task + // 4. Iniciar tarea en segundo plano let pool_clone = pool.clone(); tokio::spawn(async move { if let Err(e) = run_transcription_task(pool_clone, id).await { - tracing::error!("Transcription task failed for lesson {}: {}", id, e); + tracing::error!("La tarea de transcripción falló para la lección {}: {}", id, e); } }); diff --git a/services/cms-service/src/handlers_admin.rs b/services/cms-service/src/handlers_admin.rs index 89d9894..47fde1c 100644 --- a/services/cms-service/src/handlers_admin.rs +++ b/services/cms-service/src/handlers_admin.rs @@ -9,15 +9,15 @@ use sqlx::PgPool; use chrono::{DateTime, Utc}; use uuid::Uuid; -// ==================== Token Usage Tracking ==================== +// ==================== Seguimiento del Uso de Tokens ==================== -/// GET /api/admin/token-usage - Get token usage statistics for all users +/// GET /api/admin/token-usage - Obtener estadísticas de uso de tokens para todos los usuarios pub async fn get_token_usage( _org_ctx: Org, _claims: Claims, State(pool): State, ) -> Result, (StatusCode, String)> { - // Get user token usage from database + // Obtener el uso de tokens de usuario de la base de datos let usage: Vec = sqlx::query_as( r#" SELECT @@ -38,9 +38,9 @@ pub async fn get_token_usage( ) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso: {}", e)))?; - // Calculate stats + // Calcular estadísticas let total_tokens: i64 = usage.iter().map(|u| u.total_tokens).sum(); let total_input: i64 = usage.iter().map(|u| u.input_tokens).sum(); let total_output: i64 = usage.iter().map(|u| u.output_tokens).sum(); @@ -48,7 +48,7 @@ pub async fn get_token_usage( let top_user_tokens = usage.first().map(|u| u.total_tokens).unwrap_or(0); let avg_tokens = if !usage.is_empty() { total_tokens / usage.len() as i64 } else { 0 }; - // Estimate cost (using approximate OpenAI pricing: $0.001/1K input, $0.003/1K output) + // Estimación de coste (usando precios aproximados de OpenAI: $0.001/1K entrada, $0.003/1K salida) let estimated_cost = (total_input as f64 * 0.000001) + (total_output as f64 * 0.000003); let stats = TokenUsageStats { @@ -61,7 +61,7 @@ pub async fn get_token_usage( avg_tokens_per_user: avg_tokens, }; - // Convert to response format with USD estimation per user + // Convertir al formato de respuesta con estimación en USD por usuario let usage_with_cost: Vec = usage .into_iter() .map(|u| { @@ -78,7 +78,7 @@ pub async fn get_token_usage( last_used: u .last_used .map(|ts| ts.to_rfc3339()) - .unwrap_or_else(|| "Never".to_string()), + .unwrap_or_else(|| "Nunca".to_string()), estimated_cost_usd: user_cost, } }) @@ -90,14 +90,14 @@ pub async fn get_token_usage( })) } -/// GET /api/admin/ai-usage-dashboard - Comprehensive AI usage dashboard data +/// GET /api/admin/ai-usage-dashboard - Datos exhaustivos del panel de uso de IA pub async fn get_ai_usage_dashboard( Org(org_ctx): Org, _claims: Claims, State(pool): State, Query(filters): Query, ) -> Result, (StatusCode, String)> { - // Get daily usage for charts + // Obtener el uso diario para gráficos let daily_usage: Vec = sqlx::query_as( r#" SELECT @@ -121,9 +121,9 @@ pub async fn get_ai_usage_dashboard( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso diario: {}", e)))?; - // Get usage by endpoint/feature + // Obtener uso por punto de conexión/función let by_endpoint: Vec = sqlx::query_as( r#" SELECT @@ -147,9 +147,9 @@ pub async fn get_ai_usage_dashboard( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del punto de conexión: {}", e)))?; - // Get top users + // Obtener usuarios principales let top_users: Vec = sqlx::query_as( r#" SELECT @@ -177,18 +177,18 @@ pub async fn get_ai_usage_dashboard( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los usuarios principales: {}", e)))?; - // Calculate summary stats + // Calcular estadísticas de resumen let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum(); let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum(); let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum(); let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum(); let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum(); - // Calculate savings estimate (vs OpenAI GPT-4 pricing) + // Calcular estimación de ahorros (frente a precios de OpenAI GPT-4) // GPT-4: ~$0.03/1K input, ~$0.06/1K output - // Our local AI: ~$0.001/1K input, ~$0.003/1K output + // Nuestra IA local: ~$0.001/1K entrada, ~$0.003/1K salida let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006); let savings_vs_openai = openai_equivalent_cost - total_cost; @@ -208,7 +208,7 @@ pub async fn get_ai_usage_dashboard( })) } -/// GET /api/admin/ai-usage/logs - Get detailed AI usage logs with pagination +/// GET /api/admin/ai-usage/logs - Obtener logs detallados de uso de IA con paginación pub async fn get_ai_usage_logs( Org(org_ctx): Org, _claims: Claims, @@ -254,9 +254,9 @@ pub async fn get_ai_usage_logs( .bind(offset as i64) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch logs: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los logs: {}", e)))?; - // Get total count for pagination + // Obtener el conteo total para la paginación let count: (i64,) = sqlx::query_as( r#" SELECT COUNT(*) FROM ai_usage_logs @@ -272,7 +272,7 @@ pub async fn get_ai_usage_logs( .bind(filters.user_id.clone()) .fetch_one(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count logs: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al contar los logs: {}", e)))?; Ok(Json(UsageLogsResponse { logs, @@ -427,15 +427,15 @@ pub struct TokenUsageResponse { pub stats: TokenUsageStats, } -// ==================== Global AI Usage Dashboard (Root Only) ==================== +// ==================== Panel de Uso Global de IA (Solo Root) ==================== -/// GET /api/admin/ai-usage/global - Global AI usage dashboard for root users +/// GET /api/admin/ai-usage/global - Panel de uso global de IA para usuarios root pub async fn get_ai_usage_global( _claims: Claims, State(pool): State, Query(filters): Query, ) -> Result, (StatusCode, String)> { - // Get daily usage for charts (all organizations) + // Obtener el uso diario para gráficos (todas las organizaciones) let daily_usage: Vec = sqlx::query_as( r#" SELECT @@ -457,9 +457,9 @@ pub async fn get_ai_usage_global( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso diario: {}", e)))?; - // Get usage by endpoint/feature + // Obtener uso por punto de conexión/función let by_endpoint: Vec = sqlx::query_as( r#" SELECT @@ -481,9 +481,9 @@ pub async fn get_ai_usage_global( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del punto de conexión: {}", e)))?; - // Get usage by organization + // Obtener uso por organización let by_organization: Vec = sqlx::query_as( r#" SELECT @@ -507,9 +507,9 @@ pub async fn get_ai_usage_global( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch org usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso de la organización: {}", e)))?; - // Get top users across all organizations + // Obtener usuarios principales en todas las organizaciones let top_users: Vec = sqlx::query_as( r#" SELECT @@ -537,9 +537,9 @@ pub async fn get_ai_usage_global( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los usuarios principales: {}", e)))?; - // Get usage by request type (for pie chart) + // Obtener uso por tipo de solicitud (para gráfico circular) let by_request_type: Vec = sqlx::query_as( r#" SELECT @@ -558,18 +558,18 @@ pub async fn get_ai_usage_global( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch request type usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del tipo de solicitud: {}", e)))?; - // Calculate summary stats + // Calcular estadísticas de resumen let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum(); let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum(); let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum(); let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum(); let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum(); - // Calculate savings estimate (vs OpenAI GPT-4 pricing) + // Calcular estimación de ahorros (frente a precios de OpenAI GPT-4) // GPT-4: ~$0.03/1K input, ~$0.06/1K output - // Our local AI: ~$0.001/1K input, ~$0.003/1K output + // Nuestra IA local: ~$0.001/1K entrada, ~$0.003/1K salida let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006); let savings_vs_openai = openai_equivalent_cost - total_cost; let savings_percentage = if openai_equivalent_cost > 0.0 { @@ -578,7 +578,7 @@ pub async fn get_ai_usage_global( 0.0 }; - // Get total active users count + // Obtener el conteo total de usuarios activos let total_active_users: i64 = sqlx::query_scalar( r#" SELECT COUNT(DISTINCT au.user_id) @@ -591,9 +591,9 @@ pub async fn get_ai_usage_global( .bind(filters.end_date.clone()) .fetch_one(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count users: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al contar los usuarios: {}", e)))?; - // Calculate student-specific usage (chat interactions) + // Calcular uso específico por estudiante (interacciones de chat) let student_chat_usage: Vec = sqlx::query_as( r#" SELECT @@ -621,9 +621,9 @@ pub async fn get_ai_usage_global( .bind(filters.end_date.clone()) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch student chat usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso de chat de estudiantes: {}", e)))?; - // Calculate student chat totals + // Calcular totales de chat de estudiantes let total_student_chat_tokens: i64 = student_chat_usage.iter().map(|s| s.total_tokens).sum(); let total_student_chat_cost: f64 = student_chat_usage.iter().map(|s| s.cost_usd).sum(); let total_student_chat_requests: i64 = student_chat_usage.iter().map(|s| s.chat_requests).sum(); @@ -722,7 +722,7 @@ pub struct GlobalAiUsageResponse { pub student_chat_usage: Vec, } -// ==================== User Token Limits ==================== +// ==================== Límites de Tokens de Usuario ==================== #[derive(Debug, Deserialize)] pub struct SetTokenLimitPayload { @@ -730,7 +730,7 @@ pub struct SetTokenLimitPayload { pub token_limit_reset_day: Option, } -/// PUT /admin/users/{user_id}/token-limit - Set monthly token limit for user +/// PUT /admin/users/{user_id}/token-limit - Establecer límite mensual de tokens para el usuario pub async fn set_user_token_limit( _org: Org, _claims: Claims, @@ -738,7 +738,7 @@ pub async fn set_user_token_limit( Path(user_id): Path, Json(payload): Json, ) -> Result { - // Validate reset day (1-28 to avoid month-end issues) + // Validar día de reinicio (1-28 para evitar problemas a fin de mes) let reset_day = payload.token_limit_reset_day.unwrap_or(1).clamp(1, 28); sqlx::query( @@ -755,19 +755,19 @@ pub async fn set_user_token_limit( .bind(user_id) .execute(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to set limit: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al establecer el límite: {}", e)))?; Ok(StatusCode::OK) } -/// GET /admin/users/{user_id}/token-usage - Get token usage for specific user +/// GET /admin/users/{user_id}/token-usage - Obtener uso de tokens para un usuario específico pub async fn get_user_token_usage( _org: Org, _claims: Claims, State(pool): State, Path(user_id): Path, ) -> Result, (StatusCode, String)> { - // Get current month usage + // Obtener uso del mes actual let usage: UserTokenUsageDetail = sqlx::query_as( r#" SELECT @@ -790,12 +790,12 @@ pub async fn get_user_token_usage( .bind(user_id) .fetch_one(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch usage: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso: {}", e)))?; Ok(Json(usage)) } -/// GET /admin/users/{user_id}/token-limit/check - Check if user has available tokens +/// GET /admin/users/{user_id}/token-limit/check - Comprobar si el usuario tiene tokens disponibles pub async fn check_user_token_limit( _org: Org, _claims: Claims, diff --git a/services/cms-service/src/handlers_assets.rs b/services/cms-service/src/handlers_assets.rs index fca97f2..84e5411 100644 --- a/services/cms-service/src/handlers_assets.rs +++ b/services/cms-service/src/handlers_assets.rs @@ -158,7 +158,7 @@ async fn maybe_push_local_file_to_s3( let bytes = tokio::fs::read(local_path) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read local file failed: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al leer el archivo local: {}", e)))?; let client = build_s3_client(&settings).await?; let key = build_s3_object_key(org_id, course_id, storage_filename); @@ -189,7 +189,7 @@ async fn push_bytes_to_s3( .body(bytes.into()) .send() .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 upload failed: {}", e)))?; + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al subir a S3: {}", e)))?; let storage_path = format!("s3://{}/{}", settings.bucket, key); let public_url = build_s3_public_url(settings, key); @@ -200,7 +200,7 @@ async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, Stri if let Some((bucket, key)) = parse_s3_storage_path(storage_path) { let settings = get_s3_settings().ok_or(( StatusCode::INTERNAL_SERVER_ERROR, - "S3 storage path found but S3 is not configured".to_string(), + "Se encontró una ruta de almacenamiento S3 pero S3 no está configurado".to_string(), ))?; let client = build_s3_client(&settings).await?; client @@ -209,7 +209,7 @@ async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, Stri .key(key) .send() .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 delete failed: {}", e)))?; + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al eliminar de S3: {}", e)))?; return Ok(()); } @@ -227,26 +227,26 @@ fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> { } /// GET /api/assets/s3-proxy/{bucket}/{*key} -/// Proxies private S3 objects through CMS so frontend URLs do not depend on public-read ACLs. +/// Realiza un proxy de objetos privados de S3 a través del CMS para que las URLs del frontend no dependan de ACLs de lectura pública. pub async fn public_s3_proxy( Path(params): Path>, ) -> Result { let bucket = params .get("bucket") .cloned() - .ok_or((StatusCode::BAD_REQUEST, "Missing bucket".to_string()))?; + .ok_or((StatusCode::BAD_REQUEST, "Falta el cubo (bucket)".to_string()))?; let key = params .get("key") .cloned() - .ok_or((StatusCode::BAD_REQUEST, "Missing key".to_string()))?; + .ok_or((StatusCode::BAD_REQUEST, "Falta la clave (key)".to_string()))?; let settings = get_s3_settings().ok_or(( StatusCode::NOT_FOUND, - "S3 storage is not configured".to_string(), + "El almacenamiento S3 no está configurado".to_string(), ))?; if bucket != settings.bucket { - return Err((StatusCode::FORBIDDEN, "Bucket not allowed".to_string())); + return Err((StatusCode::FORBIDDEN, "Cubo (bucket) no permitido".to_string())); } let storage_path = format!("s3://{}/{}", bucket, key); @@ -257,13 +257,13 @@ pub async fn public_s3_proxy( header::CONTENT_TYPE, "application/octet-stream" .parse() - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?, + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Cabecera inválida: {}", e)))?, ); headers.insert( header::CACHE_CONTROL, "public, max-age=3600" .parse() - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?, + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Cabecera inválida: {}", e)))?, ); Ok((headers, bytes)) @@ -282,18 +282,18 @@ async fn read_storage_bytes(storage_path: &str) -> Result, (StatusCode, .key(key) .send() .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 read failed: {}", e)))?; + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al leer de S3: {}", e)))?; let data = output .body .collect() .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 stream read failed: {}", e)))?; + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al leer el flujo de S3: {}", e)))?; return Ok(data.into_bytes().to_vec()); } tokio::fs::read(storage_path) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e))) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de lectura: {}", e))) } /// POST /api/assets/upload - Subir un archivo a la biblioteca global @@ -357,12 +357,12 @@ pub async fn upload_asset( } if data.is_empty() { - return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string())); + return Err((StatusCode::BAD_REQUEST, "No se subió ningún archivo".to_string())); } let asset_id = Uuid::new_v4(); - // Ensure uploads directory exists + // Asegurar que el directorio de subidas existe tokio::fs::create_dir_all("uploads") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -543,7 +543,7 @@ pub async fn delete_asset( State(pool): State, Path(id): Path, ) -> Result { - // 1. Get asset metadata to find file path + // 1. Obtener metadatos del activo para encontrar la ruta del archivo let asset: Asset = sqlx::query_as( "SELECT * FROM assets WHERE id = $1 AND organization_id = $2" ) @@ -552,16 +552,16 @@ pub async fn delete_asset( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Activo no encontrado".to_string()))?; - // 2. Delete from DB + // 2. Eliminar de la base de datos sqlx::query("DELETE FROM assets WHERE id = $1") .bind(id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3. Delete physical file or S3 object + // 3. Eliminar archivo físico u objeto de S3 let _ = delete_storage_path(&asset.storage_path).await; Ok(StatusCode::NO_CONTENT) @@ -582,7 +582,7 @@ pub async fn ingest_asset_for_rag( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Activo no encontrado".to_string()))?; let extracted = extract_asset_text(&asset).await?; let content = extracted.trim(); @@ -668,7 +668,7 @@ pub async fn ingest_asset_for_rag( /// - file: ZIP requerido /// - course_id: UUID opcional /// - ingest_rag: true/false opcional (default false) -/// Extracts a unit number from a ZIP entry path using the top-level folder name. +/// Extrae un número de unidad de la ruta de una entrada ZIP usando el nombre de la carpeta de nivel superior. /// Supports: "Unit 1/...", "Unidad 1/...", "unit-01/...", "01/...", "1/..." fn extract_unit_number(entry_name: &str) -> Option { let parts: Vec<&str> = entry_name.splitn(2, '/').collect(); @@ -680,7 +680,7 @@ fn extract_unit_number(entry_name: &str) -> Option { return None; } let lower = folder.to_lowercase(); - // Strip common textual prefixes, then parse leading digits + // Eliminar prefijos textuales comunes, luego analizar los dígitos iniciales let stripped = lower .trim_start_matches("unidad") .trim_start_matches("unit") @@ -739,13 +739,13 @@ async fn process_zip_entry_without_rag( let temp_storage_path = format!("uploads/{}", temp_storage_filename); tokio::fs::write(&temp_storage_path, &content) .await - .map_err(|e| format!("{}: local write failed ({})", entry_name, e))?; + .map_err(|e| format!("{}: Error en la escritura local ({})", entry_name, e))?; let final_storage_filename = format!("{}.mp4", asset_id); let final_storage_path = format!("uploads/{}", final_storage_filename); if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &final_storage_path).await { let _ = tokio::fs::remove_file(&temp_storage_path).await; - return Err(format!("{}: flv transcode failed ({})", entry_name, msg)); + return Err(format!("{}: la transcodificación de flv falló ({})", entry_name, msg)); } let _ = tokio::fs::remove_file(&temp_storage_path).await; diff --git a/services/cms-service/src/handlers_branding.rs b/services/cms-service/src/handlers_branding.rs index 2b56986..d5f47e6 100644 --- a/services/cms-service/src/handlers_branding.rs +++ b/services/cms-service/src/handlers_branding.rs @@ -1,4 +1,4 @@ -// Organization Branding Handlers +// Manejadores de Marca de la Organización use axum::{ Json, @@ -32,84 +32,84 @@ pub struct BrandingResponse { pub secondary_color: String, } -// Upload organization logo +// Cargar logo de la organización pub async fn upload_organization_logo( claims: common::auth::Claims, Org(org_ctx): Org, State(pool): State, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { - // Only admins can upload logos + // Solo los administradores pueden cargar logos if claims.role != "admin" { - return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); } - // Verify organization exists and user has access + // Verificar que la organización existe y el usuario tiene acceso let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Organización no encontrada".into()))?; - // Process multipart form + // Procesar formulario multipart while let Some(field) = multipart .next_field() .await - .map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))? + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Error en multipart: {}", e)))? { let name = field.name().unwrap_or("").to_string(); if name == "file" { let filename = field .file_name() - .ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))? + .ok_or((StatusCode::BAD_REQUEST, "Faltan datos del nombre del archivo".into()))? .to_string(); - // Validate file extension + // Validar extensión del archivo let ext = filename.split('.').last().unwrap_or(""); if !["png", "jpg", "jpeg", "svg"].contains(&ext.to_lowercase().as_str()) { return Err(( StatusCode::BAD_REQUEST, - "Invalid file type. Only PNG, JPG, and SVG allowed".into(), + "Tipo de archivo inválido. Solo se permiten PNG, JPG y SVG".into(), )); } let data = field.bytes().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to read file: {}", e), + format!("Error al leer el archivo: {}", e), ) })?; - // Validate file size (max 2MB) + // Validar tamaño del archivo (máx. 2MB) if data.len() > 2 * 1024 * 1024 { return Err(( StatusCode::BAD_REQUEST, - "File too large. Maximum 2MB allowed".into(), + "Archivo demasiado grande. Se permite un máximo de 2MB".into(), )); } - // Create uploads directory if it doesn't exist + // Crear el directorio de subidas si no existe std::fs::create_dir_all("uploads/org-logos").map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create directory: {}", e), + format!("Error al crear el directorio: {}", e), ) })?; - // Generate unique filename + // Generar nombre de archivo único let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext); let filepath = format!("uploads/org-logos/{}", unique_filename); - // Save file + // Guardar archivo std::fs::write(&filepath, &data).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to save file: {}", e), + format!("Error al guardar el archivo: {}", e), ) })?; - // Update organization in database + // Actualizar organización en la base de datos let logo_url = format!("/{}", filepath); sqlx::query("UPDATE organizations SET logo_url = $1 WHERE id = $2") .bind(&logo_url) @@ -119,7 +119,7 @@ pub async fn upload_organization_logo( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Database error: {}", e), + format!("Error de base de datos: {}", e), ) })?; @@ -136,92 +136,92 @@ pub async fn upload_organization_logo( return Ok(Json(json!({ "logo_url": logo_url, - "message": "Logo uploaded successfully" + "message": "Logo cargado con éxito" }))); } } - Err((StatusCode::BAD_REQUEST, "No file provided".into())) + Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo".into())) } -// Upload organization favicon +// Cargar favicon de la organización pub async fn upload_organization_favicon( claims: common::auth::Claims, Org(org_ctx): Org, State(pool): State, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { - // Only admins can upload favicons + // Solo los administradores pueden cargar favicons if claims.role != "admin" { - return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); } - // Verify organization exists and user has access + // Verificar que la organización existe y el usuario tiene acceso let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Organización no encontrada".into()))?; - // Process multipart form + // Procesar formulario multipart while let Some(field) = multipart .next_field() .await - .map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))? + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Error en multipart: {}", e)))? { let name = field.name().unwrap_or("").to_string(); if name == "file" { let filename = field .file_name() - .ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))? + .ok_or((StatusCode::BAD_REQUEST, "Falta el nombre del archivo".into()))? .to_string(); - // Validate file extension + // Validar extensión del archivo let ext = filename.split('.').last().unwrap_or(""); if !["png", "jpg", "jpeg", "svg", "ico"].contains(&ext.to_lowercase().as_str()) { return Err(( StatusCode::BAD_REQUEST, - "Invalid file type. Only PNG, JPG, SVG, and ICO allowed".into(), + "Tipo de archivo inválido. Solo se permiten PNG, JPG, SVG e ICO".into(), )); } let data = field.bytes().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to read file: {}", e), + format!("Error al leer el archivo: {}", e), ) })?; - // Validate file size (max 512KB for favicons seems reasonable, but sticking to 1MB to be safe) + // Validar tamaño del archivo (el máx. razonable para favicons es 512KB, pero se mantiene en 1MB por seguridad) if data.len() > 1024 * 1024 { return Err(( StatusCode::BAD_REQUEST, - "File too large. Maximum 1MB allowed".into(), + "Archivo demasiado grande. Se permite un máximo de 1MB".into(), )); } - // Create uploads directory if it doesn't exist + // Crear el directorio de subidas si no existe std::fs::create_dir_all("uploads/org-favicons").map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create directory: {}", e), + format!("Error al crear el directorio: {}", e), ) })?; - // Generate unique filename + // Generar nombre de archivo único let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext); let filepath = format!("uploads/org-favicons/{}", unique_filename); - // Save file + // Guardar archivo std::fs::write(&filepath, &data).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to save file: {}", e), + format!("Error al guardar el archivo: {}", e), ) })?; - // Update organization in database + // Actualizar organización en la base de datos let favicon_url = format!("/{}", filepath); sqlx::query("UPDATE organizations SET favicon_url = $1 WHERE id = $2") .bind(&favicon_url) @@ -231,7 +231,7 @@ pub async fn upload_organization_favicon( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Database error: {}", e), + format!("Error de base de datos: {}", e), ) })?; @@ -248,27 +248,27 @@ pub async fn upload_organization_favicon( return Ok(Json(json!({ "favicon_url": favicon_url, - "message": "Favicon uploaded successfully" + "message": "Favicon cargado con éxito" }))); } } - Err((StatusCode::BAD_REQUEST, "No file provided".into())) + Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo".into())) } -// Update organization branding colors +// Actualizar colores de marca de la organización pub async fn update_organization_branding( claims: common::auth::Claims, Org(org_ctx): Org, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Only admins can update branding + // Solo los administradores pueden actualizar la marca if claims.role != "admin" { - return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); } - // Validate hex color format + // Validar formato de color hexadecimal let validate_color = |color: &str| -> bool { color.len() == 7 && color.starts_with('#') @@ -279,7 +279,7 @@ pub async fn update_organization_branding( if !validate_color(primary) { return Err(( StatusCode::BAD_REQUEST, - "Invalid primary_color format. Use #RRGGBB".into(), + "Formato de primary_color inválido. Use #RRGGBB".into(), )); } } @@ -288,12 +288,12 @@ pub async fn update_organization_branding( if !validate_color(secondary) { return Err(( StatusCode::BAD_REQUEST, - "Invalid secondary_color format. Use #RRGGBB".into(), + "Formato de secondary_color inválido. Use #RRGGBB".into(), )); } } - // Update organization + // Actualizar organización let org = sqlx::query_as::<_, Organization>( "UPDATE organizations SET name = COALESCE($1, name), @@ -316,7 +316,7 @@ pub async fn update_organization_branding( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Database error: {}", e), + format!("Error de base de datos: {}", e), ) })?; @@ -334,7 +334,7 @@ pub async fn update_organization_branding( Ok(Json(org)) } -// Get organization branding (public endpoint) +// Obtener marca de la organización (punto de conexión público) pub async fn get_organization_branding( State(pool): State, ) -> Result, StatusCode> { diff --git a/services/cms-service/src/handlers_embeddings.rs b/services/cms-service/src/handlers_embeddings.rs index c6e4d4d..2c4d652 100644 --- a/services/cms-service/src/handlers_embeddings.rs +++ b/services/cms-service/src/handlers_embeddings.rs @@ -1,5 +1,5 @@ -//! Handlers for PGVector embeddings in Question Bank -//! Enables semantic search and RAG with AI-powered embeddings +//! Manejadores para incrustaciones (embeddings) de PGVector en el Banco de Preguntas +//! Habilita la búsqueda semántica y RAG con incrustaciones impulsadas por IA use axum::{ Json, @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -// ==================== Query Parameters ==================== +// ==================== Parámetros de Consulta ==================== #[derive(Debug, Deserialize)] pub struct SemanticSearchFilters { @@ -43,26 +43,26 @@ pub struct GenerateEmbeddingsResult { pub duration_ms: u64, } -// ==================== Generate Embeddings ==================== +// ==================== Generar Incrustaciones (Embeddings) ==================== -/// POST /api/question-bank/embeddings/generate - Generate embeddings for all questions without them +/// POST /api/question-bank/embeddings/generate - Generar incrustaciones para todas las preguntas que no las tengan pub async fn generate_question_embeddings( Org(org_ctx): Org, State(pool): State, ) -> Result, (StatusCode, String)> { let start = std::time::Instant::now(); - // Create client that accepts invalid certificates (for dev with self-signed certs) + // Crear cliente que acepte certificados inválidos (para desarrollo con certificados autofirmados) 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)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?; let ollama_url = ai::get_ollama_url(); let model = ai::get_embedding_model(); - // Get questions without embeddings + // Obtener preguntas sin incrustaciones let questions: Vec = sqlx::query_as( r#" SELECT * FROM question_bank @@ -82,7 +82,7 @@ pub async fn generate_question_embeddings( let mut failed = 0; for question in questions { - // Generate embedding text (combine question + options + explanation) + // Generar el texto de la incrustación (combinar pregunta + opciones + explicación) let mut embedding_text = question.question_text.clone(); if let Some(options) = &question.options { @@ -104,12 +104,12 @@ pub async fn generate_question_embeddings( embedding_text.push_str(explanation); } - // Generate embedding + // Generar incrustación match generate_embedding(&client, &ollama_url, &model, &embedding_text).await { Ok(response) => { let pgvector = ai::embedding_to_pgvector(&response.embedding); - // Update question with embedding + // Actualizar pregunta con la incrustación let result: Result<(i64,), sqlx::Error> = sqlx::query_as( r#" UPDATE question_bank @@ -127,16 +127,16 @@ pub async fn generate_question_embeddings( match result { Ok(_) => { processed += 1; - tracing::debug!("Generated embedding for question {}", question.id); + tracing::debug!("Incrustación generada para la pregunta {}", question.id); } Err(e) => { failed += 1; - tracing::error!("Failed to update embedding for question {}: {}", question.id, e); + tracing::error!("Error al actualizar la incrustación para la pregunta {}: {}", question.id, e); } } } Err(e) => { - tracing::error!("Failed to generate embedding for question {}: {}", question.id, e); + tracing::error!("Error al generar la incrustación para la pregunta {}: {}", question.id, e); failed += 1; } } @@ -145,7 +145,7 @@ pub async fn generate_question_embeddings( let duration_ms = start.elapsed().as_millis() as u64; tracing::info!( - "Generated embeddings: {} processed, {} failed in {}ms", + "Incrustaciones generadas: {} procesadas, {} fallidas en {}ms", processed, failed, duration_ms @@ -158,23 +158,23 @@ pub async fn generate_question_embeddings( })) } -/// POST /api/question-bank/:id/embedding/regenerate - Regenerate embedding for a specific question +/// POST /api/question-bank/:id/embedding/regenerate - Regenerar incrustación para una pregunta específica pub async fn regenerate_question_embedding( Org(org_ctx): Org, Path(question_id): Path, State(pool): State, ) -> Result { - // Create client that accepts invalid certificates + // Crear cliente que acepte certificados inválidos 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)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?; let ollama_url = ai::get_ollama_url(); let model = ai::get_embedding_model(); - // Get question + // Obtener pregunta let question: QuestionBank = sqlx::query_as( "SELECT * FROM question_bank WHERE id = $1 AND organization_id = $2" ) @@ -183,9 +183,9 @@ pub async fn regenerate_question_embedding( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Question not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()))?; - // Generate embedding text + // Generar texto de la incrustación let mut embedding_text = question.question_text.clone(); if let Some(options) = &question.options { @@ -207,14 +207,14 @@ pub async fn regenerate_question_embedding( embedding_text.push_str(explanation); } - // Generate embedding + // Generar incrustación let response = generate_embedding(&client, &ollama_url, &model, &embedding_text) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de IA: {}", e)))?; let pgvector = ai::embedding_to_pgvector(&response.embedding); - // Update question + // Actualizar pregunta sqlx::query( r#" UPDATE question_bank @@ -232,35 +232,35 @@ pub async fn regenerate_question_embedding( Ok(StatusCode::OK) } -// ==================== Semantic Search ==================== +// ==================== Búsqueda Semántica ==================== -/// GET /api/question-bank/semantic-search - Search questions by semantic similarity +/// GET /api/question-bank/semantic-search - Buscar preguntas por similitud semántica pub async fn semantic_search( Org(org_ctx): Org, State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { - // Create client that accepts invalid certificates + // Crear cliente que acepte certificados inválidos 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)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?; let ollama_url = ai::get_ollama_url(); let model = ai::get_embedding_model(); - // Generate embedding for query + // Generar incrustación para la consulta let embedding_response = generate_embedding(&client, &ollama_url, &model, &filters.query) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de IA: {}", e)))?; let pgvector = ai::embedding_to_pgvector(&embedding_response.embedding); let limit = filters.limit.unwrap_or(20); let threshold = filters.threshold.unwrap_or(0.5); - // Build query with optional filters + // Construir consulta con filtros opcionales let mut query = String::from( r#" SELECT @@ -316,7 +316,7 @@ pub async fn semantic_search( Ok(Json(results)) } -/// GET /api/question-bank/similar/:id - Find questions similar to a given question +/// GET /api/question-bank/similar/:id - Encontrar preguntas similares a una pregunta dada pub async fn find_similar_questions( Org(org_ctx): Org, Path(question_id): Path, diff --git a/services/cms-service/src/handlers_library.rs b/services/cms-service/src/handlers_library.rs index a8cedf9..aad810e 100644 --- a/services/cms-service/src/handlers_library.rs +++ b/services/cms-service/src/handlers_library.rs @@ -13,7 +13,7 @@ use uuid::Uuid; pub struct LibraryBlockFilters { #[serde(rename = "type")] pub block_type: Option, - pub tags: Option, // Comma-separated list + pub tags: Option, // Lista separada por comas pub search: Option, } @@ -51,7 +51,7 @@ pub async fn list_library_blocks( State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { - // Base query + // Consulta base let mut query = String::from("SELECT * FROM library_blocks WHERE organization_id = $1"); let mut param_count = 1; @@ -78,7 +78,7 @@ pub async fn list_library_blocks( query.push_str(" ORDER BY created_at DESC"); - // Build query con bind dinámico + // Construir consulta con bind dinámico let mut sql_query = sqlx::query_as::<_, LibraryBlock>(&query).bind(org_ctx.id); if let Some(block_type) = &filters.block_type { @@ -120,7 +120,7 @@ pub async fn get_library_block( match block { Some(b) => Ok(Json(b)), - None => Err((StatusCode::NOT_FOUND, "Block not found".to_string())), + None => Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string())), } } @@ -140,7 +140,7 @@ pub async fn update_library_block( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if existing.is_none() { - return Err((StatusCode::NOT_FOUND, "Block not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string())); } // Update dinámico basado en campos provistos @@ -201,7 +201,7 @@ pub async fn delete_library_block( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { - return Err((StatusCode::NOT_FOUND, "Block not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string())); } Ok(StatusCode::NO_CONTENT) @@ -221,7 +221,7 @@ pub async fn increment_block_usage( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { - return Err((StatusCode::NOT_FOUND, "Block not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string())); } Ok(StatusCode::OK) diff --git a/services/cms-service/src/handlers_question_bank.rs b/services/cms-service/src/handlers_question_bank.rs index ee6715f..758decd 100644 --- a/services/cms-service/src/handlers_question_bank.rs +++ b/services/cms-service/src/handlers_question_bank.rs @@ -92,7 +92,7 @@ async fn connect_mysql_pool(env_var: &str) -> Result Result { last_error = e.to_string(); tracing::warn!( - "MySQL connection attempt {}/3 failed for {}: {}", + "Intento de conexión a MySQL {}/3 fallido para {}: {}", attempt, env_var, last_error @@ -128,13 +128,13 @@ async fn connect_mysql_pool(env_var: &str) -> Result, } -/// Save or update study plans and courses from MySQL during import +/// Guardar o actualizar planes de estudio y cursos de MySQL durante la importación pub async fn save_mysql_courses_and_plans( pool: &PgPool, org_id: Uuid, @@ -172,14 +172,14 @@ pub async fn save_mysql_courses_and_plans( ) -> Result<(), String> { let plans_count = plans.len(); let courses_count = courses.len(); - tracing::info!("Saving {} study plans and {} courses from MySQL", plans_count, courses_count); + tracing::info!("Guardando {} planes de estudio y {} cursos de MySQL", plans_count, courses_count); - // Save study plans first + // Guardar planes de estudio primero for plan in plans { let course_type = calculate_course_type(&plan.nombre_plan); - tracing::debug!("Saving study plan: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios); + tracing::debug!("Guardando plan de estudios: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios); - // Mirror SAM structure in PostgreSQL using SAM-native column names. + // Reflejar la estructura SAM en PostgreSQL usando nombres de columna nativos de SAM. sqlx::query( r#" INSERT INTO sam_study_plans (organization_id, idPlanDeEstudios, Nombre, Activo) @@ -195,7 +195,7 @@ pub async fn save_mysql_courses_and_plans( .bind(&plan.nombre_plan) .execute(pool) .await - .map_err(|e| format!("Failed to save SAM study plan mirror: {}", e))?; + .map_err(|e| format!("Error al guardar el reflejo del plan de estudios SAM: {}", e))?; sqlx::query( r#" @@ -213,15 +213,15 @@ pub async fn save_mysql_courses_and_plans( .bind(&course_type) .execute(pool) .await - .map_err(|e| format!("Failed to save study plan: {}", e))?; + .map_err(|e| format!("Error al guardar el plan de estudios: {}", e))?; } - // Save courses + // Guardar cursos for course in courses { - // Determine course_type from duration (40h = regular, 80h = intensive) + // Determinar el course_type a partir de la duración (40h = regular, 80h = intensivo) let course_type = calculate_course_type_from_duration(course.duracion); let level_calculated = calculate_course_level(course.nivel_curso); - tracing::debug!("Saving course: {} (ID: {}, Plan ID: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios); + tracing::debug!("Guardando curso: {} (ID: {}, ID de Plan: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios); sqlx::query( r#" @@ -246,9 +246,9 @@ pub async fn save_mysql_courses_and_plans( .bind(course.duracion) .execute(pool) .await - .map_err(|e| format!("Failed to save SAM course mirror: {}", e))?; + .map_err(|e| format!("Error al guardar el reflejo del curso SAM: {}", e))?; - // Get study_plan_id from mysql_study_plans + // Obtener study_plan_id de mysql_study_plans let study_plan_id: i32 = sqlx::query_scalar( "SELECT id FROM mysql_study_plans WHERE mysql_id = $1 AND organization_id = $2" ) @@ -256,7 +256,7 @@ pub async fn save_mysql_courses_and_plans( .bind(org_id) .fetch_one(pool) .await - .map_err(|e| format!("Failed to find study plan: {}", e))?; + .map_err(|e| format!("Error al encontrar el plan de estudios: {}", e))?; sqlx::query( r#" @@ -284,10 +284,10 @@ pub async fn save_mysql_courses_and_plans( .bind(&level_calculated) .execute(pool) .await - .map_err(|e| format!("Failed to save course: {}", e))?; + .map_err(|e| format!("Error al guardar el curso: {}", e))?; } - tracing::info!("Successfully saved {} study plans and {} courses", plans_count, courses_count); + tracing::info!("Se guardaron con éxito {} planes de estudio y {} cursos", plans_count, courses_count); Ok(()) } @@ -320,9 +320,9 @@ fn calculate_course_level(nivel: Option) -> String { } } -// ==================== Create ==================== +// ==================== Crear ==================== -/// POST /api/question-bank - Create a new question in the bank +/// POST /api/question-bank - Crear una nueva pregunta en el banco pub async fn create_question( Org(org_ctx): Org, claims: Claims, @@ -382,9 +382,9 @@ pub async fn create_question( Ok(Json(question)) } -// ==================== List ==================== +// ==================== Listar ==================== -/// GET /api/question-bank - List questions with filters +/// GET /api/question-bank - Listar preguntas con filtros pub async fn list_questions( Org(org_ctx): Org, State(pool): State, @@ -396,7 +396,7 @@ pub async fn list_questions( && filters.search.is_none() && filters.has_audio.is_none() { - // No filters - simple query + // Sin filtros - consulta simple sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false ORDER BY created_at DESC", @@ -504,9 +504,9 @@ pub async fn list_questions( Ok(Json(questions)) } -// ==================== Get ==================== +// ==================== Obtener ==================== -/// GET /api/question-bank/{id} - Get a single question +/// GET /api/question-bank/{id} - Obtener una sola pregunta pub async fn get_question( Org(org_ctx): Org, Path(id): Path, @@ -528,16 +528,16 @@ pub async fn get_question( .fetch_one(&pool) .await .map_err(|e| match e { - sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()), + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; Ok(Json(question)) } -// ==================== Update ==================== +// ==================== Actualizar ==================== -/// PUT /api/question-bank/{id} - Update a question +/// PUT /api/question-bank/{id} - Actualizar una pregunta pub async fn update_question( Org(org_ctx): Org, Path(id): Path, @@ -599,16 +599,16 @@ pub async fn update_question( .fetch_one(&pool) .await .map_err(|e| match e { - sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()), + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; Ok(Json(question)) } -// ==================== Delete ==================== +// ==================== Eliminar ==================== -/// DELETE /api/question-bank/{id} - Delete (archive) a question +/// DELETE /api/question-bank/{id} - Eliminar (archivar) una pregunta pub async fn delete_question( Org(org_ctx): Org, Path(id): Path, @@ -628,15 +628,15 @@ pub async fn delete_question( .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())); + return Err((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string())); } Ok(StatusCode::NO_CONTENT) } -// ==================== Import from MySQL ==================== +// ==================== Importar desde MySQL ==================== -/// POST /api/question-bank/import-mysql - Import questions from MySQL question bank +/// POST /api/question-bank/import-mysql - Importar preguntas desde el banco de preguntas de MySQL pub async fn import_from_mysql( Org(org_ctx): Org, claims: Claims, @@ -645,10 +645,10 @@ pub async fn import_from_mysql( ) -> Result>, (StatusCode, String)> { use serde_json::json; - // Connect to MySQL + // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; - // Fetch all study plans and courses from MySQL to sync them + // Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos let mysql_plans: Vec = sqlx::query_as( r#" SELECT DISTINCT @@ -662,13 +662,13 @@ pub async fn import_from_mysql( .fetch_all(&mysql_pool) .await .map_err(|e| { - let error_msg = format!("Failed to fetch: {}", e); - tracing::error!("MySQL Error: {}", error_msg); - tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)"); + let error_msg = format!("Error al obtener: {}", e); + tracing::error!("Error de MySQL: {}", error_msg); + tracing::error!("Verifique los nombres de las columnas en su base de datos MySQL (tablas plandeestudios, curso)"); (StatusCode::INTERNAL_SERVER_ERROR, error_msg) })?; - tracing::info!("Fetched {} study plans from MySQL", mysql_plans.len()); + tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len()); let mysql_courses: Vec = sqlx::query_as( r#" @@ -688,19 +688,19 @@ pub async fn import_from_mysql( ) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; - tracing::info!("Fetched {} courses from MySQL", mysql_courses.len()); + tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len()); - // Save plans and courses to PostgreSQL - tracing::info!("Saving plans and courses to PostgreSQL..."); + // Guardar planes y cursos en PostgreSQL + tracing::info!("Guardando planes y cursos en PostgreSQL..."); save_mysql_courses_and_plans(&pool, org_ctx.id, mysql_plans, mysql_courses) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save courses/plans: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?; - // Fetch questions from MySQL + // Obtener preguntas de MySQL let mysql_questions: Vec = if payload.import_all.unwrap_or(false) { - // Import ALL questions (no limit) + // Importar TODAS las preguntas (sin límite) sqlx::query_as( r#" SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo, @@ -716,9 +716,9 @@ pub async fn import_from_mysql( ) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))? } else if let Some(course_id) = payload.mysql_course_id { - // Import all questions for a specific course (no limit) + // Importar todas las preguntas para un curso específico (sin límite) sqlx::query_as( r#" SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo, @@ -735,9 +735,9 @@ pub async fn import_from_mysql( .bind(course_id) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))? } else if let Some(question_ids) = payload.question_ids { - // Fetch specific question IDs - use simple approach + // Obtener IDs de preguntas específicas - usar enfoque simple let mut imported_questions: Vec = vec![]; for q_id in question_ids { @@ -756,10 +756,10 @@ pub async fn import_from_mysql( .bind(q_id) .fetch_optional(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch question {}: {}", q_id, e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener la pregunta {}: {}", q_id, e)))?; if let Some(question) = mq { - // Map MySQL question type to platform question type + // Mapear el tipo de pregunta de MySQL al tipo de pregunta de la plataforma let question_type = map_mysql_question_type(question.id_tipo_pregunta, None); let options = if question_type == QuestionBankType::MultipleChoice { @@ -804,7 +804,7 @@ pub async fn import_from_mysql( .bind(&source_metadata) .fetch_one(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to import question {}: {}", question.id_pregunta, e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", question.id_pregunta, e)))?; imported_questions.push(qb); } @@ -813,21 +813,21 @@ pub async fn import_from_mysql( mysql_pool.close().await; return Ok(Json(imported_questions)); } else { - return Err((StatusCode::BAD_REQUEST, "Must provide course_id, question_ids, or import_all".to_string())); + return Err((StatusCode::BAD_REQUEST, "Debe proporcionar course_id, question_ids o import_all".to_string())); }; mysql_pool.close().await; if mysql_questions.is_empty() { - return Err((StatusCode::NOT_FOUND, "No questions found in MySQL to import".to_string())); + return Err((StatusCode::NOT_FOUND, "No se encontraron preguntas en MySQL para importar".to_string())); } - // Import questions into PostgreSQL + // Importar preguntas a PostgreSQL let mut imported_questions: Vec = vec![]; let mut skipped_count = 0; for mq in mysql_questions { - // Check if question already imported + // Comprobar si la pregunta ya ha sido importada let exists: (bool,) = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2)" ) @@ -835,17 +835,17 @@ pub async fn import_from_mysql( .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to check existing: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al comprobar existencia: {}", e)))?; if exists.0 { skipped_count += 1; - continue; // Skip already imported question + continue; // Omitir pregunta ya importada } - // Map MySQL question type to platform question type + // Mapear el tipo de pregunta de MySQL al tipo de pregunta de la plataforma let question_type = map_mysql_question_type(mq.id_tipo_pregunta, None); - // Create options for multiple choice (if applicable) + // Crear opciones para opción múltiple (si corresponde) let options = if question_type == QuestionBankType::MultipleChoice { Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"])) } else if question_type == QuestionBankType::TrueFalse { @@ -885,27 +885,27 @@ pub async fn import_from_mysql( .bind(&mq.descripcion) .bind(&question_type) .bind(&options) - .bind(&serde_json::Value::Null) // Correct answer to be filled by user + .bind(&serde_json::Value::Null) // Respuesta correcta para que el usuario la complete .bind(&source_metadata) .bind(mq.id_pregunta) .bind(mq.id_cursos) .fetch_one(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to import question {}: {}", mq.id_pregunta, e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", mq.id_pregunta, e)))?; imported_questions.push(question); } - tracing::info!("Imported {} questions from MySQL (skipped {} already imported)", imported_questions.len(), skipped_count); + tracing::info!("Se importaron {} preguntas de MySQL (se omitieron {} ya importadas)", imported_questions.len(), skipped_count); Ok(Json(imported_questions)) } -// ==================== Helpers ==================== +// ==================== Auxiliares ==================== fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> QuestionBankType { - // Map MySQL question types to platform types - // First try by name, then by ID as fallback + // Mapear tipos de preguntas de MySQL a tipos de plataforma + // Primero intentar por nombre, luego por ID como respaldo if let Some(nombre) = tipo_nombre { let nombre_lower = nombre.to_lowercase(); if nombre_lower.contains("selecc") || nombre_lower.contains("múltiple") || nombre_lower.contains("multiple") || nombre_lower.contains("alternativa") { @@ -929,13 +929,13 @@ fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> Questi if nombre_lower.contains("corta") || nombre_lower.contains("short") || nombre_lower.contains("texto") { return QuestionBankType::ShortAnswer; } - // Audio type in MySQL is typically listening comprehension with multiple choice answers + // El tipo audio en MySQL suele ser comprensión auditiva con respuestas de opción múltiple if nombre_lower.contains("audio") || nombre_lower.contains("listening") { return QuestionBankType::MultipleChoice; } } - // Fallback to ID mapping + // Respaldo al mapeo por ID match mysql_type { 1 => QuestionBankType::MultipleChoice, // Alternativa 2 => QuestionBankType::MultipleChoice, // Audio (listening comprehension) @@ -948,7 +948,7 @@ fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> Questi } } -/// Parse MySQL answers JSON and extract options and correct answer +/// Analizar el JSON de respuestas de MySQL y extraer opciones y respuesta correcta fn parse_mysql_answers( answers_json: Option<&str>, question_type: QuestionBankType, @@ -956,13 +956,13 @@ fn parse_mysql_answers( if let Some(json_str) = answers_json { if let Ok(answers) = serde_json::from_str::>(json_str) { if !answers.is_empty() { - // Extract options (all answer texts) + // Extraer opciones (todos los textos de respuesta) let options: Vec = answers .iter() .filter_map(|a| a.get("texto").and_then(|t| t.as_str()).map(String::from)) .collect(); - // Extract correct answer(s) + // Extraer respuesta(s) correcta(s) let correct_indices: Vec = answers .iter() .enumerate() @@ -978,16 +978,16 @@ fn parse_mysql_answers( let correct_answer = if question_type == QuestionBankType::TrueFalse || question_type == QuestionBankType::MultipleChoice { - // For multiple choice, store index/indices + // Para opción múltiple, guardar índice/índices if correct_indices.len() == 1 { Some(serde_json::json!(correct_indices[0])) } else if correct_indices.len() > 1 { Some(serde_json::json!(correct_indices)) } else { - Some(serde_json::json!(0)) // Default to first + Some(serde_json::json!(0)) // Por defecto a la primera } } else { - // For other types, store the text + // Para otros tipos, guardar el texto correct_indices.first() .and_then(|&i| answers.get(i)) .and_then(|a| a.get("texto").and_then(|t| t.as_str())) @@ -999,7 +999,7 @@ fn parse_mysql_answers( } } - // Default values if no answers provided + // Valores por defecto si no se proporcionan respuestas let default_options = match question_type { QuestionBankType::MultipleChoice => Some(serde_json::json!(["Opción A", "Opción B", "Opción C", "Opción D"])), QuestionBankType::TrueFalse => Some(serde_json::json!(["Verdadero", "Falso"])), @@ -1009,17 +1009,17 @@ fn parse_mysql_answers( (default_options, Some(serde_json::json!(0))) } -// ==================== MySQL Integration ==================== +// ==================== Integración con MySQL ==================== -/// GET /api/question-bank/mysql-courses - List courses from MySQL for import +/// GET /api/question-bank/mysql-courses - Listar cursos de MySQL para importación pub async fn list_mysql_courses( Org(_org_ctx): Org, State(_pool): State, ) -> Result>, (StatusCode, String)> { - // Connect to MySQL + // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; - // Fetch courses with their plan names + // Obtener cursos con sus nombres de plan let courses: Vec = sqlx::query_as( r#" SELECT DISTINCT @@ -1038,19 +1038,19 @@ pub async fn list_mysql_courses( ) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; mysql_pool.close().await; Ok(Json(courses)) } -/// GET /api/question-bank/mysql-plans - Get all study plans from PostgreSQL (imported from MySQL) +/// GET /api/question-bank/mysql-plans - Obtener todos los planes de estudio de PostgreSQL (importados de MySQL) pub async fn get_mysql_plans( Org(org_ctx): Org, State(pool): State, ) -> Result>, (StatusCode, String)> { - // Read from SAM mirror in PostgreSQL with SAM-native fields. + // Leer del reflejo SAM en PostgreSQL con campos nativos de SAM. let mut plans: Vec = sqlx::query_as( r#" SELECT @@ -1064,9 +1064,9 @@ pub async fn get_mysql_plans( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes: {}", e)))?; - // Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror. + // Respaldo compatible con versiones anteriores: si el reflejo SAM está vacío, usar el reflejo de metadatos heredado. if plans.is_empty() { plans = sqlx::query_as( r#" @@ -1081,10 +1081,10 @@ pub async fn get_mysql_plans( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy plans: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes heredados: {}", e)))?; } - // Last-resort auto-sync: if still empty, pull metadata from MySQL and persist it. + // Auto-sincronización de último recurso: si sigue vacío, obtener metadatos de MySQL y persistirlos. if plans.is_empty() { match connect_mysql_pool("MYSQL_DATABASE_URL").await { Ok(mysql_pool) => { @@ -1123,21 +1123,21 @@ pub async fn get_mysql_plans( match (mysql_plans, mysql_courses) { (Ok(p), Ok(c)) => { if let Err(err) = save_mysql_courses_and_plans(&pool, org_ctx.id, p, c).await { - tracing::warn!("Auto-sync MySQL metadata failed: {}", err); + tracing::warn!("Fallo la auto-sincronización de metadatos de MySQL: {}", err); } } - (Err(e), _) => tracing::warn!("Auto-sync plans query failed: {}", e), - (_, Err(e)) => tracing::warn!("Auto-sync courses query failed: {}", e), + (Err(e), _) => tracing::warn!("Fallo la consulta de planes de auto-sincronización: {}", e), + (_, Err(e)) => tracing::warn!("Fallo la consulta de cursos de auto-sincronización: {}", e), } mysql_pool.close().await; } Err(e) => { - tracing::warn!("Auto-sync could not connect to MySQL: {:?}", e); + tracing::warn!("La auto-sincronización no pudo conectarse a MySQL: {:?}", e); } } - // Reload plans after auto-sync attempt. + // Recargar planes tras el intento de auto-sincronización. plans = sqlx::query_as( r#" SELECT @@ -1174,13 +1174,13 @@ pub async fn get_mysql_plans( Ok(Json(plans)) } -/// GET /api/question-bank/mysql-courses - Get courses filtered by plan from PostgreSQL +/// GET /api/question-bank/mysql-courses - Obtener cursos filtrados por plan de PostgreSQL pub async fn get_mysql_courses_by_plan( Org(org_ctx): Org, State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { - // Read from SAM mirror in PostgreSQL with SAM-native fields. + // Leer del reflejo SAM en PostgreSQL con campos nativos de SAM. let mut courses: Vec = sqlx::query_as( r#" SELECT @@ -1205,9 +1205,9 @@ pub async fn get_mysql_courses_by_plan( .bind(filters.plan_id) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; - // Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror. + // Respaldo compatible con versiones anteriores: si el reflejo SAM está vacío, usar el reflejo de metadatos heredado. if courses.is_empty() { courses = sqlx::query_as( r#" @@ -1231,7 +1231,7 @@ pub async fn get_mysql_courses_by_plan( .bind(filters.plan_id) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy courses: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos heredados: {}", e)))?; } Ok(Json(courses)) @@ -1255,15 +1255,15 @@ pub struct MySqlCourseInfo { pub nivel_curso: Option, pub id_plan_de_estudios: i32, pub nombre_plan: String, - pub duracion: Option, // Duration in hours (40=regular, 80=intensive) - MySQL float type + pub duracion: Option, // Duración en horas (40=regular, 80=intensivo) - Tipo float de MySQL } #[derive(Debug, Serialize, Deserialize)] pub struct ImportAllFromMySQLPayload { - pub import_metadata_only: Option, // Only import metadata (courses/plans), not questions + pub import_metadata_only: Option, // Solo importar metadatos (cursos/planes), no preguntas } -/// POST /api/question-bank/import-mysql-all - Import ALL questions from MySQL (bulk import) +/// POST /api/question-bank/import-mysql-all - Importar TODAS las preguntas de MySQL (importación masiva) pub async fn import_all_from_mysql( Org(org_ctx): Org, claims: Claims, @@ -1272,7 +1272,7 @@ pub async fn import_all_from_mysql( ) -> Result, (StatusCode, String)> { use serde_json::json; - // Parse optional JSON body + // Analizar el cuerpo JSON opcional let import_metadata_only = if body.trim().is_empty() { false } else { @@ -1281,10 +1281,10 @@ pub async fn import_all_from_mysql( .unwrap_or(false) }; - // Connect to MySQL + // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; - // Fetch all study plans and courses from MySQL to sync them + // Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos let mysql_plans: Vec = sqlx::query_as( r#" SELECT DISTINCT @@ -1298,13 +1298,13 @@ pub async fn import_all_from_mysql( .fetch_all(&mysql_pool) .await .map_err(|e| { - let error_msg = format!("Failed to fetch: {}", e); - tracing::error!("MySQL Error: {}", error_msg); - tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)"); + let error_msg = format!("Error al obtener: {}", e); + tracing::error!("Error de MySQL: {}", error_msg); + tracing::error!("Verifique los nombres de las columnas en su base de datos MySQL (tablas plandeestudios, curso)"); (StatusCode::INTERNAL_SERVER_ERROR, error_msg) })?; - tracing::info!("Fetched {} study plans from MySQL", mysql_plans.len()); + tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len()); let mysql_courses: Vec = sqlx::query_as( r#" @@ -1324,17 +1324,17 @@ pub async fn import_all_from_mysql( ) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; - tracing::info!("Fetched {} courses from MySQL", mysql_courses.len()); + tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len()); - // Save plans and courses to PostgreSQL - tracing::info!("Saving plans and courses to PostgreSQL..."); + // Guardar planes y cursos en PostgreSQL + tracing::info!("Guardando planes y cursos en PostgreSQL..."); save_mysql_courses_and_plans(&pool, org_ctx.id, mysql_plans.clone(), mysql_courses.clone()) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save courses/plans: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?; - // If only metadata import is requested, return early + // Si solo se solicita la importación de metadatos, salir antes if import_metadata_only { return Ok(Json(json!({ "success": true, @@ -1353,7 +1353,7 @@ pub async fn import_all_from_mysql( }))); } - // Fetch ALL questions from MySQL with answers (using JSON aggregation for answers) + // Obtener TODAS las preguntas de MySQL con respuestas (usando agregación JSON para las respuestas) let mysql_questions: Vec = sqlx::query_as( r#" SELECT @@ -1393,7 +1393,7 @@ pub async fn import_all_from_mysql( ) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?; mysql_pool.close().await; @@ -1403,17 +1403,17 @@ pub async fn import_all_from_mysql( "imported": 0, "skipped": 0, "updated": 0, - "error": "No questions found in MySQL" + "error": "No se encontraron preguntas en MySQL" }))); } - // Import questions into PostgreSQL + // Importar preguntas a PostgreSQL let mut imported_count = 0; let mut skipped_count = 0; let mut updated_count = 0; for mq in mysql_questions { - // Check if question already exists + // Comprobar si la pregunta ya existe let existing: Option<(Uuid, bool)> = sqlx::query_as( "SELECT id, is_active FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2" ) @@ -1421,13 +1421,13 @@ pub async fn import_all_from_mysql( .bind(org_ctx.id) .fetch_optional(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Check failed: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Fallo en la comprobación: {}", e)))?; match existing { Some((id, is_active)) => { - // Question exists - update if it was inactive or has new data + // La pregunta existe - actualizar si estaba inactiva o tiene nuevos datos if !is_active { - // Reactivate and update + // Reactivar y actualizar let _ = sqlx::query( "UPDATE question_bank SET is_active = true, question_text = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2" ) @@ -1438,14 +1438,14 @@ pub async fn import_all_from_mysql( .await; updated_count += 1; } else { - skipped_count += 1; // Already exists and active, skip + skipped_count += 1; // Ya existe y está activa, omitir } } None => { - // New question - insert with answers from MySQL + // Nueva pregunta - insertar con respuestas de MySQL let question_type = map_mysql_question_type(mq.id_tipo_pregunta, mq.tipo_pregunta_nombre.as_deref()); - // Parse answers from MySQL + // Analizar respuestas de MySQL let (options, correct_answer) = parse_mysql_answers(mq.respuestas_json.as_deref(), question_type); let has_answers = mq.respuestas_json.is_some(); @@ -1495,7 +1495,7 @@ pub async fn import_all_from_mysql( } tracing::info!( - "Bulk import from MySQL: {} imported, {} skipped, {} updated", + "Importación masiva desde MySQL: {} importadas, {} omitidas, {} actualizadas", imported_count, skipped_count, updated_count @@ -1544,7 +1544,7 @@ pub struct MySqlQuestionFull { pub nivel_curso: Option, pub id_plan_de_estudios: i32, pub plan_nombre: String, - pub respuestas_json: Option, // JSON array de respuestas + pub respuestas_json: Option, // Array JSON de respuestas pub tipo_pregunta_nombre: Option, // Nombre del tipo de pregunta } @@ -1560,7 +1560,7 @@ struct MySqlQuestion { plan_nombre: String, } -// ==================== AI Generation ==================== +// ==================== Generación por IA ==================== #[derive(Debug, Deserialize)] pub struct AIGenerateQuestionPayload { @@ -1578,7 +1578,7 @@ pub struct AIQuestionResponse { pub explanation: String, } -/// POST /question-bank/ai-generate - Generate question options/answers using AI (Ollama only) +/// POST /question-bank/ai-generate - Generar opciones/respuestas de preguntas usando IA (solo Ollama) pub async fn ai_generate_question( _org_ctx: Org, _claims: Claims, @@ -1587,11 +1587,11 @@ pub async fn ai_generate_question( use std::env; use std::time::Duration; - let question_text = payload.question_text.unwrap_or_else(|| "English grammar question".to_string()); + let question_text = payload.question_text.unwrap_or_else(|| "Pregunta de gramática inglesa".to_string()); let difficulty = payload.difficulty.unwrap_or_else(|| "medium".to_string()); let skill = payload.skill.unwrap_or_else(|| "grammar".to_string()); - // Build prompt for AI + // Construir prompt para la IA let system_prompt = format!( r#"You are an expert English Teacher creating quiz questions. @@ -1610,7 +1610,7 @@ pub async fn ai_generate_question( question_text, difficulty, skill ); - // Call Ollama AI with extended timeout + // Llamar a Ollama AI con tiempo de espera extendido let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); let url = format!("{}/api/chat", base_url); @@ -1628,37 +1628,37 @@ pub async fn ai_generate_question( "model": model, "messages": [ { "role": "system", "content": system_prompt }, - { "role": "user", "content": "Generate the question in JSON format" } + { "role": "user", "content": "Generar la pregunta en formato JSON" } ], "stream": false, "format": "json" })) .send() .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La solicitud de IA falló: {}", e)))?; if !response.status().is_success() { - return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", response.status()))); + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", response.status()))); } let result: serde_json::Value = response.json().await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?; - // Extract content from Ollama response + // Extraer contenido de la respuesta de Ollama let content = result .get("message") .and_then(|m| m.get("content")) .and_then(|c| c.as_str()) - .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response format".to_string()))?; + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Formato de respuesta de IA no válido".to_string()))?; - // Parse AI response as JSON + // Analizar la respuesta de la IA como JSON let ai_question: AIQuestionResponse = serde_json::from_str(content) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse question JSON: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar el JSON de la pregunta: {}", e)))?; Ok(Json(ai_question)) } -// ==================== Import Courses from MySQL ==================== +// ==================== Importar Cursos de MySQL ==================== #[derive(Debug, Serialize, Deserialize)] pub struct ImportCourseFromMySQLPayload { @@ -1678,7 +1678,7 @@ pub struct ImportCourseResult { pub message: String, } -/// POST /api/question-bank/import-course-mysql - Import a course from MySQL with basic structure +/// POST /api/question-bank/import-course-mysql - Importar un curso de MySQL con estructura básica pub async fn import_course_from_mysql( Org(org_ctx): Org, claims: Claims, @@ -1687,10 +1687,10 @@ pub async fn import_course_from_mysql( ) -> Result, (StatusCode, String)> { use common::models::Course; - // Connect to MySQL + // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; - // Fetch course info from MySQL + // Obtener información del curso de MySQL let mysql_course: MySqlCourseInfo = sqlx::query_as( r#" SELECT @@ -1710,25 +1710,25 @@ pub async fn import_course_from_mysql( .await .map_err(|e| { if let sqlx::Error::RowNotFound = e { - (StatusCode::NOT_FOUND, format!("Course with ID {} not found in MySQL", payload.mysql_course_id)) + (StatusCode::NOT_FOUND, format!("Curso con ID {} no encontrado en MySQL", payload.mysql_course_id)) } else { - (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch course from MySQL: {}", e)) + (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el curso de MySQL: {}", e)) } })?; - tracing::info!("Importing course from MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos); + tracing::info!("Importando curso de MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos); - // Start transaction + // Iniciar transacción let mut tx = pool.begin().await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to start transaction: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al iniciar la transacción: {}", e)))?; - // Determine course type and level for structure generation + // Determinar el tipo y nivel del curso para la generación de la estructura let course_type = calculate_course_type_from_duration(mysql_course.duracion); let level = calculate_course_level(mysql_course.nivel_curso); tracing::info!("Course type: {}, Level: {}", course_type, level); - // Create course in PostgreSQL + // Crear el curso en PostgreSQL let course_title = payload.title.unwrap_or_else(|| format!("{} ({})", mysql_course.nombre_curso, mysql_course.nombre_plan)); let pacing_mode = payload.pacing_mode.unwrap_or_else(|| "self_paced".to_string()); let description = payload.description.unwrap_or_else(|| format!("Curso importado desde MySQL - Plan: {}", mysql_course.nombre_plan)); @@ -1756,11 +1756,11 @@ pub async fn import_course_from_mysql( .bind(mysql_course.id_cursos) .fetch_one(&mut *tx) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create course: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el curso: {}", e)))?; - tracing::info!("Created course in PostgreSQL: {}", new_course.id); + tracing::info!("Curso creado en PostgreSQL: {}", new_course.id); - // Generate basic course structure based on course type and level + // Generar estructura básica del curso basada en su tipo y nivel let (modules_count, lessons_count) = generate_course_structure( &mut tx, new_course.id, @@ -1772,12 +1772,12 @@ pub async fn import_course_from_mysql( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - // Commit transaction + // Confirmar transacción tx.commit().await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to commit transaction: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al confirmar la transacción: {}", e)))?; tracing::info!( - "Successfully imported course {} with {} modules and {} lessons", + "Curso {} importado con éxito con {} módulos y {} lecciones", new_course.id, modules_count, lessons_count @@ -1794,7 +1794,7 @@ pub async fn import_course_from_mysql( })) } -/// Generate basic course structure based on type and level +/// Generar estructura básica del curso según tipo y nivel async fn generate_course_structure<'a>( tx: &mut sqlx::Transaction<'a, sqlx::Postgres>, course_id: Uuid, @@ -1803,9 +1803,9 @@ async fn generate_course_structure<'a>( level: &str, course_name: &str, ) -> Result<(i32, i32), String> { - // Define module structure based on course type - // Regular (40h): 4 modules - // Intensive (80h): 8 modules + // Definir la estructura de módulos basada en el tipo de curso + // Regular (40h): 4 módulos + // Intensivo (80h): 8 módulos let modules_config = match course_type { "intensive" => vec![ ("Fundamentos Básicos", 6), @@ -1829,7 +1829,7 @@ async fn generate_course_structure<'a>( let mut total_lessons = 0; for (module_idx, (module_name, lessons_count)) in modules_config.iter().enumerate() { - // Create module + // Crear módulo let module: common::models::Module = sqlx::query_as( r#" INSERT INTO modules (course_id, organization_id, title, position) @@ -1843,16 +1843,16 @@ async fn generate_course_structure<'a>( .bind(module_idx as i32) .fetch_one(&mut **tx) .await - .map_err(|e| format!("Failed to create module {}: {}", module_idx + 1, e))?; + .map_err(|e| format!("Error al crear el módulo {}: {}", module_idx + 1, e))?; total_modules += 1; - // Create lessons for this module + // Crear lecciones para este módulo for lesson_idx in 0..*lessons_count { let lesson_position = (module_idx * lessons_count + lesson_idx) as i32; let lesson_title = format!("Lección {}.{}", module_idx + 1, lesson_idx + 1); - // Determine content type based on position (rotate through types) + // Determinar el tipo de contenido basado en la posición (rotar por tipos) let content_types = ["video", "document", "interactive", "quiz"]; let content_type = content_types[lesson_idx % content_types.len()]; @@ -1868,13 +1868,13 @@ async fn generate_course_structure<'a>( .bind(org_id) .bind(&lesson_title) .bind(content_type) - .bind("") // Empty content URL (to be filled by instructor) + .bind("") // URL de contenido vacía (para que el instructor la complete) .bind(lesson_position) - .bind(lesson_idx % 4 == 3) // Every 4th lesson is graded (quiz) + .bind(lesson_idx % 4 == 3) // Cada 4ª lección se califica (cuestionario) .bind(format!("Contenido de la lección: {}", lesson_title)) .execute(&mut **tx) .await - .map_err(|e| format!("Failed to create lesson {}: {}", lesson_position, e))?; + .map_err(|e| format!("Error al crear la lección {}: {}", lesson_position, e))?; total_lessons += 1; } @@ -1883,7 +1883,7 @@ async fn generate_course_structure<'a>( Ok((total_modules, total_lessons)) } -// ==================== Import from SAM Diagnostico ==================== +// ==================== Importar desde SAM Diagnóstico ==================== /// Row retornada por GROUP BY sobre las tablas de SAM_diagnostico #[derive(Debug, sqlx::FromRow)] @@ -2011,7 +2011,7 @@ pub async fn import_from_sam_diagnostico( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to check duplicate: {}", e), + format!("Error al comprobar duplicado: {}", e), ) })?; @@ -2083,7 +2083,7 @@ pub async fn import_from_sam_diagnostico( mysql_pool.close().await; tracing::info!( - "SAM_diagnostico import done: imported={} skipped={} errors={}", + "Importación de SAM_diagnostico finalizada: imported={} skipped={} errors={}", total_imported, total_skipped, errors.len() ); diff --git a/services/cms-service/src/handlers_rubrics.rs b/services/cms-service/src/handlers_rubrics.rs index c941316..bed64a7 100644 --- a/services/cms-service/src/handlers_rubrics.rs +++ b/services/cms-service/src/handlers_rubrics.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Row}; use uuid::Uuid; -// ==================== Payload Structs ==================== +// ==================== Estructuras de Carga Útil (Payload) ==================== #[derive(Debug, Deserialize)] pub struct CreateRubricPayload { @@ -97,9 +97,9 @@ pub struct CriterionWithLevels { pub levels: Vec, } -// ==================== Rubric Management ==================== +// ==================== Gestión de Rúbricas ==================== -/// Create a new rubric +/// Crear una nueva rúbrica pub async fn create_rubric( Org(org_ctx): Org, claims: Claims, @@ -126,7 +126,7 @@ pub async fn create_rubric( Ok(Json(rubric)) } -/// List all rubrics for a course +/// Listar todas las rúbricas de un curso pub async fn list_course_rubrics( Org(org_ctx): Org, State(pool): State, @@ -149,7 +149,7 @@ pub async fn list_course_rubrics( Ok(Json(rubrics)) } -/// Get a rubric with all criteria and levels +/// Obtener una rúbrica con todos los criterios y niveles pub async fn get_rubric_with_details( Org(org_ctx): Org, State(pool): State, @@ -168,7 +168,7 @@ pub async fn get_rubric_with_details( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?; // Get criteria let criteria: Vec = sqlx::query_as( @@ -209,7 +209,7 @@ pub async fn get_rubric_with_details( })) } -/// Update a rubric +/// Actualizar una rúbrica pub async fn update_rubric( Org(org_ctx): Org, State(pool): State, @@ -233,12 +233,12 @@ pub async fn update_rubric( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?; Ok(Json(rubric)) } -/// Delete a rubric +/// Eliminar una rúbrica pub async fn delete_rubric( Org(org_ctx): Org, State(pool): State, @@ -252,15 +252,15 @@ pub async fn delete_rubric( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { - return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string())); } Ok(StatusCode::NO_CONTENT) } -// ==================== Criterion Management ==================== +// ==================== Gestión de Criterios ==================== -/// Add a criterion to a rubric +/// Añadir un criterio a una rúbrica pub async fn create_criterion( Org(org_ctx): Org, State(pool): State, @@ -274,7 +274,7 @@ pub async fn create_criterion( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?; let position = payload.position.unwrap_or(0); @@ -311,7 +311,7 @@ pub async fn create_criterion( Ok(Json(criterion)) } -/// Update a criterion +/// Actualizar un criterio pub async fn update_criterion( Org(org_ctx): Org, State(pool): State, @@ -339,7 +339,7 @@ pub async fn update_criterion( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?; // Update rubric total_points if max_points changed if payload.max_points.is_some() { @@ -360,7 +360,7 @@ pub async fn update_criterion( Ok(Json(criterion)) } -/// Delete a criterion +/// Eliminar un criterio pub async fn delete_criterion( Org(org_ctx): Org, State(pool): State, @@ -372,7 +372,7 @@ pub async fn delete_criterion( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?; let rubric_id: Uuid = criterion_row.get("rubric_id"); @@ -390,7 +390,7 @@ pub async fn delete_criterion( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { - return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string())); } // Update rubric total_points @@ -410,9 +410,9 @@ pub async fn delete_criterion( Ok(StatusCode::NO_CONTENT) } -// ==================== Performance Level Management ==================== +// ==================== Gestión de Niveles de Desempeño ==================== -/// Add a performance level to a criterion +/// Añadir un nivel de desempeño a un criterio pub async fn create_level( Org(org_ctx): Org, State(pool): State, @@ -426,7 +426,7 @@ pub async fn create_level( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?; let position = payload.position.unwrap_or(0); @@ -449,7 +449,7 @@ pub async fn create_level( Ok(Json(level)) } -/// Update a performance level +/// Actualizar un nivel de desempeño pub async fn update_level( Org(org_ctx): Org, State(pool): State, @@ -480,12 +480,12 @@ pub async fn update_level( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Nivel no encontrado".to_string()))?; Ok(Json(level)) } -/// Delete a performance level +/// Eliminar un nivel de desempeño pub async fn delete_level( Org(org_ctx): Org, State(pool): State, @@ -508,15 +508,15 @@ pub async fn delete_level( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { - return Err((StatusCode::NOT_FOUND, "Level not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Nivel no encontrado".to_string())); } Ok(StatusCode::NO_CONTENT) } -// ==================== Lesson-Rubric Association ==================== +// ==================== Asociación Lección-Rúbrica ==================== -/// Assign a rubric to a lesson +/// Asignar una rúbrica a una lección pub async fn assign_rubric_to_lesson( Org(_org_ctx): Org, State(pool): State, @@ -539,7 +539,7 @@ pub async fn assign_rubric_to_lesson( Ok(Json(lesson_rubric)) } -/// Unassign a rubric from a lesson +/// Desasignar una rúbrica de una lección pub async fn unassign_rubric_from_lesson( Org(_org_ctx): Org, State(pool): State, @@ -553,13 +553,13 @@ pub async fn unassign_rubric_from_lesson( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { - return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Vínculo lección-rúbrica no encontrado".to_string())); } Ok(StatusCode::NO_CONTENT) } -/// Get rubrics assigned to a lesson +/// Obtener rúbricas asignadas a una lección pub async fn get_lesson_rubrics( Org(org_ctx): Org, State(pool): State, diff --git a/services/cms-service/src/handlers_sam.rs b/services/cms-service/src/handlers_sam.rs index 00d731e..43aaada 100644 --- a/services/cms-service/src/handlers_sam.rs +++ b/services/cms-service/src/handlers_sam.rs @@ -1,6 +1,6 @@ -/// SAM (Sistema de Administración Académica) Integration Handlers +/// Manejadores de Integración con SAM (Sistema de Administración Académica) /// -/// This module handles synchronization between OpenCCB and the external SAM system. +/// Este módulo maneja la sincronización entre OpenCCB y el sistema externo SAM. /// SAM tables: sige_sam_v3.alumnos, sige_sam_v3.detalle_contrato use axum::{ @@ -50,27 +50,27 @@ pub struct SamStudentFilters { pub nombre: Option, } -/// ==================== SAM Sync Handlers ==================== +/// ==================== Manejadores de Sincronización SAM ==================== /// POST /api/sam/sync-students -/// Sync students from sige_sam_v3.alumnos to OpenCCB users table +/// Sincronizar estudiantes de sige_sam_v3.alumnos a la tabla de usuarios de OpenCCB pub async fn sync_sam_students( _org: Org, _claims: Claims, State(pool): State, ) -> Result, (StatusCode, String)> { - // Connect to external SAM database + // Conectar a la base de datos externa de SAM let sam_url = std::env::var("SAM_DATABASE_URL") - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?; + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?; let sam_pool = sqlx::PgPool::connect(&sam_url) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?; let mut errors = Vec::new(); let mut students_synced = 0; - // Fetch active students from SAM using generic query + // Obtener estudiantes activos de SAM usando una consulta genérica let rows = sqlx::query( r#" SELECT @@ -85,9 +85,9 @@ pub async fn sync_sam_students( ) .fetch_all(&sam_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener estudiantes de SAM: {}", e)))?; - // Convert to SamStudentInfo + // Convertir a SamStudentInfo let sam_students: Vec = rows.iter().map(|row| { SamStudentInfo { id_alumno: row.get("id_alumno"), @@ -98,9 +98,9 @@ pub async fn sync_sam_students( } }).collect(); - // Sync each student to OpenCCB + // Sincronizar cada estudiante con OpenCCB for sam_student in sam_students { - // Check if user exists by email + // Comprobar si el usuario existe por correo electrónico let existing_user: Option<(Uuid, Option)> = sqlx::query_as( "SELECT id, sam_student_id FROM users WHERE email = $1" ) @@ -108,7 +108,7 @@ pub async fn sync_sam_students( .fetch_optional(&pool) .await .map_err(|e| { - errors.push(format!("Error checking user {}: {}", sam_student.email, e)); + errors.push(format!("Error al comprobar el usuario {}: {}", sam_student.email, e)); (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) }) .ok() @@ -116,7 +116,7 @@ pub async fn sync_sam_students( match existing_user { Some((user_id, existing_sam_id)) => { - // Update existing user with SAM info + // Actualizar usuario existente con información de SAM let update_result = sqlx::query( r#" UPDATE users @@ -136,11 +136,11 @@ pub async fn sync_sam_students( if update_result.is_ok() { students_synced += 1; } else { - errors.push(format!("Failed to update user {}", sam_student.email)); + errors.push(format!("Error al actualizar el usuario {}", sam_student.email)); } } None => { - // Create new user for SAM student + // Crear nuevo usuario para el estudiante de SAM let insert_result = sqlx::query( r#" INSERT INTO users ( @@ -157,18 +157,18 @@ pub async fn sync_sam_students( "# ) .bind(&sam_student.email) - .bind(format!("sam_managed_{}", sam_student.id_alumno)) // Placeholder password + .bind(format!("sam_managed_{}", sam_student.id_alumno)) // Contraseña provisional .bind(&sam_student.nombre) .bind("student") - .bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Default org + .bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Organización por defecto .bind(&sam_student.id_alumno) .fetch_optional(&pool) .await; match insert_result { Ok(Some(_)) => students_synced += 1, - Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)), - Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)), + Ok(None) => errors.push(format!("Error al crear el usuario para {}", sam_student.email)), + Err(e) => errors.push(format!("Error al crear el usuario {}: {}", sam_student.email, e)), } } } @@ -184,24 +184,24 @@ pub async fn sync_sam_students( } /// POST /api/sam/sync-assignments -/// Sync course assignments from sige_sam_v3.detalle_contrato +/// Sincronizar asignaciones de cursos desde sige_sam_v3.detalle_contrato pub async fn sync_sam_assignments( _org: Org, _claims: Claims, State(pool): State, ) -> Result, (StatusCode, String)> { - // Connect to external SAM database + // Conectar a la base de datos externa de SAM let sam_url = std::env::var("SAM_DATABASE_URL") - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?; + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?; let sam_pool = sqlx::PgPool::connect(&sam_url) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?; let mut errors = Vec::new(); let mut assignments_synced = 0; - // Fetch active course assignments from SAM + // Obtener asignaciones de cursos activas de SAM let rows = sqlx::query( r#" SELECT @@ -215,9 +215,9 @@ pub async fn sync_sam_assignments( ) .fetch_all(&sam_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener asignaciones: {}", e)))?; - // Convert to SamAssignmentInfo + // Convertir a SamAssignmentInfo let sam_assignments: Vec = rows.iter().map(|row| { SamAssignmentInfo { id_contrato: row.get("id_contrato"), @@ -227,10 +227,10 @@ pub async fn sync_sam_assignments( } }).collect(); - // Sync each assignment + // Sincronizar cada asignación for assignment in sam_assignments { - // Get the OpenCCB course ID from SAM course ID - // This assumes you have a mapping table or the SAM course ID matches OpenCCB course external_id + // Obtener el ID del curso de OpenCCB a partir del ID del curso SAM + // Esto asume que tienes una tabla de mapeo o que el ID del curso SAM coincide con el external_id del curso en OpenCCB let course_result = sqlx::query_as::<_, (Uuid,)>( "SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2" ) @@ -243,13 +243,13 @@ pub async fn sync_sam_assignments( Ok(Some((id,))) => Some(id), Ok(None) => None, Err(e) => { - errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e)); + errors.push(format!("Error al encontrar el curso {}: {}", assignment.id_curso_abierto, e)); continue; } }; if let Some(course_id) = course_id { - // Upsert assignment + // Actualizar o insertar asignación let upsert_result = sqlx::query( r#" INSERT INTO sam_course_assignments (sam_student_id, sam_contrato_id, course_id, is_active, synced_at) @@ -270,7 +270,7 @@ pub async fn sync_sam_assignments( if upsert_result.is_ok() { assignments_synced += 1; } else { - errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno)); + errors.push(format!("Error al sincronizar la asignación para el estudiante {}", assignment.id_alumno)); } } } @@ -285,7 +285,7 @@ pub async fn sync_sam_assignments( } /// GET /api/sam/students -/// List SAM students with optional filters +/// Listar estudiantes de SAM con filtros opcionales pub async fn list_sam_students( _org: Org, _claims: Claims, @@ -318,7 +318,7 @@ pub async fn list_sam_students( let rows = sqlx::query(&query) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch students: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los estudiantes: {}", e)))?; let students: Vec = rows.iter().map(|row| { serde_json::json!({ @@ -336,7 +336,7 @@ pub async fn list_sam_students( } /// GET /api/sam/students/{student_id}/courses -/// Get courses assigned to a specific SAM student +/// Obtener cursos asignados a un estudiante SAM específico pub async fn get_sam_student_courses( _org: Org, _claims: Claims, @@ -355,7 +355,7 @@ pub async fn get_sam_student_courses( .bind(&sam_student_id) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; let courses: Vec = rows.iter().map(|row| { serde_json::json!({ @@ -371,7 +371,7 @@ pub async fn get_sam_student_courses( } /// POST /api/sam/sync-all -/// Full synchronization: students + assignments +/// Sincronización completa: estudiantes + asignaciones pub async fn sync_all_sam( org: Org, claims: Claims, @@ -381,17 +381,17 @@ pub async fn sync_all_sam( let mut students_synced = 0; let mut assignments_synced = 0; - // Connect to external SAM database + // Conectar a la base de datos externa de SAM let sam_url = std::env::var("SAM_DATABASE_URL") - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?; + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?; let sam_pool = sqlx::PgPool::connect(&sam_url) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?; - // Sync students first + // Sincronizar estudiantes primero { - // Fetch active students from SAM + // Obtener estudiantes activos de SAM let rows = sqlx::query( r#" SELECT id_alumno, nombre, email, telefono, activo @@ -401,7 +401,7 @@ pub async fn sync_all_sam( ) .fetch_all(&sam_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener estudiantes de SAM: {}", e)))?; let sam_students: Vec = rows.iter().map(|row| { SamStudentInfo { @@ -413,7 +413,7 @@ pub async fn sync_all_sam( } }).collect(); - // Sync each student + // Sincronizar cada estudiante for sam_student in sam_students { let existing_user: Option<(Uuid, Option)> = sqlx::query_as( "SELECT id, sam_student_id FROM users WHERE email = $1" @@ -422,7 +422,7 @@ pub async fn sync_all_sam( .fetch_optional(&pool) .await .map_err(|e| { - errors.push(format!("Error checking user {}: {}", sam_student.email, e)); + errors.push(format!("Error al comprobar el usuario {}: {}", sam_student.email, e)); (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) }) .ok() @@ -439,7 +439,7 @@ pub async fn sync_all_sam( .execute(&pool) .await; - if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Failed to update user {}", sam_student.email)); } + if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Error al actualizar el usuario {}", sam_student.email)); } } None => { let insert_result = sqlx::query( @@ -456,22 +456,22 @@ pub async fn sync_all_sam( match insert_result { Ok(Some(_)) => students_synced += 1, - Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)), - Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)), + Ok(None) => errors.push(format!("Error al crear el usuario para {}", sam_student.email)), + Err(e) => errors.push(format!("Error al crear el usuario {}: {}", sam_student.email, e)), } } } } } - // Sync assignments + // Sincronizar asignaciones { let rows = sqlx::query( "SELECT id_contrato, id_alumno, id_curso_abierto, estado FROM sige_sam_v3.detalle_contrato WHERE estado = 'activo' OR estado = 'vigente'" ) .fetch_all(&sam_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener asignaciones: {}", e)))?; let sam_assignments: Vec = rows.iter().map(|row| { SamAssignmentInfo { @@ -494,7 +494,7 @@ pub async fn sync_all_sam( let course_id = match course_result { Ok(Some((id,))) => Some(id), Ok(None) => continue, - Err(e) => { errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e)); continue; } + Err(e) => { errors.push(format!("Error al encontrar el curso {}: {}", assignment.id_curso_abierto, e)); continue; } }; if let Some(course_id) = course_id { @@ -507,7 +507,7 @@ pub async fn sync_all_sam( .execute(&pool) .await; - if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno)); } + if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Error al sincronizar la asignación para el estudiante {}", assignment.id_alumno)); } } } } diff --git a/services/cms-service/src/handlers_test_templates.rs b/services/cms-service/src/handlers_test_templates.rs index 2601d60..04d7611 100644 --- a/services/cms-service/src/handlers_test_templates.rs +++ b/services/cms-service/src/handlers_test_templates.rs @@ -58,17 +58,17 @@ fn normalize_question_bank_payload_values( #[derive(Debug, Deserialize)] pub struct TestTemplateFilters { - pub mysql_course_id: Option, // Filter by MySQL course ID + pub mysql_course_id: Option, // Filtrar por ID de curso MySQL pub level: Option, pub course_type: Option, pub test_type: Option, - pub tags: Option, // Comma-separated list + pub tags: Option, // Lista separada por comas pub search: Option, } // ==================== Create ==================== -/// POST /api/test-templates - Create a new test template +/// POST /api/test-templates - Crear una nueva plantilla de test pub async fn create_test_template( Org(org_ctx): Org, claims: Claims, @@ -115,47 +115,47 @@ pub async fn create_test_template( // ==================== Read ==================== -/// GET /api/test-templates - List test templates with filters +/// GET /api/test-templates - Listar plantillas de test con filtros pub async fn list_test_templates( Org(org_ctx): Org, State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { - // Base query + // Consulta base let mut query = String::from("SELECT * FROM test_templates WHERE organization_id = $1"); let mut param_count = 1; - // Filter by mysql_course_id + // Filtrar por 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 + // Filtrar por nivel if filters.level.is_some() { param_count += 1; query.push_str(&format!(" AND level = ${}", param_count)); } - // Filter by course type + // Filtrar por tipo de curso if filters.course_type.is_some() { param_count += 1; query.push_str(&format!(" AND course_type = ${}", param_count)); } - // Filter by test type + // Filtrar por tipo de test if filters.test_type.is_some() { param_count += 1; query.push_str(&format!(" AND test_type = ${}", param_count)); } - // Filter by tags (array overlap) + // Filtrar por etiquetas (solapamiento de array) if filters.tags.is_some() { param_count += 1; query.push_str(&format!(" AND tags && ${}", param_count)); } - // Search in name and description + // Buscar en nombre y descripción if filters.search.is_some() { param_count += 1; query.push_str(&format!( @@ -166,7 +166,7 @@ pub async fn list_test_templates( query.push_str(" ORDER BY created_at DESC"); - // Build query with dynamic binds + // Construir consulta con binds dinámicos let mut sql_query = sqlx::query_as::<_, TestTemplate>(&query).bind(org_ctx.id); if let Some(mysql_course_id) = &filters.mysql_course_id { @@ -203,13 +203,13 @@ pub async fn list_test_templates( Ok(Json(templates)) } -/// GET /api/test-templates/:id - Get a specific test template with questions +/// GET /api/test-templates/:id - Obtener una plantilla de test específica con preguntas pub async fn get_test_template( Org(org_ctx): Org, Path(template_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { - // Get template + // Obtener plantilla let template: TestTemplate = sqlx::query_as( r#" SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type, @@ -224,11 +224,11 @@ pub async fn get_test_template( .fetch_one(&pool) .await .map_err(|e| match e { - sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()), + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; - // Get sections + // Obtener secciones let sections: Vec = sqlx::query_as( r#" SELECT id, template_id, title, description, section_order, points, instructions, section_data, created_at @@ -242,7 +242,7 @@ pub async fn get_test_template( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Get questions + // Obtener preguntas let questions: Vec = sqlx::query_as( r#" SELECT id, template_id, section_id, question_order, question_type, question_text, @@ -266,7 +266,7 @@ pub async fn get_test_template( // ==================== Update ==================== -/// PUT /api/test-templates/:id - Update a test template +/// PUT /api/test-templates/:id - Actualizar una plantilla de test pub async fn update_test_template( Org(org_ctx): Org, Path(template_id): Path, @@ -316,7 +316,7 @@ pub async fn update_test_template( .fetch_one(&pool) .await .map_err(|e| match e { - sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()), + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; @@ -325,7 +325,7 @@ pub async fn update_test_template( // ==================== Delete ==================== -/// DELETE /api/test-templates/:id - Delete a test template +/// DELETE /api/test-templates/:id - Eliminar una plantilla de test pub async fn delete_test_template( Org(org_ctx): Org, Path(template_id): Path, @@ -344,22 +344,22 @@ pub async fn delete_test_template( .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())); + return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string())); } Ok(StatusCode::NO_CONTENT) } -// ==================== Template Questions Management ==================== +// ==================== Gestión de Preguntas de la Plantilla ==================== -/// POST /api/test-templates/:id/questions - Add a question to a template +/// POST /api/test-templates/:id/questions - Añadir una pregunta a una plantilla 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 + // Verificar que la plantilla existe y pertenece a la organización let exists: (bool,) = sqlx::query_as( r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"# ) @@ -370,7 +370,7 @@ pub async fn create_template_question( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !exists.0 { - return Err((StatusCode::NOT_FOUND, "Template not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string())); } let question: TestTemplateQuestion = sqlx::query_as( @@ -414,13 +414,13 @@ pub struct CreateQuestionPayload { pub metadata: Option, } -/// DELETE /api/test-templates/:template_id/questions/:question_id - Delete a question +/// DELETE /api/test-templates/:template_id/questions/:question_id - Eliminar una pregunta 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 + // Verificar que la plantilla existe y pertenece a la organización let exists: (bool,) = sqlx::query_as( r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"# ) @@ -431,7 +431,7 @@ pub async fn delete_template_question( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !exists.0 { - return Err((StatusCode::NOT_FOUND, "Template not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string())); } let result = sqlx::query( @@ -447,22 +447,22 @@ pub async fn delete_template_question( .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())); + return Err((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string())); } Ok(StatusCode::NO_CONTENT) } -// ==================== Template Sections Management ==================== +// ==================== Gestión de Secciones de la Plantilla ==================== -/// POST /api/test-templates/:id/sections - Add a section to a template +/// POST /api/test-templates/:id/sections - Añadir una sección a una plantilla 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 + // Verificar que la plantilla existe let exists: (bool,) = sqlx::query_as( r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"# ) @@ -473,7 +473,7 @@ pub async fn create_template_section( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !exists.0 { - return Err((StatusCode::NOT_FOUND, "Template not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string())); } let section: TestTemplateSection = sqlx::query_as( @@ -509,7 +509,7 @@ pub struct CreateSectionPayload { pub section_data: Option, } -/// DELETE /api/test-templates/:template_id/sections/:section_id - Delete a section +/// DELETE /api/test-templates/:template_id/sections/:section_id - Eliminar una sección pub async fn delete_template_section( Org(org_ctx): Org, Path((template_id, section_id)): Path<(Uuid, Uuid)>, @@ -528,15 +528,15 @@ pub async fn delete_template_section( .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())); + return Err((StatusCode::NOT_FOUND, "Sección no encontrada".to_string())); } Ok(StatusCode::NO_CONTENT) } -// ==================== Apply Template to Lesson ==================== +// ==================== Aplicar Plantilla a la Lección ==================== -/// POST /api/test-templates/:id/apply - Apply a template to a lesson +/// POST /api/test-templates/:id/apply - Aplicar una plantilla a una lección pub async fn apply_template_to_lesson( Org(org_ctx): Org, Path(template_id): Path, @@ -544,7 +544,7 @@ pub async fn apply_template_to_lesson( State(pool): State, Json(payload): Json, ) -> Result { - // Verify template exists and belongs to organization + // Verificar que la plantilla existe y pertenece a la organización let template: TestTemplate = sqlx::query_as( r#" SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type, @@ -559,11 +559,11 @@ pub async fn apply_template_to_lesson( .fetch_one(&pool) .await .map_err(|e| match e { - sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()), + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; - // Verify lesson exists and belongs to organization + // Verificar que la lección existe y pertenece a la organización let lesson_exists: (bool,) = sqlx::query_as( r#"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)"# ) @@ -574,10 +574,10 @@ pub async fn apply_template_to_lesson( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !lesson_exists.0 { - return Err((StatusCode::NOT_FOUND, "Lesson not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Lección no encontrada".to_string())); } - // Get template questions with their sections + // Obtener las preguntas de la plantilla con sus secciones let template_questions: Vec = sqlx::query_as( r#" SELECT id, template_id, section_id, question_order, question_type, question_text, @@ -593,10 +593,10 @@ pub async fn apply_template_to_lesson( .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())); + return Err((StatusCode::BAD_REQUEST, "La plantilla no tiene preguntas".to_string())); } - // Build quiz_data JSON from template questions + // Construir el JSON quiz_data a partir de las preguntas de la plantilla let questions_json: Vec = template_questions .iter() .map(|q| { @@ -621,12 +621,12 @@ pub async fn apply_template_to_lesson( "passing_score": template.passing_score, "total_points": template.total_points, "instructions": template.instructions, - "max_attempts": 1, // Single attempt as requested + "max_attempts": 1, // Intento único según lo solicitado "show_feedback": true, // Show explanations after answering - "permanent_history": true, // Student can always view their responses + "permanent_history": true, // El estudiante siempre puede ver sus respuestas }); - // Update lesson with quiz data and configuration + // Actualizar lección con datos del cuestionario y configuración sqlx::query( r#" UPDATE lessons @@ -649,7 +649,7 @@ pub async fn apply_template_to_lesson( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Increment template usage count + // Incrementar el contador de uso de la plantilla sqlx::query("SELECT increment_template_usage($1)") .bind(template_id) .execute(&pool) @@ -657,7 +657,7 @@ pub async fn apply_template_to_lesson( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; tracing::info!( - "Applied template '{}' to lesson '{}' with {} questions", + "Plantilla '{}' aplicada a la lección '{}' con {} preguntas", template.name, payload.lesson_id, template_questions.len() @@ -672,9 +672,9 @@ pub struct ApplyTemplatePayload { pub grading_category_id: Option, } -// ==================== RAG Question Generation ==================== +// ==================== Generación de Preguntas RAG ==================== -// Helper function to generate system prompt based on question type +// Función auxiliar para generar el system prompt basado en el tipo de pregunta fn get_system_prompt_for_question_type( question_type: &str, num_questions: i32, @@ -686,12 +686,12 @@ fn get_system_prompt_for_question_type( format!( r#"You are an English Teacher creating quiz questions. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL true-false questions about: {} +Crea {} preguntas ORIGINALES de verdadero-falso sobre: {} -IMPORTANT - Return ONLY a JSON array with this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA: [ {{ "question_text": "The capital of France is Paris.", @@ -702,10 +702,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure: }} ] -Rules: -- Each question must be a clear statement -- correct_answer must be true or false -- Explanations must be concise"#, +Reglas: +- Cada pregunta debe ser una afirmación clara +- correct_answer debe ser true o false +- Las explicaciones deben ser concisas"#, rag_context, num_questions, topic ) } @@ -713,12 +713,12 @@ Rules: format!( r#"You are an English Teacher creating quiz questions. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL short-answer questions about: {} +Crea {} preguntas ORIGINALES de respuesta corta sobre: {} -IMPORTANT - Return ONLY a JSON array with this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA: [ {{ "question_text": "What is the past tense of 'go'?", @@ -730,10 +730,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure: }} ] -Rules: -- correct_answer should be the expected response -- keywords array contains acceptable variations or key concepts to check -- Questions should accept brief responses"#, +Reglas: +- correct_answer debe ser la respuesta esperada +- el array de palabras clave (keywords) contiene variaciones aceptables o conceptos clave para verificar +- Las preguntas deben aceptar respuestas breves"#, rag_context, num_questions, topic ) } @@ -741,12 +741,12 @@ Rules: format!( r#"You are an English Teacher creating essay questions. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL essay questions about: {} +Crea {} preguntas ORIGINALES de ensayo sobre: {} -IMPORTANT - Return ONLY a JSON array with this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA: [ {{ "question_text": "Explain how setting influences the mood of a short story.", @@ -757,10 +757,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure: }} ] -Rules: -- Questions must require extended written responses -- correct_answer should contain rubric guidance or expected criteria -- No options array is needed"#, +Reglas: +- Las preguntas deben requerir respuestas escritas extensas +- correct_answer debe contener una guía de rúbrica o criterios esperados +- No se necesita un array de opciones (options)"#, rag_context, num_questions, topic ) } @@ -768,15 +768,15 @@ Rules: format!( r#"You are an English Teacher creating quiz questions. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL matching question sets about: {} +Crea {} conjuntos de preguntas ORIGINALES de emparejamiento sobre: {} -IMPORTANT - Return ONLY a JSON array with matching questions. Each matching question should have this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con preguntas de emparejamiento. Cada pregunta de emparejamiento debe tener esta estructura EXACTA: [ {{ - "question_text": "Match each vocabulary term with its definition:", + "question_text": "Empareje cada término de vocabulario con su definición:", "question_type": "matching", "pairs": [ {{"left": "Verb", "right": "A word that describes an action"}}, @@ -788,128 +788,128 @@ IMPORTANT - Return ONLY a JSON array with matching questions. Each matching ques }} ] -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"#, +Reglas: +- Crear 3-5 pares de emparejamiento por pregunta +- los elementos izquierda/derecha (left/right) deben ser claros y distintos +- Todos los elementos del array de pares deben seguir la misma estructura +- Una pregunta por elemento del array"#, rag_context, num_questions, topic ) } "ordering" => { format!( - r#"You are an English Teacher creating quiz questions. + r#"Eres un profesor de inglés creando preguntas para un cuestionario. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL ordering questions about: {} +Crea {} preguntas ORIGINALES de ordenación sobre: {} -IMPORTANT - Return ONLY a JSON array with this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA: [ {{ - "question_text": "Arrange these steps of the writing process in correct order:", + "question_text": "Organice estos pasos del proceso de escritura en el orden correcto:", "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.", + "explanation": "El proceso de escritura comienza con la pre-escritura, luego el borrador, la revisión, la edición y finalmente la publicación.", "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"#, +Reglas: +- el array de elementos (items) contiene los elementos a ordenar +- correct_order es un array de índices que muestran la secuencia correcta (basado en 0) +- Debe tener al menos 4 elementos para ordenar +- Las preguntas deben tener una secuencia lógica clara"#, rag_context, num_questions, topic ) } "fill-in-the-blanks" => { format!( - r#"You are an English Teacher creating quiz questions. + r#"Eres un profesor de inglés creando preguntas para un cuestionario. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL fill-in-the-blanks questions about: {} +Crea {} preguntas ORIGINALES de completar espacios en blanco sobre: {} -IMPORTANT - Return ONLY a JSON array with this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA: [ {{ - "question_text": "The ________ is the main character in a story, while the ________ opposes them.", + "question_text": "El ________ es el personaje principal de una historia, mientras que el ________ se le opone.", "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.", + "explanation": "Estos son términos literarios clave que describen personajes en las historias.", "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"#, +Reglas: +- question_text debe tener ________ para cada espacio en blanco +- el array de espacios (blanks) tiene un objeto por espacio en blanco +- Cada objeto de espacio en blanco debe tener 'answer' y un array 'keywords' +- keywords debe incluir la respuesta principal más variaciones aceptables +- Las preguntas pueden tener de 1 a 3 espacios en blanco"#, rag_context, num_questions, topic ) } "audio-response" => { format!( - r#"You are an English Teacher creating speaking exercises. + r#"Eres un profesor de inglés creando ejercicios de expresión oral. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL audio-response speaking prompts about: {} +Crea {} consignas ORIGINALES de respuesta de audio sobre: {} -IMPORTANT - Return ONLY a JSON array with this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA: [ {{ - "question_text": "Describe a memorable trip using at least three past tense verbs.", + "question_text": "Describa un viaje memorable utilizando al menos tres verbos en pasado.", "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.", + "correct_answer": "Utilice formas claras en tiempo pasado y vocabulario de viaje relevante en una respuesta coherente.", + "explanation": "Esta consigna comprueba la fluidez y el control gramatical en la producción oral.", "points": 2 }} ] -Rules: -- Questions must require spoken production -- correct_answer should contain rubric guidance or expected response criteria -- No options array is needed"#, +Reglas: +- Las preguntas deben requerir producción oral +- correct_answer debe contener una guía de rúbrica o criterios de respuesta esperados +- No se necesita un array de opciones (options)"#, rag_context, num_questions, topic ) } _ => { // Default to multiple-choice format!( - r#"You are an English Teacher creating quiz questions. + r#"Eres un profesor de inglés creando preguntas para un cuestionario. -Use these examples as inspiration (do NOT copy): +Usa estos ejemplos como inspiración (NO los copies): {} -Create {} ORIGINAL multiple-choice questions about: {} +Crea {} preguntas ORIGINALES de opción múltiple sobre: {} -IMPORTANT - Return ONLY a JSON array with this EXACT structure: +IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA: [ {{ - "question_text": "The tourist got lost in the ______ of the city.", + "question_text": "El turista se perdió en el ______ de la ciudad.", "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.", + "explanation": "El centro (downtown) es la zona principal de una ciudad que los turistas suelen visitar.", "points": 1, "skill_assessed": "reading" }} ] -Rules: -- Option text only, no prefixes like A. or 1) -- Skills must be one of: reading, listening, speaking, writing"#, +Reglas: +- Solo texto de la opción, sin prefijos como A. o 1) +- Las habilidades (skills) deben ser una de: lectura, escucha, habla, escritura"#, rag_context, num_questions, topic ) } @@ -997,8 +997,8 @@ fn parse_ai_response_for_question_type( flatten_into_questions(questions_data) } -/// 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 +/// POST /test-templates/generate-with-rag - Generar preguntas usando RAG del banco de preguntas MySQL importado +/// Usa búsqueda semántica con embeddings de pgvector cuando están disponibles, de lo contrario recurre al filtrado por course_id pub async fn generate_questions_with_rag( Org(org_ctx): Org, claims: Claims, @@ -1019,7 +1019,7 @@ pub async fn generate_questions_with_rag( .danger_accept_invalid_certs(true) .danger_accept_invalid_hostnames(true) .build() - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?; let ollama_url = ai::get_ollama_url(); let model = ai::get_embedding_model(); @@ -1070,13 +1070,11 @@ pub async fn generate_questions_with_rag( .bind(requested_num_questions * 3) // Get more for diversity .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Semantic search failed: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda semántica falló: {}", e)))?; - tracing::info!("Semantic search found {} similar questions", mysql_questions.len()); + tracing::info!("La búsqueda semántica encontró {} preguntas similares", 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 ); @@ -1123,11 +1121,9 @@ pub async fn generate_questions_with_rag( .bind(requested_num_questions * 3) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword fallback failed: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("El recurso a palabras clave falló: {}", e)))?; if mysql_questions.is_empty() { - tracing::info!( - "Keyword fallback returned no rows; falling back to imported MySQL questions for course {:?}", payload.course_id ); @@ -1168,7 +1164,7 @@ pub async fn generate_questions_with_rag( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Course fallback failed: {}", e), + format!("El recurso por curso falló: {}", e), ) })?; } @@ -1176,7 +1172,7 @@ pub async fn generate_questions_with_rag( } } Err(e) => { - tracing::warn!("Semantic search failed, falling back to keyword search: {}", e); + tracing::warn!("La búsqueda semántica falló, recurriendo a búsqueda por palabras clave: {}", e); // Fall back to text search mysql_questions = sqlx::query_as( r#" @@ -1220,11 +1216,9 @@ pub async fn generate_questions_with_rag( .bind(requested_num_questions * 3) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword search failed: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda por palabras clave falló: {}", 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 ); @@ -1265,7 +1259,7 @@ pub async fn generate_questions_with_rag( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Course fallback failed: {}", e), + format!("El recurso por curso falló: {}", e), ) })?; } @@ -1309,7 +1303,7 @@ pub async fn generate_questions_with_rag( .bind(course_id) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?; } else { // Fetch all imported MySQL questions for this organization // NO LIMIT - fetch all questions for better RAG context @@ -1339,12 +1333,10 @@ pub async fn generate_questions_with_rag( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", 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 @@ -1379,7 +1371,7 @@ pub async fn generate_questions_with_rag( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to fetch organization-wide fallback questions: {}", e), + format!("Error al obtener preguntas de recurso de toda la organización: {}", e), ) })?; @@ -1405,7 +1397,7 @@ pub async fn generate_questions_with_rag( .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); + tracing::info!("Tipo de curso determinado: {:?}, nivel: {:?} a partir de los datos importados", course_type, level); // 2. Build RAG context from MySQL questions (lightweight format) let rag_context: String = mysql_questions @@ -1415,7 +1407,7 @@ pub async fn generate_questions_with_rag( .collect::>() .join("\n"); - tracing::info!("RAG context built with {} questions", mysql_questions.len().min(8)); + tracing::info!("Contexto RAG construido con {} preguntas", 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()); @@ -1428,7 +1420,7 @@ pub async fn generate_questions_with_rag( .build() .unwrap_or_else(|_| reqwest::Client::new()); - tracing::info!("Calling Ollama at {} with model {}", url, model); + tracing::info!("Llamando a Ollama en {} con el modelo {}", url, model); // Save topic for later use let topic = payload.topic.clone().unwrap_or_else(|| "English grammar".to_string()); @@ -1459,7 +1451,7 @@ pub async fn generate_questions_with_rag( &rag_context, ); - tracing::debug!("System prompt length: {} chars", system_prompt.len()); + tracing::debug!("Longitud del prompt del sistema: {} caracteres", system_prompt.len()); let request = client .post(&url) @@ -1483,19 +1475,19 @@ pub async fn generate_questions_with_rag( } })); - tracing::info!("Sending request to Ollama (model: {}, prompt length: {} chars)", model, system_prompt.len()); + tracing::info!("Enviando solicitud a Ollama (modelo: {}, longitud del prompt: {} caracteres)", 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()); + tracing::info!("Estado de la respuesta de Ollama: {}", 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); + tracing::error!("Ollama devolvió un estado de error {}: {}", status, body); return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Ollama returned {}. El proxy de IA agotó el tiempo de espera.", status), @@ -1503,8 +1495,8 @@ pub async fn generate_questions_with_rag( } 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()) + tracing::error!("Error al leer la respuesta del flujo de la IA: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Respuesta de IA inválida".to_string()) })?; let aggregated_content = response_text @@ -1532,7 +1524,7 @@ pub async fn generate_questions_with_rag( } }); - tracing::debug!("Ollama response: {:?}", response_json); + tracing::debug!("Respuesta de Ollama: {:?}", response_json); // Parse questions from Ollama response let ai_payload = response_json @@ -1619,7 +1611,7 @@ pub async fn generate_questions_with_rag( .to_string(); if question_text.is_empty() || question_text.eq_ignore_ascii_case("question") { - tracing::warn!("Skipping invalid generated question with empty placeholder text: {:?}", q); + tracing::warn!("Omitiendo pregunta generada inválida con texto de marcador vacío: {:?}", q); return None; } @@ -1644,7 +1636,7 @@ pub async fn generate_questions_with_rag( let (options, correct_answer, options_shuffled) = match question_type_value.as_str() { "multiple-choice" => { if original_options.len() < 2 { - tracing::warn!("Skipping invalid multiple-choice question without enough options: {:?}", q); + tracing::warn!("Omitiendo pregunta de opción múltiple inválida sin suficientes opciones: {:?}", q); return None; } if !original_options.is_empty() && original_correct_idx.is_some() { @@ -1675,7 +1667,7 @@ pub async fn generate_questions_with_rag( .and_then(|v| v.as_bool()) .map(|v| if v { json!(0) } else { json!(1) }); if bool_answer.is_none() { - tracing::warn!("Skipping invalid true-false question without boolean correct answer: {:?}", q); + tracing::warn!("Omitiendo pregunta de verdadero-falso inválida sin respuesta correcta booleana: {:?}", q); return None; } (Some(json!(["True", "False"])), bool_answer, false) @@ -1688,7 +1680,7 @@ pub async fn generate_questions_with_rag( .map(|arr| !arr.is_empty()) .unwrap_or(false); if !is_valid { - tracing::warn!("Skipping invalid matching question without pairs: {:?}", q); + tracing::warn!("Omitiendo pregunta de emparejamiento inválida sin pares: {:?}", q); return None; } (pairs.clone(), pairs, false) @@ -1711,7 +1703,7 @@ pub async fn generate_questions_with_rag( .map(|arr| !arr.is_empty()) .unwrap_or(false); if !has_items || !has_order { - tracing::warn!("Skipping invalid ordering question without items/order: {:?}", q); + tracing::warn!("Omitiendo pregunta de ordenación inválida sin elementos/orden: {:?}", q); return None; } (items, order, false) @@ -1724,7 +1716,7 @@ pub async fn generate_questions_with_rag( .map(|arr| !arr.is_empty()) .unwrap_or(false); if !has_blanks { - tracing::warn!("Skipping invalid fill-in-the-blanks question without blanks array: {:?}", q); + tracing::warn!("Omitiendo pregunta de completar espacios en blanco inválida sin array de espacios: {:?}", q); return None; } (blanks.clone(), blanks, false) @@ -1760,7 +1752,7 @@ pub async fn generate_questions_with_rag( .collect(); if generated_questions.is_empty() { - return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI failed to generate questions".to_string())); + return Err((StatusCode::INTERNAL_SERVER_ERROR, "La IA no pudo generar las preguntas".to_string())); } // Save generated questions to question bank @@ -1819,7 +1811,7 @@ pub async fn generate_questions_with_rag( } tracing::info!( - "Generated {} questions using RAG from MySQL bank, saved {} to question bank", + "Generadas {} preguntas usando RAG del banco MySQL, guardadas {} al banco de preguntas", generated_questions.len(), saved_count ); @@ -1892,7 +1884,7 @@ async fn ensure_mysql_course_metadata( let mysql_url = std::env::var("MYSQL_DATABASE_URL").map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "MYSQL_DATABASE_URL not configured".to_string(), + "MYSQL_DATABASE_URL no configurada".to_string(), ) })?; @@ -1907,7 +1899,7 @@ async fn ensure_mysql_course_metadata( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to connect to external MySQL: {}", e), + format!("Error al conectar con MySQL externo: {}", e), ) })?; @@ -1962,7 +1954,7 @@ async fn ensure_mysql_course_metadata( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to upsert MySQL study plan metadata: {}", e), + format!("Error al actualizar/insertar los metadatos del plan de estudios MySQL: {}", e), ) })?; @@ -1976,7 +1968,7 @@ async fn ensure_mysql_course_metadata( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to resolve MySQL study plan metadata: {}", e), + format!("Error al resolver los metadatos del plan de estudios MySQL: {}", e), ) })?; @@ -2013,7 +2005,7 @@ async fn ensure_mysql_course_metadata( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to upsert MySQL course metadata: {}", e), + format!("Error al actualizar/insertar los metadatos del curso MySQL: {}", e), ) })?; diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index ddf62cb..00f0444 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -36,32 +36,32 @@ async fn main() { dotenv().ok(); tracing_subscriber::fmt::init(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL debe estar configurada"); let pool = PgPoolOptions::new() .max_connections(10) .min_connections(2) .acquire_timeout(Duration::from_secs(30)) .connect(&db_url) .await - .expect("Failed to connect to database"); + .expect("Error al conectar con la base de datos"); - // Initialize health state + // Inicializar el estado de salud let health_state = HealthState::default(); sqlx::migrate!("./migrations") .run(&pool) .await - .expect("Failed to run migrations"); + .expect("Error al ejecutar las migraciones"); - // Sync default organization branding from environment + // Sincronizar la marca de la organización por defecto desde el entorno sync_default_organization(&pool).await; - // Start AI Background Worker + // Iniciar el trabajador de IA en segundo plano let worker_pool = pool.clone(); tokio::spawn(async move { - tracing::info!("AI Background Worker started"); + tracing::info!("Trabajador de IA en segundo plano iniciado"); loop { - // Check for queued transcriptions + // Buscar transcripciones en cola let queued_lessons: Vec = match sqlx::query_scalar( "SELECT id FROM lessons WHERE transcription_status = 'queued' LIMIT 5", ) @@ -70,18 +70,18 @@ async fn main() { { Ok(ids) => ids, Err(e) => { - tracing::error!("Failed to fetch queued lessons: {}", e); + tracing::error!("Error al obtener lecciones en cola: {}", e); tokio::time::sleep(Duration::from_secs(10)).await; continue; } }; for lesson_id in queued_lessons { - tracing::info!("Processing transcription for lesson: {}", lesson_id); + tracing::info!("Procesando transcripción para la lección: {}", lesson_id); if let Err(e) = handlers::run_transcription_task(worker_pool.clone(), lesson_id).await { - tracing::error!("Transcription task failed for lesson {}: {}", lesson_id, e); + tracing::error!("La tarea de transcripción falló para la lección {}: {}", lesson_id, e); let _ = sqlx::query( "UPDATE lessons SET transcription_status = 'failed' WHERE id = $1", ) @@ -97,15 +97,15 @@ async fn main() { } }); - // CORS configuration - Allow multiple origins for development and production - // Using a predicate closure to support wildcard subdomains for norteamericano.cl + // Configuración de CORS - Permitir múltiples orígenes para desarrollo y producción + // Uso de un cierre de predicado para soportar subdominios comodín para norteamericano.cl use tower_http::cors::AllowOrigin; let cors = CorsLayer::new() .allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { let origin_str = origin.to_str().unwrap_or(""); - // Development origins + // Orígenes de desarrollo let allowed_origins = [ "http://localhost:3000", "http://localhost:3003", @@ -114,7 +114,7 @@ async fn main() { "http://192.168.0.254:3000", "http://192.168.0.254:3003", "http://192.168.0.254", - // Production - Norteamericano domains (.cl and .com) + // Producción - Dominios de Norteamericano (.cl y .com) "http://studio.norteamericano.com", "https://studio.norteamericano.com", "http://learning.norteamericano.com", @@ -125,12 +125,12 @@ async fn main() { "https://learning.norteamericano.cl", ]; - // Check exact matches + // Comprobar coincidencias exactas if allowed_origins.contains(&origin_str) { return true; } - // Check wildcard for subdomains in norteamericano.cl/.com over HTTP(S) + // Comprobar comodín para subdominios en norteamericano.cl/.com sobre HTTP(S) for scheme in ["http://", "https://"] { for domain in [".norteamericano.cl", ".norteamericano.com"] { if origin_str.starts_with(scheme) && origin_str.ends_with(domain) { @@ -140,7 +140,7 @@ async fn main() { .strip_suffix(domain) .unwrap_or(""); - // Allow any subdomain (e.g., api., cdn., admin., etc.) + // Permitir cualquier subdominio (ej., api., cdn., admin., etc.) if !subdomain.is_empty() && !subdomain.contains('/') { return true; } @@ -296,7 +296,7 @@ async fn main() { "/organization/branding", axum::routing::put(handlers_branding::update_organization_branding), ) - // Content Libraries routes + // Rutas de librerías de contenido .route( "/library/blocks", get(handlers_library::list_library_blocks).post(handlers_library::create_library_block), @@ -311,7 +311,7 @@ async fn main() { "/library/blocks/{id}/increment-usage", post(handlers_library::increment_block_usage), ) - // Advanced Grading (Rubrics) routes + // Rutas de calificación avanzada (rúbricas) .route( "/courses/{id}/rubrics", get(handlers_rubrics::list_course_rubrics).post(handlers_rubrics::create_rubric), @@ -347,7 +347,7 @@ async fn main() { "/lessons/{id}/rubrics", get(handlers_rubrics::get_lesson_rubrics), ) - // Learning Sequences (Dependencies) routes + // Rutas de secuencias de aprendizaje (dependencias) .route( "/lessons/{id}/dependencies", get(handlers_dependencies::list_lesson_dependencies) @@ -357,7 +357,7 @@ async fn main() { "/lessons/{id}/dependencies/{prerequisite_id}", delete(handlers_dependencies::remove_dependency), ) - // Test Templates routes + // Rutas de plantillas de pruebas .route( "/test-templates", get(handlers_test_templates::list_test_templates) @@ -393,7 +393,7 @@ async fn main() { "/test-templates/generate-with-rag", post(handlers_test_templates::generate_questions_with_rag), ) - // Question Bank routes + // Rutas del banco de preguntas .route( "/question-bank", get(handlers_question_bank::list_questions) @@ -433,7 +433,7 @@ async fn main() { "/question-bank/ai-generate", post(handlers_question_bank::ai_generate_question), ) - // Embedding routes for semantic search + // Rutas de embeddings para búsqueda semántica .route( "/question-bank/embeddings/generate", post(handlers_embeddings::generate_question_embeddings), @@ -450,7 +450,7 @@ async fn main() { "/question-bank/{id}/embedding/regenerate", post(handlers_embeddings::regenerate_question_embedding), ) - // SAM Integration routes + // Rutas de integración con SAM .route( "/sam/sync-all", post(handlers_sam::sync_all_sam), @@ -471,7 +471,7 @@ async fn main() { "/sam/students/{student_id}/courses", get(handlers_sam::get_sam_student_courses), ) - // Admin routes + // Rutas de administración .route( "/admin/token-usage", get(handlers_admin::get_token_usage), @@ -527,12 +527,12 @@ async fn main() { "/api/assets/s3-proxy/{bucket}/{*key}", get(handlers_assets::public_s3_proxy), ) - // Health check routes + // Rutas de verificación de salud .merge(health::health_routes(pool.clone()).with_state(health_state)) .nest_service("/assets", tower_http::services::ServeDir::new("uploads")) .merge(auth_routes) .merge(protected_routes) - // Security headers + // Cabeceras de seguridad .layer(SetResponseHeaderLayer::overriding( http::header::STRICT_TRANSPORT_SECURITY, http::HeaderValue::from_static("max-age=31536000; includeSubDomains"), @@ -553,14 +553,14 @@ async fn main() { http::header::REFERRER_POLICY, http::HeaderValue::from_static("strict-origin-when-cross-origin"), )) - // CORS layer - MUST be last to execute first on response + // Capa CORS - DEBE ser la última para ejecutarse primero en la respuesta .layer(cors) - // Trace layer for logging requests/responses + // Capa de trazado para registrar solicitudes/respuestas .layer(TraceLayer::new_for_http()) .with_state(pool); let addr = SocketAddr::from(([0, 0, 0, 0], 3001)); - tracing::info!("CMS Service listening on {} with rate limiting and security headers", addr); + tracing::info!("Servicio CMS escuchando en {} con limitación de tasa y cabeceras de seguridad", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, public_routes).await.unwrap(); } @@ -597,8 +597,8 @@ async fn sync_default_organization(pool: &sqlx::PgPool) { .await; match result { - Ok(_) => tracing::info!("Default organization branding synced from .env"), - Err(e) => tracing::error!("Failed to sync default organization branding: {}", e), + Ok(_) => tracing::info!("Marca de la organización por defecto sincronizada desde .env"), + Err(e) => tracing::error!("Error al sincronizar la marca de la organización por defecto: {}", e), } } diff --git a/services/cms-service/src/webhooks.rs b/services/cms-service/src/webhooks.rs index 8ff0b13..de75bb3 100644 --- a/services/cms-service/src/webhooks.rs +++ b/services/cms-service/src/webhooks.rs @@ -15,7 +15,7 @@ impl WebhookService { } pub async fn dispatch(&self, org_id: Uuid, event_type: &str, payload: &serde_json::Value) { - // 1. Fetch active webhooks for this org that are interested in this event + // 1. Obtener webhooks activos para esta org que estén interesados en este evento let webhooks = match sqlx::query_as::<_, Webhook>( "SELECT * FROM webhooks WHERE organization_id = $1 AND is_active = TRUE AND $2 = ANY(events)" ) @@ -25,7 +25,7 @@ impl WebhookService { .await { Ok(w) => w, Err(e) => { - tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e); + tracing::error!("Error al obtener los webhooks para la org {}: {}", org_id, e); return; } }; @@ -44,7 +44,7 @@ impl WebhookService { .header("X-OpenCCB-Event", event_type) .header("X-OpenCCB-Delivery", Uuid::new_v4().to_string()); - // Add signature if secret exists + // Añadir firma si existe el secreto if let Some(secret) = &webhook.secret { let signature = generate_signature(secret, &payload_str); request = request.header("X-OpenCCB-Signature", signature); @@ -56,15 +56,14 @@ impl WebhookService { match res { Ok(response) => { if !response.status().is_success() { - tracing::warn!( - "Webhook delivery to {} (event: {}) failed with status {}", + "El envío del webhook a {} (evento: {}) falló con estado {}", url, event_type, response.status() ); } else { tracing::info!( - "Successfully delivered webhook to {} (event: {})", + "Webhook enviado con éxito a {} (evento: {})", url, event_type ); @@ -72,7 +71,7 @@ impl WebhookService { } Err(e) => { tracing::error!( - "Failed to deliver webhook to {} (event: {}): {}", + "Error al enviar el webhook a {} (evento: {}): {}", url, event_type, e diff --git a/services/lms-service/src/db_util.rs b/services/lms-service/src/db_util.rs index 296b20d..de7650b 100644 --- a/services/lms-service/src/db_util.rs +++ b/services/lms-service/src/db_util.rs @@ -24,7 +24,7 @@ pub async fn set_session_context( .await?; } if let Some(user_agent) = ua { - // Use set_config for potentially long strings to avoid SQL injection/formatting issues + // Usar set_config para cadenas potencialmente largas para evitar inyección SQL o problemas de formato sqlx::query("SELECT set_config('app.user_agent', $1, true)") .bind(user_agent) .execute(&mut **tx) diff --git a/services/lms-service/src/external_db.rs b/services/lms-service/src/external_db.rs index c4deecf..8813d94 100644 --- a/services/lms-service/src/external_db.rs +++ b/services/lms-service/src/external_db.rs @@ -11,16 +11,16 @@ pub async fn init_mysql_pool() -> Option { .await { Ok(pool) => { - tracing::info!("Connected to external MySQL database"); + tracing::info!("Conectado a la base de datos MySQL externa"); Some(pool) } Err(e) => { - tracing::error!("Failed to connect to external MySQL database: {}", e); + tracing::error!("Error al conectar a la base de datos MySQL externa: {}", e); None } } } else { - tracing::info!("MYSQL_DATABASE_URL not set, skipping external database integration"); + tracing::info!("MYSQL_DATABASE_URL no establecida, omitiendo la integración con la base de datos externa"); None } } diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 1dee002..309e6b9 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -25,12 +25,12 @@ use serde_json::json; use base64::Engine; use tokio::time::{Duration, timeout}; -// Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish) +// Contador simple de tokens (aproximado: 1 token ≈ 4 caracteres en inglés, ~3-5 en español) fn count_tokens(text: &str) -> i32 { if text.is_empty() { return 0; } - // Spanish average: ~4 chars per token + // Promedio en español: ~4 caracteres por token (text.len() / 4) as i32 + 1 } @@ -58,8 +58,8 @@ pub async fn get_me( })) } -/// Get course language configuration -/// Returns whether the course uses auto-detection or fixed language +/// Obtener configuración de idioma del curso +/// Devuelve si el curso usa autodetección o un idioma fijo pub async fn get_course_language_config( State(pool): State, Path(course_id): Path, @@ -77,7 +77,7 @@ pub async fn get_course_language_config( .fetch_optional(&pool) .await .map_err(|e| { - tracing::error!("Error fetching course language config: {}", e); + tracing::error!("Error al obtener la configuración de idioma del curso: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -233,7 +233,7 @@ pub async fn bulk_enroll_users( let mut already_enrolled_emails = Vec::new(); for email in payload.emails { - // 1. Find user by email in the organization + // 1. Buscar usuario por email en la organización let user: Option = sqlx::query_as("SELECT * FROM users WHERE email = $1") .bind(&email) .fetch_optional(&pool) @@ -242,7 +242,7 @@ pub async fn bulk_enroll_users( match user { Some(user) => { - // 2. Check if already enrolled + // 2. Comprobar si ya está inscrito let is_enrolled: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM enrollments WHERE user_id = $1 AND course_id = $2)" ) @@ -257,13 +257,13 @@ pub async fn bulk_enroll_users( continue; } - // 3. Enroll (Admin enrollment ignores payment) + // 3. Inscribir (La inscripción por administrador ignora el pago) let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Set session context for audit + // Establecer contexto de sesión para auditoría crate::db_util::set_session_context( &mut tx, Some(claims.sub), @@ -311,10 +311,10 @@ pub async fn export_course_grades( Path(course_id): Path, ) -> Result { if claims.role != "admin" && claims.role != "instructor" { - return Err((StatusCode::FORBIDDEN, "Unauthorized".to_string())); + return Err((StatusCode::FORBIDDEN, "No autorizado".to_string())); } - // 1. Get Categories + // 1. Obtener categorías #[derive(sqlx::FromRow)] struct Cat { id: Uuid, name: String } let categories: Vec = sqlx::query_as( @@ -325,7 +325,7 @@ pub async fn export_course_grades( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 2. Get Student general data + // 2. Obtener datos generales de los estudiantes #[derive(sqlx::FromRow)] struct StudentRow { id: Uuid, @@ -358,7 +358,7 @@ pub async fn export_course_grades( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3. Get detailed grades per user/category + // 3. Obtener calificaciones detalladas por usuario/categoría #[derive(sqlx::FromRow)] struct UserCategoryGrade { user_id: Uuid, @@ -383,7 +383,7 @@ pub async fn export_course_grades( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 4. Build CSV + // 4. Construir CSV let mut csv = "Name,Email,Cohort,Progress,Overall Score".to_string(); for cat in &categories { csv.push_str(&format!(",{}", cat.name)); @@ -426,7 +426,7 @@ pub async fn export_course_grades( .map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Response building failed".to_string(), + "Error al construir la respuesta".to_string(), ) })? .into_response()) @@ -446,10 +446,10 @@ pub async fn enroll_user( let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?; let user_id = claims.sub; - // Optional: ID from the external system (idDetalleContrato) + // Opcional: ID del sistema externo (idDetalleContrato) let external_id: Option = payload.get("external_id").and_then(|v| v.as_i64()).map(|v| v as i32); - // 1. Check if course exists and get its price + // 1. Comprobar si el curso existe y obtener su precio let course_info: (f64, String) = sqlx::query_as("SELECT price, currency FROM courses WHERE id = $1") .bind(course_id) @@ -457,7 +457,7 @@ pub async fn enroll_user( .await .map_err(|_| StatusCode::NOT_FOUND)?; - // 2. If it's a paid course, check for a successful transaction + // 2. Si es un curso de pago, comprobar si hay una transacción exitosa if course_info.0 > 0.0 { let has_paid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM transactions WHERE user_id = $1 AND course_id = $2 AND status = 'success')" @@ -507,11 +507,11 @@ pub async fn enroll_user( .fetch_one(&mut *tx) .await .map_err(|e: sqlx::Error| { - tracing::error!("Enrollment failed: {}", e); + tracing::error!("La inscripción falló: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - // If an external_id was provided, persist it on the enrollment now + // Si se proporcionó un external_id, persistirlo en la inscripción ahora if let Some(ext_id) = external_id { sqlx::query("UPDATE enrollments SET external_id = $1 WHERE id = $2") .bind(ext_id) @@ -519,7 +519,7 @@ pub async fn enroll_user( .execute(&mut *tx) .await .map_err(|e: sqlx::Error| { - tracing::error!("Failed to set external_id on enrollment: {}", e); + tracing::error!("Error al establecer el external_id en la inscripción: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; } @@ -528,7 +528,7 @@ pub async fn enroll_user( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Dispatch Webhook + // Enviar Webhook let webhook_service = common::webhooks::WebhookService::new(pool.clone()); webhook_service .dispatch( @@ -606,7 +606,7 @@ pub async fn register( .to_string() }); - // Use provided organization name or Default Organization + // Usar nombre de organización proporcionado u Organización por Defecto let mut tx = pool .begin() .await @@ -695,7 +695,7 @@ pub async fn login( let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "JWT generation failed".into(), + "Error al generar el JWT".into(), ) })?; @@ -761,7 +761,7 @@ pub async fn ingest_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 1. Upsert Organization + // 1. Insertar o actualizar (Upsert) Organización let org_id = payload.course.organization_id; sqlx::query( "INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, created_at, updated_at) @@ -791,7 +791,7 @@ pub async fn ingest_course( StatusCode::INTERNAL_SERVER_ERROR })?; - // 2. Upsert Course + // 2. Insertar o actualizar (Upsert) Curso sqlx::query( "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode, price, currency) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) @@ -825,11 +825,11 @@ pub async fn ingest_course( .execute(&mut *tx) .await .map_err(|e: sqlx::Error| { - tracing::error!("Failed to upsert course during ingestion: {}", e); + tracing::error!("Error al realizar el upsert del curso durante la ingesta: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - // 2. Clear existing grading categories, modules and lessons (cascading handles lessons/categories) + // 2. Limpiar categorías de calificación, módulos y lecciones existentes (la cascada maneja lecciones/categorías) sqlx::query("DELETE FROM grading_categories WHERE course_id = $1") .bind(payload.course.id) .execute(&mut *tx) @@ -848,7 +848,7 @@ pub async fn ingest_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 3. Insert Grading Categories + // 3. Insertar categorías de calificación for cat in payload.grading_categories { sqlx::query( "INSERT INTO grading_categories (id, course_id, name, weight, drop_count, created_at, organization_id) @@ -866,7 +866,7 @@ pub async fn ingest_course( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } - // 4. Insert Instructors + // 4. Insertar instructores if let Some(instructors) = payload.instructors { for instructor in instructors { sqlx::query( @@ -882,13 +882,13 @@ pub async fn ingest_course( .execute(&mut *tx) .await .map_err(|e: sqlx::Error| { - tracing::error!("Failed to insert instructor: {}", e); + tracing::error!("Error al insertar el instructor: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; } } - // 4. Insert Modules and Lessons + // 4. Insertar módulos y lecciones for pub_module in &payload.modules { sqlx::query( "INSERT INTO modules (id, course_id, title, position, created_at, organization_id) @@ -932,7 +932,7 @@ pub async fn ingest_course( .execute(&mut *tx) .await .map_err(|e: sqlx::Error| { - tracing::error!("Failed to insert lesson: {}", e); + tracing::error!("Error al insertar la lección: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; } @@ -942,15 +942,15 @@ pub async fn ingest_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 5. Background Ingestion of Knowledge Base - // We do this after commit to ensure lesson IDs are persistent + // 5. Ingesta en segundo plano de la base de conocimientos + // Hacemos esto después del commit para asegurar que los IDs de las lecciones sean persistentes for pub_module in &payload.modules { for lesson in &pub_module.lessons { let block_content = extract_block_content(&lesson.metadata); if !block_content.trim().is_empty() { let _ = ingest_lesson_knowledge(&pool, org_id, lesson.id, &block_content).await; } - // Also ingest summary as a high-relevance chunk + // También ingerir el resumen como un fragmento de alta relevancia if let Some(summary) = &lesson.summary { let _ = ingest_lesson_knowledge(&pool, org_id, lesson.id, summary).await; } @@ -973,7 +973,7 @@ pub async fn get_course_outline( org_ctx.id ); - // If it's a preview token, ensure it's for the correct course + // Si es un token de vista previa, asegurar que es para el curso correcto if claims.token_type.as_deref() == Some("preview") { if claims.course_id != Some(id) { tracing::warn!( @@ -988,7 +988,7 @@ pub async fn get_course_outline( id ); } - // 1. Fetch Course + // 1. Obtener curso let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") .bind(id) .fetch_one(&pool) @@ -1000,7 +1000,7 @@ pub async fn get_course_outline( tracing::info!("get_course_outline: course found, title='{}'", course.title); - // 2. Fetch Modules + // 2. Obtener módulos let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") .bind(id) @@ -1013,7 +1013,7 @@ pub async fn get_course_outline( tracing::info!("get_course_outline: found {} modules", modules.len()); - // 3. Fetch Organization + // 3. Obtener organización let organization = sqlx::query_as::<_, common::models::Organization>( "SELECT * FROM organizations WHERE id = $1", ) @@ -1034,7 +1034,7 @@ pub async fn get_course_outline( organization.name ); - // 4. Fetch Grading Categories + // 4. Obtener categorías de calificación let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( "SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at", ) @@ -1046,7 +1046,7 @@ pub async fn get_course_outline( StatusCode::INTERNAL_SERVER_ERROR })?; - // 5. Fetch Lessons + // 5. Obtener lecciones let mut pub_modules = Vec::new(); for module in modules { let lessons = sqlx::query_as::<_, Lesson>( @@ -1067,7 +1067,7 @@ pub async fn get_course_outline( pub_modules.push(common::models::PublishedModule { module, lessons }); } - // 6. Fetch all dependencies for this course + // 6. Obtener todas las dependencias para este curso let dependencies: Vec = sqlx::query_as( r#" SELECT ld.* @@ -1085,7 +1085,7 @@ pub async fn get_course_outline( StatusCode::INTERNAL_SERVER_ERROR })?; - // 7. Fetch Course Team + // 7. Obtener equipo del curso let instructors = sqlx::query_as::<_, common::models::CourseInstructor>( "SELECT ci.*, u.email, u.full_name FROM course_instructors ci JOIN users u ON ci.user_id = u.id @@ -1118,13 +1118,13 @@ pub async fn get_lesson_content( claims.sub ); - // 1. Check for preview token override + // 1. Comprobar anulación por token de vista previa let is_preview = claims.token_type.as_deref() == Some("preview"); let lesson = if is_preview { tracing::info!("get_lesson_content: Using preview token for lesson {}", id); - // Ensure the preview token is for the correct course (if we want to be strict) - // or just fetch the lesson and verify it belongs to the same org. + // Asegurar que el token de vista previa sea para el curso correcto (si queremos ser estrictos) + // o simplemente obtener la lección y verificar que pertenece a la misma organización. sqlx::query_as::<_, Lesson>( "SELECT l.* FROM lessons l JOIN modules m ON l.module_id = m.id @@ -1167,11 +1167,11 @@ pub async fn get_lesson_content( } }; - // 2. Enforce Prerequisites (Skip for previews) + // 2. Aplicar prerrequisitos (Omitir para vistas previas) if is_preview { return Ok(Json(lesson)); } - // We check if there are any prerequisites that the user hasn't completed yet. + // Comprobamos si hay prerrequisitos que el usuario aún no haya completado. #[derive(sqlx::FromRow)] struct UnmetDep { prereq_title: String, @@ -1269,12 +1269,12 @@ pub async fn submit_lesson_score( Some(org_ctx.id), ip, ua, - Some("SYSTEM_EVENT".to_string()), + Some("EVENTO_DEL_SISTEMA".to_string()), ) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 1. Get lesson attempt rules + // 1. Obtener reglas de intentos de la lección let max_attempts: Option> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1") .bind(payload.lesson_id) @@ -1287,7 +1287,7 @@ pub async fn submit_lesson_score( } let max_attempts = max_attempts.flatten(); - // 2. Check existing grade/attempts + // 2. Comprobar calificación/intentos existentes let existing_attempts: Option = sqlx::query_scalar("SELECT attempts_count FROM user_grades WHERE user_id = $1 AND lesson_id = $2 AND organization_id = $3") .bind(payload.user_id) .bind(payload.lesson_id) @@ -1307,7 +1307,7 @@ pub async fn submit_lesson_score( } } - // 3. Upsert with automated DB logic (XP, Badges) + // 3. Upsert con lógica de BD automatizada (XP, insignias) let grade = sqlx::query_as::<_, common::models::UserGrade>( "SELECT * FROM fn_upsert_user_grade($1, $2, $3, $4, $5, $6)", ) @@ -1321,9 +1321,9 @@ pub async fn submit_lesson_score( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3.1 Synchronize with external MySQL if available + // 3.1 Sincronizar con MySQL externo si está disponible if let Some(mysql_pool) = mysql_pool { - // Fetch the external_id (idDetalleContrato) from the enrollment record + // Obtener el external_id (idDetalleContrato) del registro de inscripción let external_id: Option = sqlx::query_scalar( "SELECT external_id FROM enrollments WHERE user_id = $1 AND course_id = $2" ) @@ -1337,11 +1337,11 @@ pub async fn submit_lesson_score( if let Some(id_detalle_contrato) = external_id { let table = env::var("EXTERNAL_TABLE_GRADES").unwrap_or_else(|_| "notas".to_string()); - // The external MySQL table uses the exact same 0-100 scale. + // La tabla MySQL externa usa exactamente la misma escala 0-100. let nota = payload.score.round() as i32; - // Resolve idTipoNota from the lesson's grading category (tipo_nota_id), - // falling back to the EXTERNAL_ID_TIPO_NOTA env var. + // Resolver idTipoNota desde la categoría de calificación de la lección (tipo_nota_id), + // recurriendo a la variable de entorno EXTERNAL_ID_TIPO_NOTA. let tipo_nota_from_category: Option = sqlx::query_scalar( "SELECT gc.tipo_nota_id FROM grading_categories gc \ JOIN lessons l ON l.grading_category_id = gc.id \ @@ -1357,7 +1357,7 @@ pub async fn submit_lesson_score( .or_else(|| { env::var("EXTERNAL_ID_TIPO_NOTA").ok().and_then(|v| v.parse().ok()) }) - .unwrap_or(1); // Default: CA (Continuous Assessment) + .unwrap_or(1); // Por defecto: CA (Evaluación Continua) let query = format!( "INSERT INTO {} (idDetalleContrato, FechaIngresoNota, idTipoNota, Nota, Activo) VALUES (?, NOW(), ?, ?, 1)", @@ -1371,11 +1371,11 @@ pub async fn submit_lesson_score( .execute(&mysql_pool) .await .map_err(|e| { - tracing::error!("Failed to sync grade to external MySQL (notas): {}", e); + tracing::error!("Error al sincronizar la calificación con MySQL externo (notas): {}", e); }); } else { tracing::warn!( - "No external_id found for enrollment (user_id={}, course_id={}). Grade not synced to MySQL.", + "No se encontró external_id para la inscripción (user_id={}, course_id={}). Calificación no sincronizada con MySQL.", payload.user_id, payload.course_id ); @@ -1386,7 +1386,7 @@ pub async fn submit_lesson_score( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 4. Dispatch Webhooks + // 4. Enviar Webhooks let webhook_service = common::webhooks::WebhookService::new(pool.clone()); // lesson.completed @@ -1403,7 +1403,7 @@ pub async fn submit_lesson_score( ) .await; - // Detect course completion logic + // Lógica de detección de finalización de curso let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)") .bind(payload.course_id) .fetch_one(&pool).await.unwrap_or(0); @@ -1493,7 +1493,7 @@ pub async fn get_leaderboard( .fetch_all(&pool) .await .map_err(|e: sqlx::Error| { - tracing::error!("Failed to fetch leaderboard: {}", e); + tracing::error!("Error al obtener la tabla de clasificación: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1549,7 +1549,7 @@ pub async fn get_course_grades( .fetch_all(&pool) .await .map_err(|e: sqlx::Error| { - tracing::error!("Failed to fetch course grades: {}", e); + tracing::error!("Error al obtener las calificaciones del curso: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) })?; @@ -1583,7 +1583,7 @@ pub async fn get_course_analytics( Path(course_id): Path, Query(filter): Query, ) -> Result, (StatusCode, String)> { - // 1. Total Enrollments + // 1. Inscripciones totales let total_enrollments: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) @@ -1602,7 +1602,7 @@ pub async fn get_course_analytics( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 2. Average Course Score (Overall) + // 2. Puntaje promedio del curso (General) let average_score: Option = sqlx::query_scalar( r#" SELECT AVG(score)::float4 @@ -1621,8 +1621,8 @@ pub async fn get_course_analytics( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3. Per-Lesson Analytics - // Note: We cast AVG to float4 for PostgreSQL compatibility + // 3. Analítica por lección + // Nota: Convertimos AVG a float4 para compatibilidad con PostgreSQL let rows = sqlx::query( r#" SELECT @@ -1673,7 +1673,7 @@ pub async fn get_student_progress_stats( ) -> Result, (StatusCode, String)> { let user_id = claims.sub; - // 1. Total Lessons + // 1. Lecciones totales let total_lessons: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)" ) @@ -1683,7 +1683,7 @@ pub async fn get_student_progress_stats( .await .unwrap_or(0); - // 2. Completed Lessons + // 2. Lecciones completadas let completed_lessons: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3", ) @@ -1694,7 +1694,7 @@ pub async fn get_student_progress_stats( .await .unwrap_or(0); - // 3. Daily Progress (Last 30 days) + // 3. Progreso diario (Últimos 30 días) let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>( r#" SELECT @@ -1714,7 +1714,7 @@ pub async fn get_student_progress_stats( .await .unwrap_or_default(); - // 4. Prediction Logic + // 4. Lógica de predicción let first_entry: Option> = sqlx::query_scalar( "SELECT MIN(created_at) FROM user_grades WHERE user_id = $1 AND course_id = $2" ) @@ -1759,7 +1759,7 @@ pub async fn get_advanced_analytics( State(pool): State, Path(course_id): Path, ) -> Result, StatusCode> { - // 1. Cohort Analysis using DB function + // 1. Análisis de cohorte usando función de BD let cohort_data = sqlx::query_as::<_, common::models::CohortData>( "SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1, $2)", ) @@ -1772,7 +1772,7 @@ pub async fn get_advanced_analytics( StatusCode::INTERNAL_SERVER_ERROR })?; - // 2. Retention Analysis using DB function + // 2. Análisis de retención usando función de BD let retention_data = sqlx::query_as::<_, common::models::RetentionData>( "SELECT lesson_id, lesson_title, student_count, completion_rate FROM fn_get_retention_data($1, $2)", ) @@ -1911,7 +1911,7 @@ pub async fn check_deadlines_and_notify(pool: PgPool) { .await; if let Err(e) = result { - tracing::error!("Failed to run deadline notifications: {}", e); + tracing::error!("Error al ejecutar las notificaciones de fecha límite: {}", e); } } @@ -1923,7 +1923,7 @@ pub async fn toggle_bookmark( ) -> Result { let user_id = claims.sub; - // 1. Get course_id from lesson + // 1. Obtener course_id de la lección let course_id: Uuid = sqlx::query_scalar( "SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1" ) @@ -1932,7 +1932,7 @@ pub async fn toggle_bookmark( .await .map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".to_string()))?; - // 2. Check if already bookmarked + // 2. Comprobar si ya está marcado como favorito let existing_id: Option = sqlx::query_scalar( "SELECT id FROM user_bookmarks WHERE user_id = $1 AND lesson_id = $2" ) @@ -1943,7 +1943,7 @@ pub async fn toggle_bookmark( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if let Some(id) = existing_id { - // Remove bookmark + // Eliminar marcador sqlx::query("DELETE FROM user_bookmarks WHERE id = $1") .bind(id) .execute(&pool) @@ -1951,7 +1951,7 @@ pub async fn toggle_bookmark( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(StatusCode::NO_CONTENT) } else { - // Add bookmark + // Añadir marcador sqlx::query( "INSERT INTO user_bookmarks (organization_id, user_id, course_id, lesson_id) VALUES ($1, $2, $3, $4)" ) @@ -2041,7 +2041,7 @@ pub async fn get_recommendations( let user_id = claims.sub; - // 1. Fetch performance data (recent grades) + // 1. Obtener datos de desempeño (calificaciones recientes) let grades: Vec = sqlx::query_as::<_, common::models::UserGrade>( "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2 ORDER BY created_at DESC LIMIT 10" ) @@ -2051,7 +2051,7 @@ pub async fn get_recommendations( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 2. Fetch lesson metadata (titles and tags) for context + // 2. Obtener metadatos de la lección (títulos y etiquetas) para contexto #[derive(sqlx::FromRow)] struct LessonContext { id: Uuid, @@ -2059,10 +2059,10 @@ pub async fn get_recommendations( metadata: Option>, } - // We need to join with modules to filter by course_id because lessons table doesn't always have course_id in all schemas (it references module) - // But the current query uses `WHERE course_id = $1`. Let's assume the schema is correct or update the query if needed. - // Based on previous context, lessons table has `module_id`. It might not have `course_id` directly unless denormalized. - // However, the existing code I'm replacing used `FROM lessons WHERE course_id = $1`. If that worked, I'll keep it. + // Necesitamos unir con módulos para filtrar por course_id porque la tabla de lecciones no siempre tiene course_id en todos los esquemas (hace referencia al módulo) + // Pero la consulta actual usa `WHERE course_id = $1`. Asumiremos que el esquema es correcto o actualizaremos la consulta si es necesario. + // Basado en el contexto anterior, la tabla de lecciones tiene `module_id`. Podría no tener `course_id` directamente a menos que esté desnormalizada. + // Sin embargo, el código existente que estoy modificando usaba `WHERE course_id = $1`. Si eso funcionaba, lo mantendré. // Wait, the previous `get_recommendations` used: `SELECT id, title FROM lessons WHERE course_id = $1`. // Let's verify schema. If `course_id` exists on lessons, good. If not, it might error. // Given the migration 20260115000001_add_org_to_all_tables.sql might have added some fields, but `course_id` is usually on modules. @@ -2083,9 +2083,9 @@ pub async fn get_recommendations( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3. Prepare AI context with Skills Analysis + // 3. Preparar contexto de IA con Análisis de Habilidades use std::collections::HashMap; - let mut skill_scores: HashMap = HashMap::new(); // Tag -> (Total Score, Count) + let mut skill_scores: HashMap = HashMap::new(); // Etiqueta -> (Puntaje Total, Conteo) let mut performance_summary = String::new(); for grade in &grades { @@ -2133,9 +2133,9 @@ pub async fn get_recommendations( performance_summary.push_str(&skills_summary); } - // 4. Call Ollama + // 4. Llamar a Ollama let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); - // Keep AI requests below proxy timeout so we can return fallback JSON instead of 504. + // Mantener las solicitudes de IA por debajo del tiempo de espera del proxy para que podamos devolver un JSON de respaldo en lugar de un 504. let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(45)) @@ -2222,7 +2222,7 @@ pub async fn evaluate_audio_response( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Check token limit before proceeding (estimate 1500 tokens for audio evaluation) + // Comprobar el límite de tokens antes de continuar (se estiman 1500 tokens para la evaluación de audio) if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 1500).await { return Err((StatusCode::TOO_MANY_REQUESTS, "Token limit exceeded".to_string())); } @@ -2375,7 +2375,7 @@ pub async fn evaluate_audio_file( .await .map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?; - // 1. Send to Whisper + // 1. Enviar a Whisper let whisper_url = env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); let client = reqwest::Client::new(); @@ -2402,7 +2402,7 @@ pub async fn evaluate_audio_file( if !response.status().is_success() { let err_body = response.text().await.unwrap_or_default(); - tracing::error!("Whisper error: {}", err_body); + tracing::error!("Error de Whisper: {}", err_body); return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de Whisper: {}", err_body), @@ -2439,7 +2439,7 @@ pub async fn evaluate_audio_file( .collect() }; - // 2. Perform AI Grading + // 2. Realizar calificación por IA let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let (url, auth_header, model) = if provider == "local" { let base_url = @@ -2512,12 +2512,12 @@ pub async fn evaluate_audio_file( ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?; grading.transcript = Some(transcript.clone()); - // 3. Save audio response to database - // Determine status based on evaluation + // 3. Guardar respuesta de audio en la base de datos + // Determinar estado basado en la evaluación let status = "ai_evaluated"; let response_id = Uuid::new_v4(); - // Get attempt number (check if there's a previous response for this block) + // Obtener número de intento (comprobar si hay una respuesta previa para este bloque) let attempt_number: i32 = sqlx::query_scalar( "SELECT COALESCE(MAX(attempt_number), 0) + 1 FROM audio_responses WHERE user_id = $1 AND lesson_id = $2 AND block_id = $3" ) @@ -2528,7 +2528,7 @@ pub async fn evaluate_audio_file( .await .unwrap_or(1); - // Store in S3 when configured; otherwise keep legacy DB storage for compatibility. + // Almacenar en S3 cuando esté configurado; de lo contrario, mantener almacenamiento en BD heredado por compatibilidad. let mut audio_url: Option = None; let mut audio_data_db: Option> = None; @@ -2563,7 +2563,7 @@ pub async fn evaluate_audio_file( if put_result.is_ok() { audio_url = Some(build_s3_audio_public_url(&settings, &key)); } else { - // Fallback to DB storage if S3 upload fails. + // Respaldo al almacenamiento en BD si falla la carga en S3. audio_data_db = Some( base64::engine::general_purpose::STANDARD .encode(&audio_data) @@ -2616,7 +2616,7 @@ pub async fn evaluate_audio_file( Ok(Json(grading)) } -// ==================== AUDIO RESPONSE TEACHER ENDPOINTS ==================== +// ==================== ENDPOINTS DE PROFESOR PARA RESPUESTA DE AUDIO ==================== #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct AudioResponseListItem { @@ -2680,22 +2680,22 @@ async fn instructor_has_course_access( Ok(has_access) } -/// Get all audio responses for teachers -/// Filters: course_id, lesson_id, status (pending, ai_evaluated, teacher_evaluated, both_evaluated), user_id +/// Obtener todas las respuestas de audio para profesores +/// Filtros: course_id, lesson_id, estado (pending, ai_evaluated, teacher_evaluated, both_evaluated), user_id pub async fn get_audio_responses( Org(org_ctx): Org, claims: Claims, State(pool): State, Query(filters): Query, ) -> Result>, StatusCode> { - // Only instructors and admins can access + // Solo instructores y administradores pueden acceder if claims.role != "admin" && claims.role != "instructor" { return Err(StatusCode::FORBIDDEN); } let is_instructor = claims.role == "instructor"; - // Use static query with optional filters + instructor scoping + // Usar consulta estática con filtros opcionales + alcance de instructor let responses = sqlx::query_as::<_, AudioResponseListItem>( r#" SELECT @@ -2761,14 +2761,14 @@ pub async fn get_audio_responses( Ok(Json(responses)) } -/// Get single audio response with full details including audio data +/// Obtener una única respuesta de audio con detalles completos, incluidos los datos de audio pub async fn get_audio_response_detail( Org(org_ctx): Org, claims: Claims, State(pool): State, Path(response_id): Path, ) -> Result, StatusCode> { - // Only instructors and admins can access + // Solo instructores y administradores pueden acceder if claims.role != "admin" && claims.role != "instructor" { return Err(StatusCode::FORBIDDEN); } @@ -2824,7 +2824,7 @@ pub async fn get_audio_response_detail( } } -/// Get audio data as base64 for playback +/// Obtener datos de audio como base64 para reproducción pub async fn get_audio_response_audio( Org(org_ctx): Org, claims: Claims, @@ -2835,7 +2835,7 @@ pub async fn get_audio_response_audio( return Err(StatusCode::FORBIDDEN); } - // Only instructors, admins, and the owner can access + // Solo instructores, administradores y el propietario pueden acceder let row: Option<(Option>, Option, Uuid, Uuid)> = sqlx::query_as( "SELECT audio_data, audio_url, user_id, course_id FROM audio_responses WHERE id = $1 AND organization_id = $2" ) @@ -2850,7 +2850,7 @@ pub async fn get_audio_response_audio( match row { Some((audio_data, audio_url, owner_user_id, course_id)) => { - // Access rules: admin always, instructor only their courses, student only own response. + // Reglas de acceso: administrador siempre, instructor solo sus cursos, estudiante solo su propia respuesta. if claims.role == "student" && claims.sub != owner_user_id { return Err(StatusCode::FORBIDDEN); } @@ -2861,7 +2861,7 @@ pub async fn get_audio_response_audio( } if let Some(data) = audio_data { - // Legacy path: DB contains base64 bytes. + // Ruta heredada: la BD contiene bytes base64. let audio_bytes = base64::engine::general_purpose::STANDARD .decode(&data) .unwrap_or(data); @@ -2946,7 +2946,7 @@ async fn read_audio_response_from_url(url: &str) -> Result<(Vec, String), St Ok((bytes, content_type)) } -/// Teacher evaluates an audio response +/// El profesor evalúa una respuesta de audio pub async fn teacher_evaluate_audio( Org(org_ctx): Org, claims: Claims, @@ -2954,17 +2954,17 @@ pub async fn teacher_evaluate_audio( Path(response_id): Path, Json(payload): Json, ) -> Result, StatusCode> { - // Only instructors and admins can evaluate + // Solo instructores y administradores pueden evaluar if claims.role != "admin" && claims.role != "instructor" { return Err(StatusCode::FORBIDDEN); } - // Validate score + // Validar puntaje if payload.teacher_score < 0 || payload.teacher_score > 100 { return Err(StatusCode::BAD_REQUEST); } - // Get current response to determine new status + // Obtener respuesta actual para determinar el nuevo estado let response_meta: Option<(String, Uuid)> = sqlx::query_as( "SELECT status::text, course_id FROM audio_responses WHERE id = $1 AND organization_id = $2" ) @@ -2986,14 +2986,14 @@ pub async fn teacher_evaluate_audio( return Err(StatusCode::FORBIDDEN); } - // Determine new status + // Determinar nuevo estado let new_status = if current_status == "ai_evaluated" { "both_evaluated" } else { "teacher_evaluated" }; - // Update the response + // Actualizar la respuesta let updated = sqlx::query( r#" UPDATE audio_responses @@ -3030,14 +3030,14 @@ pub async fn teacher_evaluate_audio( } } -/// Get audio response statistics for a course +/// Obtener estadísticas de respuestas de audio para un curso pub async fn get_audio_response_stats( Org(org_ctx): Org, claims: Claims, State(pool): State, Path(course_id): Path, ) -> Result, StatusCode> { - // Only instructors and admins can access + // Solo instructores y administradores pueden acceder if claims.role != "admin" && claims.role != "instructor" { return Err(StatusCode::FORBIDDEN); } @@ -3125,9 +3125,9 @@ pub async fn get_code_hint( Path(lesson_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { - tracing::info!("Code hint request for lesson_id={}", lesson_id); + tracing::info!("Solicitud de pista de código para lesson_id={}", lesson_id); - // Access check: enrolled or preview + // Verificación de acceso: inscrito o vista previa let is_enrolled = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM enrollments WHERE user_id = $1 AND course_id = (SELECT course_id FROM modules WHERE id = (SELECT module_id FROM lessons WHERE id = $2)))" ) @@ -3240,7 +3240,7 @@ pub async fn chat_with_tutor( return Err((StatusCode::TOO_MANY_REQUESTS, "Monthly AI token limit exceeded. Please contact your administrator.".to_string())); } - // 1. Fetch lesson context with access check (matches get_lesson_content) + // 1. Obtener contexto de la lección con verificación de acceso (coincide con get_lesson_content) let is_preview = claims.token_type.as_deref() == Some("preview"); let lesson = if is_preview { @@ -3271,7 +3271,7 @@ pub async fn chat_with_tutor( })? .ok_or((StatusCode::NOT_FOUND, "Lección no encontrada o acceso denegado".into()))?; - // 1.5 Fetch previous lessons in the course for context + // 1.5 Obtener lecciones anteriores del curso para contexto let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1") .bind(lesson.module_id) .fetch_one(&pool) @@ -3307,13 +3307,13 @@ pub async fn chat_with_tutor( let mut history_context = String::new(); if !previous_lessons.is_empty() { - history_context.push_str("\n--- PAST LESSONS HISTORY (FOR CONTEXT) ---\n"); + history_context.push_str("\n--- HISTORIAL DE LECCIONES PASADAS (PARA CONTEXTO) ---\n"); for prev in previous_lessons { use sqlx::Row; let title: String = prev.get("title"); let summary: Option = prev.get("summary"); history_context.push_str(&format!( - "Past Lesson: {}\nSummary: {}\n\n", + "Lección Pasada: {}\nResumen: {}\n\n", title, summary.as_deref().unwrap_or("No hay resumen disponible.") )); @@ -3335,11 +3335,11 @@ pub async fn chat_with_tutor( history_context ); - // 2. Setup AI request + // 2. Configurar solicitud de IA let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let _client = reqwest::Client::new(); - // 2.1 Handle Session and Memory + // 2.1 Manejar Sesión y Memoria let session_id = if let Some(sid) = payload.session_id { sid } else { @@ -3362,7 +3362,7 @@ pub async fn chat_with_tutor( sid }; - // Save user message + // Guardar mensaje del usuario sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)") .bind(session_id) .bind("user") @@ -3376,7 +3376,7 @@ pub async fn chat_with_tutor( ) })?; - // Fetch last 6 messages for context + // Obtener los últimos 6 mensajes para contexto let history_rows = sqlx::query( "SELECT role, content FROM chat_messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT 6" ) @@ -3396,16 +3396,16 @@ pub async fn chat_with_tutor( } } - // 2.2 Knowledge Base Retrieval (RAG) - Hybrid Search - // First try semantic search with embeddings (more accurate) - // Fall back to full-text search if embeddings not available + // 2.2 Recuperación de Base de Conocimientos (RAG) - Búsqueda Híbrida + // Primero intentar búsqueda semántica con embeddings (más precisa) + // Recurrir a la búsqueda de texto completo si los embeddings no están disponibles use common::ai::{self, generate_embedding}; let mut kb_context = String::new(); - // Try semantic search with embeddings first - // Create client that accepts invalid certificates (for dev with self-signed certs) + // Intentar búsqueda semántica con embeddings primero + // Crear cliente que acepte certificados inválidos (para desarrollo con certificados autofirmados) let client = reqwest::Client::builder() .danger_accept_invalid_certs(true) .danger_accept_invalid_hostnames(true) @@ -3422,7 +3422,7 @@ pub async fn chat_with_tutor( Ok(response) => { let pgvector = ai::embedding_to_pgvector(&response.embedding); - // Semantic search with pgvector + // Búsqueda semántica con pgvector let search_results = sqlx::query( r#" SELECT content_chunk, 1 - (embedding <=> $1::vector) AS similarity @@ -3439,7 +3439,7 @@ pub async fn chat_with_tutor( .await .unwrap_or_default(); - // Filter by similarity threshold (0.5) + // Filtrar por umbral de similitud (0.5) let relevant_results: Vec<_> = search_results .into_iter() .filter(|row| { @@ -3459,7 +3459,7 @@ pub async fn chat_with_tutor( Err(e) => { tracing::warn!("Semantic search failed, falling back to full-text search: {}", e); - // Fall back to full-text search + // Recurrir a la búsqueda de texto completo let search_results = sqlx::query( r#" SELECT content_chunk @@ -3562,7 +3562,7 @@ pub async fn chat_with_tutor( .unwrap_or("Lo siento, tuve un problema procesando tu pregunta.") .to_string(); - // Calculate and log token usage + // Calcular y registrar el uso de tokens let input_tokens = count_tokens(&system_prompt) + count_tokens(&payload.message); let output_tokens = count_tokens(&tutor_response); let total_tokens = input_tokens + output_tokens; @@ -3586,7 +3586,7 @@ pub async fn chat_with_tutor( .execute(&pool) .await; - // Save assistant response + // Guardar respuesta del asistente let _ = sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)") .bind(session_id) @@ -3609,7 +3609,7 @@ pub async fn chat_role_play( Json(payload): Json, ) -> Result, (StatusCode, String)> { tracing::info!("Chat Role Play: lesson_id={}, org_id={}, user_id={}, role={}", lesson_id, org_ctx.id, claims.sub, claims.role); - // 1. Fetch lesson with access check (matches get_lesson_content logic) + // 1. Obtener lección con verificación de acceso (coincide con la lógica de get_lesson_content) let is_preview = claims.token_type.as_deref() == Some("preview"); let lesson = if is_preview { @@ -3640,7 +3640,7 @@ pub async fn chat_role_play( })? .ok_or((StatusCode::NOT_FOUND, "Lección no encontrada o acceso denegado".into()))?; - // 2. Find the specific role-playing block in metadata or content_blocks + // 2. Encontrar el bloque específico de juego de rol en metadata o content_blocks let blocks = lesson.content_blocks .or_else(|| lesson.metadata.as_ref().and_then(|m| m.get("blocks").cloned())) .and_then(|b| b.as_array().cloned()) @@ -3655,10 +3655,10 @@ pub async fn chat_role_play( let user_role = block.get("user_role").and_then(|s| s.as_str()).unwrap_or(""); let objectives = block.get("objectives").and_then(|s| s.as_str()).unwrap_or(""); - // Try to parse block_id as Uuid for DB storage, if it's not a Uuid (legacy), we store None + // Intentar analizar block_id como Uuid para almacenamiento en BD; si no es un Uuid (heredado), almacenamos None let block_uuid = Uuid::parse_str(&payload.block_id).ok(); - // 3. Handle Session + // 3. Manejar Sesión let session_id = if let Some(sid) = payload.session_id { sid } else { @@ -3681,7 +3681,7 @@ pub async fn chat_role_play( row.get::(0) }; - // 4. Save user message + // 4. Guardar mensaje del usuario sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)") .bind(session_id) .bind("user") @@ -3690,7 +3690,7 @@ pub async fn chat_role_play( .await .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al guardar el mensaje del usuario".into()))?; - // 5. Fetch history (last 10 messages for context) + // 5. Obtener historial (últimos 10 mensajes para contexto) let history_rows = sqlx::query( "SELECT role, content FROM chat_messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT 10" ) @@ -3707,7 +3707,7 @@ pub async fn chat_role_play( conversation_history.push_str(&format!("{}: {}\n", role.to_uppercase(), content)); } - // 6. AI Request + // 6. Solicitud de IA let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); @@ -3760,7 +3760,7 @@ pub async fn chat_role_play( let ai_data: serde_json::Value = response.json().await.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al analizar la respuesta de la IA".into()))?; let ai_response = ai_data["choices"][0]["message"]["content"].as_str().unwrap_or("Lo siento, tuve un problema procesando la simulación.").to_string(); - // 7. Save assistant response + // 7. Guardar respuesta del asistente let _ = sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)") .bind(session_id) .bind("assistant") @@ -3781,7 +3781,7 @@ pub async fn get_lesson_feedback( ) -> Result, (StatusCode, String)> { let user_id = claims.sub; - // 1. Fetch lesson context + // 1. Obtener contexto de la lección let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") .bind(lesson_id) @@ -3790,7 +3790,7 @@ pub async fn get_lesson_feedback( .await .map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?; - // 2. Fetch user's grade for this lesson + // 2. Obtener la calificación del usuario para esta lección let grade = sqlx::query_as::<_, common::models::UserGrade>( "SELECT * FROM user_grades WHERE user_id = $1 AND lesson_id = $2", ) @@ -3806,7 +3806,7 @@ pub async fn get_lesson_feedback( let score_pct = (grade.score * 100.0) as i32; - // Reuse cached feedback when it was generated for the same attempt count. + // Reutilizar retroalimentación almacenada cuando fue generada para el mismo conteo de intentos. if let Some(metadata) = grade.metadata.as_ref() { let cached_attempts = metadata .get("ai_lesson_feedback_attempts") @@ -3845,7 +3845,7 @@ pub async fn get_lesson_feedback( block_content ); - // 3. Setup AI request + // 3. Configurar solicitud de IA let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); @@ -3934,7 +3934,7 @@ pub async fn get_lesson_feedback( } }; - // Persist feedback so repeated views do not trigger AI every time. + // Persistir retroalimentación para que las visualizaciones repetidas no activen la IA cada vez. let mut new_metadata = grade.metadata.clone().unwrap_or_else(|| json!({})); if !new_metadata.is_object() { new_metadata = json!({}); @@ -3979,7 +3979,7 @@ pub async fn ingest_lesson_knowledge( lesson_id: Uuid, content: &str, ) -> Result<(), sqlx::Error> { - // Split content into chunks of ~1000 characters for better RAG granularity + // Dividir contenido en fragmentos de ~1000 caracteres para una mejor granularidad de RAG let chunks: Vec<&str> = content .as_bytes() .chunks(1000) @@ -4013,7 +4013,7 @@ fn extract_block_content(metadata: &Option) -> String { let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); let title = block.get("title").and_then(|t| t.as_str()).unwrap_or(""); - block_content.push_str(&format!("\n--- Block: {} ({}) ---\n", title, block_type)); + block_content.push_str(&format!("\n--- Bloque: {} ({}) ---\n", title, block_type)); match block_type { "description" | "fill-in-the-blanks" => { @@ -4040,7 +4040,7 @@ fn extract_block_content(metadata: &Option) -> String { let left = p.get("left").and_then(|l| l.as_str()).unwrap_or(""); let right = p.get("right").and_then(|r| r.as_str()).unwrap_or(""); block_content.push_str(&format!( - "Pair {}: {} <-> {}\n", + "Par {}: {} <-> {}\n", i + 1, left, right @@ -4071,19 +4071,19 @@ fn extract_block_content(metadata: &Option) -> String { if let Some(instructions) = block.get("instructions").and_then(|i| i.as_str()) { - block_content.push_str(&format!("Instructions: {}\n", instructions)); + block_content.push_str(&format!("Instrucciones: {}\n", instructions)); } } "document" => { if let Some(desc) = block.get("description").and_then(|d| d.as_str()) { - block_content.push_str(&format!("Document Description: {}\n", desc)); + block_content.push_str(&format!("Descripción del Documento: {}\n", desc)); } } "hotspot" => { if let Some(description) = block.get("description").and_then(|d| d.as_str()) { block_content.push_str(&format!( - "Hotspot Activity Description: {}\n", + "Descripción de la Actividad de Hotspot: {}\n", description )); } diff --git a/services/lms-service/src/handlers_announcements.rs b/services/lms-service/src/handlers_announcements.rs index 05ba8e2..df81725 100644 --- a/services/lms-service/src/handlers_announcements.rs +++ b/services/lms-service/src/handlers_announcements.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; -// ========== Request/Response DTOs ========== +// ========== DTOs de Solicitud/Respuesta ========== #[derive(Deserialize)] pub struct CreateAnnouncementPayload { @@ -27,7 +27,7 @@ pub struct UpdateAnnouncementPayload { pub is_pinned: Option, } -// ========== HANDLERS ========== +// ========== MANEJADORES ========== pub async fn list_announcements( Org(org_ctx): Org, @@ -50,7 +50,7 @@ pub async fn list_announcements( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Attach cohort_ids to each announcement + // Adjuntar cohort_ids a cada anuncio for a in &mut announcements { let cohorts: Vec<(Uuid,)> = sqlx::query_as( "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1" @@ -75,17 +75,17 @@ pub async fn create_announcement( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Check if user is instructor or admin + // Verificar si el usuario es instructor o administrador let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + .map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, - "Only instructors can create announcements".to_string(), + "Solo los instructores pueden crear anuncios".to_string(), )); } @@ -94,7 +94,7 @@ pub async fn create_announcement( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 1. Create announcement + // 1. Crear anuncio let mut announcement = sqlx::query_as::<_, CourseAnnouncement>( "INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned) VALUES ($1, $2, $3, $4, $5, $6) @@ -110,7 +110,7 @@ pub async fn create_announcement( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 2. Link cohorts if provided + // 2. Vincular cohortes si se proporcionan if let Some(ref cohort_ids) = payload.cohort_ids { for cohort_id in cohort_ids { sqlx::query( @@ -129,7 +129,7 @@ pub async fn create_announcement( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3. Get target students for notifications + // 3. Obtener estudiantes objetivo para notificaciones let enrolled_students = if let Some(ref cohort_ids) = payload.cohort_ids { if !cohort_ids.is_empty() { sqlx::query_as::<_, (Uuid,)>( @@ -144,7 +144,7 @@ pub async fn create_announcement( .fetch_all(&pool) .await } else { - // Fallback to everyone if empty list provided (though UI should prevent) + // Recurrir a todos si se proporciona una lista vacía (aunque la interfaz debería evitarlo) sqlx::query_as::<_, (Uuid,)>( "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2", ) @@ -154,7 +154,7 @@ pub async fn create_announcement( .await } } else { - // No segment provided -> everyone in the course + // No se proporcionó segmento -> todos en el curso sqlx::query_as::<_, (Uuid,)>( "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2", ) @@ -165,7 +165,7 @@ pub async fn create_announcement( } .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Create notification for each enrolled student + // Crear notificación para cada estudiante inscrito for (student_id,) in enrolled_students { let notification_title = format!("Nuevo Anuncio: {}", payload.title); let notification_message = if payload.content.len() > 100 { @@ -198,21 +198,21 @@ pub async fn update_announcement( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Check if user is instructor or admin + // Verificar si el usuario es instructor o administrador let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + .map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, - "Only instructors can update announcements".to_string(), + "Solo los instructores pueden actualizar anuncios".to_string(), )); } - // Get current announcement to verify ownership + // Obtener el anuncio actual para verificar la propiedad let current = sqlx::query_as::<_, CourseAnnouncement>( "SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2", ) @@ -220,7 +220,7 @@ pub async fn update_announcement( .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Announcement not found".to_string()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Anuncio no encontrado".to_string()))?; let title = payload.title.unwrap_or(current.title); let content = payload.content.unwrap_or(current.content); @@ -250,17 +250,17 @@ pub async fn delete_announcement( Path(announcement_id): Path, State(pool): State, ) -> Result { - // Check if user is instructor or admin + // Verificar si el usuario es instructor o administrador let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + .map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, - "Only instructors can delete announcements".to_string(), + "Solo los instructores pueden eliminar anuncios".to_string(), )); } diff --git a/services/lms-service/src/handlers_cohorts.rs b/services/lms-service/src/handlers_cohorts.rs index 2e188b8..d8d3059 100644 --- a/services/lms-service/src/handlers_cohorts.rs +++ b/services/lms-service/src/handlers_cohorts.rs @@ -55,7 +55,7 @@ pub async fn add_cohort_member( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Verify cohort belongs to org + // Verificar que la cohorte pertenece a la organización let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)", ) @@ -66,7 +66,7 @@ pub async fn add_cohort_member( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !exists { - return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string())); } let member = sqlx::query_as::<_, UserCohort>( @@ -92,7 +92,7 @@ pub async fn remove_cohort_member( Path((cohort_id, user_id)): Path<(Uuid, Uuid)>, State(pool): State, ) -> Result { - // Verify cohort belongs to org + // Verificar que la cohorte pertenece a la organización let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)", ) @@ -103,7 +103,7 @@ pub async fn remove_cohort_member( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !exists { - return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string())); } sqlx::query("DELETE FROM user_cohorts WHERE cohort_id = $1 AND user_id = $2") @@ -122,7 +122,7 @@ pub async fn get_cohort_members( Path(cohort_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { - // Verify cohort belongs to org + // Verificar que la cohorte pertenece a la organización let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)", ) @@ -133,7 +133,7 @@ pub async fn get_cohort_members( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !exists { - return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string())); } let members = sqlx::query_scalar("SELECT user_id FROM user_cohorts WHERE cohort_id = $1") diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index 7e06263..ba26368 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; -// ========== Request/Response DTOs ========== +// ========== DTOs de Solicitud/Respuesta ========== #[derive(Deserialize)] pub struct CreateThreadPayload { @@ -37,7 +37,7 @@ pub struct ThreadListQuery { pub page: Option, } -// ========== THREAD HANDLERS ========== +// ========== MANEJADORES DE HILOS ========== pub async fn list_threads( Org(org_ctx): Org, @@ -141,7 +141,7 @@ pub async fn create_thread( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Auto-subscribe author to thread + // Suscribir automáticamente al autor al hilo let _ = sqlx::query( "INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id) VALUES ($1, $2, $3) @@ -162,13 +162,13 @@ pub async fn get_thread_detail( Path(thread_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { - // Increment view count + // Incrementar el conteo de visualizaciones let _ = sqlx::query("UPDATE discussion_threads SET view_count = view_count + 1 WHERE id = $1") .bind(thread_id) .execute(&pool) .await; - // Get thread with author info + // Obtener el hilo con información del autor let thread = sqlx::query_as::<_, ThreadWithAuthor>( "SELECT t.*, @@ -186,9 +186,9 @@ pub async fn get_thread_detail( .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?; - // Get all posts with author info and user votes + // Obtener todos los mensajes con información del autor y votos del usuario let posts = get_thread_posts_recursive(&pool, thread_id, None, claims.sub, org_ctx.id).await?; Ok(Json(serde_json::json!({ @@ -197,7 +197,7 @@ pub async fn get_thread_detail( }))) } -// Recursive function to build nested post tree +// Función recursiva para construir el árbol de mensajes anidados fn get_thread_posts_recursive<'a>( pool: &'a PgPool, thread_id: Uuid, @@ -244,7 +244,7 @@ fn get_thread_posts_recursive<'a>( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Recursively fetch replies for each post + // Obtener respuestas recursivamente para cada mensaje for post in &mut posts { post.replies = get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?; @@ -260,17 +260,17 @@ pub async fn pin_thread( Path(thread_id): Path, State(pool): State, ) -> Result { - // Check if user is instructor + // Verificar si el usuario es instructor o administrador let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + .map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, - "Only instructors can pin threads".to_string(), + "Solo los instructores pueden fijar hilos".to_string(), )); } @@ -290,17 +290,17 @@ pub async fn lock_thread( Path(thread_id): Path, State(pool): State, ) -> Result { - // Check if user is instructor + // Verificar si el usuario es instructor o administrador let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + .map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, - "Only instructors can lock threads".to_string(), + "Solo los instructores pueden bloquear hilos".to_string(), )); } @@ -314,7 +314,7 @@ pub async fn lock_thread( Ok(StatusCode::OK) } -// ========== POST HANDLERS ========== +// ========== MANEJADORES DE MENSAJES ========== pub async fn create_post( Org(org_ctx): Org, @@ -323,13 +323,13 @@ pub async fn create_post( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Check if thread is locked + // Verificar si el hilo está bloqueado let thread = sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1") .bind(thread_id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?; if thread.0 { return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string())); @@ -349,7 +349,7 @@ pub async fn create_post( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // TODO: Send notifications to subscribed users + // TODO: Enviar notificaciones a los usuarios suscritos Ok(Json(post)) } @@ -360,17 +360,17 @@ pub async fn endorse_post( Path(post_id): Path, State(pool): State, ) -> Result { - // Check if user is instructor + // Verificar si el usuario es instructor o administrador let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + .map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, - "Only instructors can endorse posts".to_string(), + "Solo los instructores pueden recomendar mensajes".to_string(), )); } @@ -395,7 +395,7 @@ pub async fn vote_post( return Err((StatusCode::BAD_REQUEST, "Tipo de voto inválido".to_string())); } - // Upsert vote + // Upsert de voto sqlx::query( "INSERT INTO discussion_votes (organization_id, post_id, user_id, vote_type) VALUES ($1, $2, $3, $4) @@ -410,7 +410,7 @@ pub async fn vote_post( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Recalculate upvotes + // Recalcular votos positivos let upvote_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'", ) @@ -429,7 +429,7 @@ pub async fn vote_post( Ok(StatusCode::OK) } -// ========== SUBSCRIPTION HANDLERS ========== +// ========== MANEJADORES DE SUSCRIPCIONES ========== pub async fn subscribe_thread( Org(org_ctx): Org, diff --git a/services/lms-service/src/handlers_embeddings.rs b/services/lms-service/src/handlers_embeddings.rs index 34b45fd..07b011b 100644 --- a/services/lms-service/src/handlers_embeddings.rs +++ b/services/lms-service/src/handlers_embeddings.rs @@ -1,5 +1,5 @@ -//! Handlers for PGVector embeddings in Knowledge Base (LMS) -//! Enables semantic search for AI tutor chat with RAG +//! Manejadores para embeddings de PGVector en la Base de Conocimientos (LMS) +//! Habilita la búsqueda semántica para el chat del tutor de IA con RAG use axum::{ Json, @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -// ==================== Query Parameters ==================== +// ==================== Parámetros de Consulta ==================== #[derive(Debug, Deserialize)] pub struct KnowledgeSearchFilters { @@ -41,7 +41,7 @@ pub struct GenerateKnowledgeEmbeddingsResult { pub duration_ms: u64, } -// ==================== Generate Embeddings ==================== +// ==================== Generar Embeddings ==================== /// POST /api/knowledge-base/embeddings/generate - Generate embeddings for all knowledge base entries pub async fn generate_knowledge_embeddings( @@ -50,7 +50,7 @@ pub async fn generate_knowledge_embeddings( ) -> Result, (StatusCode, String)> { let start = std::time::Instant::now(); - // Create client that accepts invalid certificates (for dev with self-signed certs) + // Crear cliente que acepte certificados inválidos (para desarrollo con certificados autofirmados) let client = reqwest::Client::builder() .danger_accept_invalid_certs(true) .danger_accept_invalid_hostnames(true) @@ -60,7 +60,7 @@ pub async fn generate_knowledge_embeddings( let ollama_url = ai::get_ollama_url(); let model = ai::get_embedding_model(); - // Get knowledge base entries without embeddings + // Obtener entradas de la base de conocimientos sin embeddings let entries: Vec = sqlx::query_as( r#" SELECT * FROM knowledge_base @@ -80,12 +80,12 @@ pub async fn generate_knowledge_embeddings( let mut failed = 0; for entry in entries { - // Generate embedding from content chunk + // Generar embedding desde el fragmento de contenido match generate_embedding(&client, &ollama_url, &model, &entry.content_chunk).await { Ok(response) => { let pgvector = ai::embedding_to_pgvector(&response.embedding); - // Update entry with embedding + // Actualizar entrada con embedding let result: Result<(i64,), sqlx::Error> = sqlx::query_as( r#" UPDATE knowledge_base @@ -108,7 +108,7 @@ pub async fn generate_knowledge_embeddings( } Err(e) => { tracing::error!( - "Failed to generate embedding for knowledge entry {}: {}", + "Error al generar el embedding para la entrada de conocimiento {}: {}", entry.id, e ); @@ -133,13 +133,13 @@ pub async fn generate_knowledge_embeddings( })) } -/// POST /api/knowledge-base/{id}/embedding/regenerate - Regenerate embedding for a specific entry +/// POST /api/knowledge-base/{id}/embedding/regenerate - Regenerar embedding para una entrada específica pub async fn regenerate_knowledge_embedding( Org(org_ctx): Org, Path(entry_id): Path, State(pool): State, ) -> Result { - // Create client that accepts invalid certificates + // Crear cliente que acepte certificados inválidos let client = reqwest::Client::builder() .danger_accept_invalid_certs(true) .danger_accept_invalid_hostnames(true) @@ -149,7 +149,7 @@ pub async fn regenerate_knowledge_embedding( let ollama_url = ai::get_ollama_url(); let model = ai::get_embedding_model(); - // Get entry + // Obtener entrada let entry: KnowledgeBaseEntry = sqlx::query_as( "SELECT * FROM knowledge_base WHERE id = $1 AND organization_id = $2" ) @@ -158,16 +158,16 @@ pub async fn regenerate_knowledge_embedding( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Knowledge base entry not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Entrada de la base de conocimientos no encontrada".to_string()))?; - // Generate embedding + // Generar embedding let response = generate_embedding(&client, &ollama_url, &model, &entry.content_chunk) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?; let pgvector = ai::embedding_to_pgvector(&response.embedding); - // Update entry + // Actualizar entrada sqlx::query( r#" UPDATE knowledge_base @@ -185,7 +185,7 @@ pub async fn regenerate_knowledge_embedding( Ok(StatusCode::OK) } -// ==================== Semantic Search ==================== +// ==================== Búsqueda Semántica ==================== /// GET /api/knowledge-base/semantic-search - Search knowledge base by semantic similarity pub async fn semantic_search_knowledge( @@ -193,7 +193,7 @@ pub async fn semantic_search_knowledge( State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { - // Create client that accepts invalid certificates + // Crear cliente que acepte certificados inválidos let client = reqwest::Client::builder() .danger_accept_invalid_certs(true) .danger_accept_invalid_hostnames(true) @@ -203,7 +203,7 @@ pub async fn semantic_search_knowledge( let ollama_url = ai::get_ollama_url(); let model = ai::get_embedding_model(); - // Generate embedding for query + // Generar embedding para la consulta let embedding_response = generate_embedding(&client, &ollama_url, &model, &filters.query) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?; @@ -213,7 +213,7 @@ pub async fn semantic_search_knowledge( let limit = filters.limit.unwrap_or(10); let threshold = filters.threshold.unwrap_or(0.5); - // Build query with optional filters + // Construir consulta con filtros opcionales let mut query = String::from( r#" SELECT @@ -269,7 +269,7 @@ pub async fn semantic_search_knowledge( Ok(Json(results)) } -// ==================== Helper Structs ==================== +// ==================== Estructuras de Ayuda ==================== #[derive(Debug, sqlx::FromRow, Clone)] struct KnowledgeBaseEntry { diff --git a/services/lms-service/src/handlers_payments.rs b/services/lms-service/src/handlers_payments.rs index 32c1e4e..5d579ed 100644 --- a/services/lms-service/src/handlers_payments.rs +++ b/services/lms-service/src/handlers_payments.rs @@ -26,18 +26,18 @@ pub async fn create_payment_preference( let user_id = claims.sub; let course_id = payload.course_id; - // 1. Get Course details + // 1. Obtener detalles del curso let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") .bind(course_id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Curso no encontrado".into()))?; if course.price <= 0.0 { - return Err((StatusCode::BAD_REQUEST, "Course is free".into())); + return Err((StatusCode::BAD_REQUEST, "El curso es gratuito".into())); } - // 2. Create a pending transaction + // 2. Crear una transacción pendiente let transaction_id = Uuid::new_v4(); sqlx::query( "INSERT INTO transactions (id, organization_id, user_id, course_id, amount, currency, status) @@ -53,7 +53,7 @@ pub async fn create_payment_preference( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3. Call Mercado Pago API + // 3. Llamar a la API de Mercado Pago let mp_access_token = std::env::var("MP_ACCESS_TOKEN").unwrap_or_default(); let back_url_success = std::env::var("MP_BACK_URL_SUCCESS").unwrap_or_default(); let back_url_failure = std::env::var("MP_BACK_URL_FAILURE").unwrap_or_default(); @@ -94,7 +94,7 @@ pub async fn create_payment_preference( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("MP Error: {}", e), + format!("Error de MP: {}", e), ) })?; @@ -102,14 +102,14 @@ pub async fn create_payment_preference( let err_text = mp_response.text().await.unwrap_or_default(); return Err(( StatusCode::BAD_GATEWAY, - format!("MP API Error: {}", err_text), + format!("Error de la API de MP: {}", err_text), )); } let mp_data: serde_json::Value = mp_response.json().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to parse MP response: {}", e), + format!("Error al analizar la respuesta de MP: {}", e), ) })?; @@ -119,7 +119,7 @@ pub async fn create_payment_preference( .unwrap_or_default() .to_string(); - // Update transaction with provider reference + // Actualizar transacción con la referencia del proveedor sqlx::query("UPDATE transactions SET provider_reference = $1 WHERE id = $2") .bind(&preference_id) .bind(transaction_id) @@ -155,7 +155,7 @@ pub async fn mercadopago_webhook( let payment_id = payload.data.id; let mp_access_token = std::env::var("MP_ACCESS_TOKEN").unwrap_or_default(); - // 1. Fetch payment details from Mercado Pago to verify status + // 1. Obtener detalles del pago de Mercado Pago para verificar el estado let client = reqwest::Client::new(); let mp_response = client .get(format!( @@ -181,27 +181,27 @@ pub async fn mercadopago_webhook( .unwrap_or_default(); if status != "approved" { - return Ok(StatusCode::OK); // Payment not yet approved, wait for next notification + return Ok(StatusCode::OK); // El pago aún no ha sido aprobado, esperar a la siguiente notificación } - // 2. Find transaction by external reference (transaction_id) + // 2. Buscar transacción por referencia externa (transaction_id) let transaction: Option<(Uuid, Uuid, Uuid)> = sqlx::query_as( "SELECT id, user_id, course_id FROM transactions WHERE id = $1 OR provider_reference = $1", ) - .bind(&external_reference) // Try by external reference first + .bind(&external_reference) // Intentar por referencia externa primero .fetch_optional(&pool) .await .unwrap_or(None); if let Some((trans_id, user_id, course_id)) = transaction { - // Mark transaction as success + // Marcar transacción como exitosa sqlx::query("UPDATE transactions SET status = 'success', updated_at = NOW() WHERE id = $1") .bind(trans_id) .execute(&pool) .await .ok(); - // Auto-enroll the user + // Inscribir automáticamente al usuario sqlx::query("INSERT INTO enrollments (id, user_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING") .bind(Uuid::new_v4()) .bind(user_id) diff --git a/services/lms-service/src/handlers_peer_review.rs b/services/lms-service/src/handlers_peer_review.rs index 32cd4e2..89fe8e6 100644 --- a/services/lms-service/src/handlers_peer_review.rs +++ b/services/lms-service/src/handlers_peer_review.rs @@ -18,7 +18,7 @@ pub async fn submit_assignment( Path((course_id, lesson_id)): Path<(Uuid, Uuid)>, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Check if submission already exists + // Verificar si la entrega ya existe let existing: Option = sqlx::query_as( "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2" ) @@ -29,7 +29,7 @@ pub async fn submit_assignment( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if let Some(_) = existing { - // Update existing submission + // Actualizar entrega existente let updated: CourseSubmission = sqlx::query_as( r#" UPDATE course_submissions @@ -48,7 +48,7 @@ pub async fn submit_assignment( return Ok(Json(updated)); } - // Create new submission + // Crear nueva entrega let submission: CourseSubmission = sqlx::query_as( r#" INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content) @@ -74,10 +74,10 @@ pub async fn get_peer_review_assignment( State(pool): State, Path((course_id, lesson_id)): Path<(Uuid, Uuid)>, ) -> Result>, (StatusCode, String)> { - // Find a submission that: - // 1. Is not my own - // 2. Has fewer than 2 reviews (configurable, but hardcoded for now) - // 3. I haven't reviewed yet + // Buscar una entrega que: + // 1. No sea la mía propia + // 2. Tenga menos de 2 revisiones (configurable, pero hardcoded por ahora) + // 3. Yo no haya revisado aún let submission: Option = sqlx::query_as( r#" SELECT s.* @@ -115,7 +115,7 @@ pub async fn submit_peer_review( Path((_course_id, _lesson_id)): Path<(Uuid, Uuid)>, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Verify valid submission + // Verificar entrega válida let submission_row = sqlx::query( "SELECT user_id FROM course_submissions WHERE id = $1" ) @@ -126,17 +126,17 @@ pub async fn submit_peer_review( let submission_user_id = match submission_row { Some(row) => row.get::("user_id"), - None => return Err((StatusCode::NOT_FOUND, "Submission not found".to_string())), + None => return Err((StatusCode::NOT_FOUND, "Entrega no encontrada".to_string())), }; if submission_user_id == claims.sub { return Err(( StatusCode::BAD_REQUEST, - "Cannot review your own submission".to_string(), + "No puedes revisar tu propia entrega".to_string(), )); } - // Check if already reviewed + // Verificar si ya fue revisada let existing = sqlx::query( "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2" ) @@ -149,11 +149,11 @@ pub async fn submit_peer_review( if existing.is_some() { return Err(( StatusCode::CONFLICT, - "You have already reviewed this submission".to_string(), + "Ya has revisado esta entrega".to_string(), )); } - // Create review + // Crear revisión let review: PeerReview = sqlx::query_as( r#" INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id) @@ -179,7 +179,7 @@ pub async fn get_my_submission_feedback( State(pool): State, Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>, ) -> Result>, (StatusCode, String)> { - // Get reviews for my submission on this lesson + // Obtener revisiones para mi entrega en esta lección let reviews: Vec = sqlx::query_as( r#" SELECT pr.* diff --git a/services/lms-service/src/jwks.rs b/services/lms-service/src/jwks.rs index 30890ef..5a5cd97 100644 --- a/services/lms-service/src/jwks.rs +++ b/services/lms-service/src/jwks.rs @@ -6,23 +6,23 @@ pub fn get_lti_private_key() -> jsonwebtoken::EncodingKey { let key_str = env::var("LTI_PRIVATE_KEY").unwrap_or_else(|_| { let dev_key_path = "services/lms-service/dev_keys/lti_private.pem"; std::fs::read_to_string(dev_key_path).unwrap_or_else(|_| { - // Return a dummy key or handle error gracefully in production - // For now, we'll return a string that will likely fail decoding if used, - // but allows the service to start if LTI is not used. - tracing::warn!("LTI private key not found at {} and LTI_PRIVATE_KEY is not set.", dev_key_path); + // Devolver una clave ficticia o manejar el error de forma adecuada en producción + // Por ahora, devolveremos una cadena que probablemente fallará al decodificarse si se utiliza, + // pero permite que el servicio se inicie si no se utiliza LTI. + tracing::warn!("Clave privada LTI no encontrada en {} y LTI_PRIVATE_KEY no está establecida.", dev_key_path); String::new() }) }); if key_str.is_empty() { - // Handle the empty key case - maybe return a specialized error or a dummy key - // that fails later. jsonwebtoken::EncodingKey::from_rsa_pem usually expects valid PEM. - // We'll use a dummy valid-looking but useless PEM if it's empty to avoid panic on startup - // but it will fail on actual LTI usage. + // Manejar el caso de clave vacía; tal vez devolver un error especializado o una clave ficticia + // que falle más tarde. jsonwebtoken::EncodingKey::from_rsa_pem suele esperar un PEM válido. + // Utilizaremos un PEM ficticio con apariencia válida pero inútil si está vacío para evitar pánico al inicio, + // pero fallará en el uso real de LTI. return jsonwebtoken::EncodingKey::from_rsa_pem(b"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA7f...dummy...\n-----END RSA PRIVATE KEY-----").expect("Dummy key failed"); } - jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key format") + jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Formato de clave privada LTI inválido") } pub fn get_lti_jwks() -> JwkSet { diff --git a/services/lms-service/src/live.rs b/services/lms-service/src/live.rs index 121d729..b5d5b50 100644 --- a/services/lms-service/src/live.rs +++ b/services/lms-service/src/live.rs @@ -42,7 +42,7 @@ pub async fn create_meeting( Json(payload): Json, ) -> Result, (StatusCode, String)> { if claims.role == "student" { - return Err((StatusCode::FORBIDDEN, "Only instructors can create meetings".to_string())); + return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden crear reuniones".to_string())); } let meeting_id = format!("openccb-{}", Uuid::new_v4()); @@ -76,7 +76,7 @@ pub async fn delete_meeting( claims: Claims, ) -> Result { if claims.role == "student" { - return Err((StatusCode::FORBIDDEN, "Only instructors can delete meetings".to_string())); + return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden eliminar reuniones".to_string())); } sqlx::query("DELETE FROM meetings WHERE id = $1 AND organization_id = $2") diff --git a/services/lms-service/src/lti.rs b/services/lms-service/src/lti.rs index 51cb49f..326b703 100644 --- a/services/lms-service/src/lti.rs +++ b/services/lms-service/src/lti.rs @@ -25,7 +25,7 @@ pub async fn lti_login_initiation( State(pool): State, Query(params): Query, ) -> Result { - // 1. Find registration + // 1. Buscar registro let registration = sqlx::query_as::<_, LtiRegistration>( "SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)" ) @@ -34,20 +34,20 @@ pub async fn lti_login_initiation( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::BAD_REQUEST, "LTI Registration not found".to_string()))?; + .ok_or((StatusCode::BAD_REQUEST, "Registro LTI no encontrado".to_string()))?; - // 2. Generate state and nonce + // 2. Generar estado y nonce let state = Uuid::new_v4().to_string(); let nonce = Uuid::new_v4().to_string(); - // 3. Store nonce + // 3. Almacenar nonce sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)") .bind(&nonce) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 4. Construct redirect URL + // 4. Construir URL de redirección let mut url = format!( "{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post", registration.auth_login_url, @@ -76,9 +76,9 @@ pub async fn validate_lti_jwt( client_id: &str, ) -> Result { let header = decode_header(id_token).map_err(|e| e.to_string())?; - let kid = header.kid.ok_or("Missing kid in JWT header")?; + let kid = header.kid.ok_or("Falta kid en el encabezado JWT")?; - // Fetch JWKS + // Obtener JWKS let jwks: JwkSet = reqwest::get(jwks_url) .await .map_err(|e| e.to_string())? @@ -86,7 +86,7 @@ pub async fn validate_lti_jwt( .await .map_err(|e| e.to_string())?; - let jwk = jwks.find(&kid).ok_or("JWK not found for kid")?; + let jwk = jwks.find(&kid).ok_or("JWK no encontrado para kid")?; let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?; let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256); @@ -102,27 +102,27 @@ pub async fn lti_launch( State(pool): State, Form(payload): Form, ) -> Result { - // 1. Decode claims manually to find registration (since we don't have the key yet) + // 1. Decodificar claims manualmente para encontrar el registro (ya que aún no tenemos la clave) let parts: Vec<&str> = payload.id_token.split('.').collect(); if parts.len() != 3 { - return Err((StatusCode::BAD_REQUEST, "Invalid JWT format".to_string())); + return Err((StatusCode::BAD_REQUEST, "Formato JWT inválido".to_string())); } let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1]) - .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 in JWT payload: {}", e)))?; + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Base64 inválido en el payload del JWT: {}", e)))?; let claims: serde_json::Value = serde_json::from_slice(&decoded_claims) - .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON in JWT payload: {}", e)))?; + .map_err(|e| (StatusCode::BAD_REQUEST, format!("JSON inválido en el payload del JWT: {}", e)))?; - let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Missing iss claim".to_string()))?; + let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Falta el claim iss".to_string()))?; let aud_val = &claims["aud"]; let aud = match aud_val { serde_json::Value::String(s) => s.as_str(), - serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "Invalid aud in array".to_string()))?, - _ => return Err((StatusCode::BAD_REQUEST, "Invalid aud claim".to_string())), + serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "aud inválido en el array".to_string()))?, + _ => return Err((StatusCode::BAD_REQUEST, "Claim aud inválido".to_string())), }; - // 2. Find registration + // 2. Buscar registro let registration = sqlx::query_as::<_, LtiRegistration>( "SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2" ) @@ -131,14 +131,14 @@ pub async fn lti_launch( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "LTI Registration not found for issuer/aud".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Registro LTI no encontrado para emisor/audiencia".to_string()))?; - // 3. Validate JWT + // 3. Validar JWT let lti_claims = validate_lti_jwt(&payload.id_token, ®istration.jwks_url, ®istration.client_id) .await - .map_err(|e| (StatusCode::UNAUTHORIZED, format!("JWT validation failed: {}", e)))?; + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Validación de JWT fallida: {}", e)))?; - // 4. Verify nonce + // 4. Verificar nonce let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1") .bind(<i_claims.nonce) .execute(&pool) @@ -147,10 +147,10 @@ pub async fn lti_launch( .rows_affected() > 0; if !nonce_exists { - return Err((StatusCode::BAD_REQUEST, "Invalid or expired nonce".to_string())); + return Err((StatusCode::BAD_REQUEST, "Nonce inválido o expirado".to_string())); } - // 5. Find or create user + // 5. Buscar o crear usuario let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", ""))); let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string()); @@ -206,16 +206,16 @@ pub async fn lti_launch( let user = user.unwrap(); - // 8. Redirect based on message type + // 8. Redirigir según el tipo de mensaje let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".to_string()); let token = common::auth::create_jwt(user.id, user.organization_id, &user.role) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create token: {}", e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el token: {}", e)))?; let redirect_target = lti_claims.resource_link.as_ref().map(|rl| rl.id.clone()).unwrap_or_default(); if lti_claims.message_type == "LtiDeepLinkingRequest" { - let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Missing deep_linking_settings".to_string()))?; + let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Faltan deep_linking_settings".to_string()))?; let dl_request_id = Uuid::new_v4(); sqlx::query( @@ -249,8 +249,8 @@ pub async fn lti_deep_linking_response( claims: Claims, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // 1. Retrieve and delete DL request - let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid DL token".to_string()))?; + // 1. Recuperar y eliminar la solicitud de DL + let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Token de DL inválido".to_string()))?; let dl_request = sqlx::query( "DELETE FROM lti_deep_linking_requests WHERE id = $1 RETURNING registration_id, deployment_id, return_url, data" @@ -259,15 +259,15 @@ pub async fn lti_deep_linking_response( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::UNAUTHORIZED, "Invalid or expired DL request".to_string()))?; + .ok_or((StatusCode::UNAUTHORIZED, "Solicitud de DL inválida o expirada".to_string()))?; - // Manual mapping since we can't use query!/query_as! easily for RETURNING without a struct + // Mapeo manual ya que no podemos usar query!/query_as! fácilmente para RETURNING sin una estructura let registration_id: Uuid = dl_request.get("registration_id"); let deployment_id: String = dl_request.get("deployment_id"); let _return_url: String = dl_request.get::("return_url"); let dl_data: Option = dl_request.get("data"); - // 2. Find registration + // 2. Buscar registro let registration = sqlx::query_as::<_, LtiRegistration>( "SELECT * FROM lti_registrations WHERE id = $1", ) diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 4f4c06d..b6f33a6 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -36,44 +36,44 @@ async fn main() { dotenv().ok(); tracing_subscriber::fmt::init(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL debe estar configurada"); let pool = PgPoolOptions::new() .max_connections(10) .min_connections(2) .acquire_timeout(Duration::from_secs(30)) .connect(&db_url) .await - .expect("Failed to connect to database"); + .expect("Error al conectar con la base de datos"); - // Initialize health state + // Inicializar estado de salud let health_state = HealthState::default(); let mysql_pool = external_db::init_mysql_pool().await; - // Run migrations automatically + // Ejecutar migraciones automáticamente sqlx::migrate!("./migrations") .run(&pool) .await - .expect("Failed to run migrations"); + .expect("Error al ejecutar las migraciones"); - // Start background task for deadline notifications + // Iniciar tarea en segundo plano para notificaciones de fechas límite let pool_clone = pool.clone(); tokio::spawn(async move { loop { handlers::check_deadlines_and_notify(pool_clone.clone()).await; - tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Every hour + tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Cada hora } }); - // CORS configuration - Allow multiple origins for development and production - // Using a predicate closure to support wildcard subdomains for norteamericano.cl + // Configuración de CORS - Permitir múltiples orígenes para desarrollo y producción + // Usando un cierre de predicado para soportar subdominios comodín para norteamericano.cl use tower_http::cors::AllowOrigin; let cors = CorsLayer::new() .allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { let origin_str = origin.to_str().unwrap_or(""); - // Development origins + // Orígenes de desarrollo let allowed_origins = [ "http://localhost:3000", "http://localhost:3003", @@ -82,17 +82,17 @@ async fn main() { "http://192.168.0.254:3000", "http://192.168.0.254:3003", "http://192.168.0.254", - // Production - Norteamericano domains (HTTPS) + // Producción - Dominios de Norteamericano (HTTPS) "https://studio.norteamericano.cl", "https://learning.norteamericano.cl", ]; - // Check exact matches + // Comprobar coincidencias exactas if allowed_origins.contains(&origin_str) { return true; } - // Check wildcard for subdomains: https://*.norteamericano.cl + // Comprobar comodín para subdominios: https://*.norteamericano.cl if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") { let subdomain = origin_str .strip_prefix("https://") @@ -100,7 +100,7 @@ async fn main() { .strip_suffix(".norteamericano.cl") .unwrap_or(""); - // Allow any subdomain (e.g., api., cdn., admin., etc.) + // Permitir cualquier subdominio (p. ej., api., cdn., admin., etc.) if !subdomain.is_empty() && !subdomain.contains('/') { return true; } @@ -169,10 +169,10 @@ async fn main() { "/courses/{id}/dropout-risks", get(predictive::get_course_dropout_risks), ) - // Live Learning + // Aprendizaje en Vivo (Live Learning) .route("/courses/{id}/meetings", get(live::get_course_meetings).post(live::create_meeting)) .route("/courses/{id}/meetings/{meeting_id}", delete(live::delete_meeting)) - // Portfolio & Badges + // Portafolio e insignias (Badges) .route("/profile/{user_id}", get(portfolio::get_public_profile)) .route("/my/badges", get(portfolio::get_my_badges)) .route("/badges/award", post(portfolio::award_badge)) @@ -189,7 +189,7 @@ async fn main() { .route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap)) .route("/audio/evaluate", post(handlers::evaluate_audio_response)) .route("/audio/evaluate-file", post(handlers::evaluate_audio_file)) - // Audio Response Teacher Routes + // Rutas de Profesor para Respuesta de Audio .route("/audio-responses", get(handlers::get_audio_responses)) .route("/audio-responses/{id}", get(handlers::get_audio_response_detail)) .route("/audio-responses/{id}/audio", get(handlers::get_audio_response_audio)) @@ -204,7 +204,7 @@ async fn main() { "/notifications/{id}/read", post(handlers::mark_notification_as_read), ) - // Knowledge Base Embedding Routes for Semantic RAG + // Rutas de Embeddings de Base de Conocimientos para RAG Semántico .route( "/knowledge-base/embeddings/generate", post(handlers_embeddings::generate_knowledge_embeddings), @@ -217,7 +217,7 @@ async fn main() { "/knowledge-base/{id}/embedding/regenerate", post(handlers_embeddings::regenerate_knowledge_embedding), ) - // Discussion Forums Routes + // Rutas de Foros de Discusión .route( "/courses/{id}/discussions", get(handlers_discussions::list_threads), @@ -255,7 +255,7 @@ async fn main() { "/discussions/{id}/unsubscribe", post(handlers_discussions::unsubscribe_thread), ) - // Announcements + // Anuncios .route( "/courses/{id}/announcements", get(handlers_announcements::list_announcements), @@ -274,7 +274,7 @@ async fn main() { ) .route("/lessons/{id}/notes", get(handlers_notes::get_note)) .route("/lessons/{id}/notes", put(handlers_notes::save_note)) - // Cohorts + // Cohortes (Cohorts) .route("/cohorts", get(handlers_cohorts::list_cohorts)) .route("/cohorts", post(handlers_cohorts::create_cohort)) .route( @@ -289,7 +289,7 @@ async fn main() { "/cohorts/{id}/members", get(handlers_cohorts::get_cohort_members), ) - // Peer Assessment + // Evaluación por Pares (Peer Assessment) .route( "/courses/{id}/lessons/{lesson_id}/submit", post(handlers_peer_review::submit_assignment), @@ -338,7 +338,7 @@ async fn main() { "#) })) - // Health check routes + // Rutas de comprobación de salud (Health check) .merge(health::health_routes(pool.clone()).with_state(health_state)) .route("/catalog", get(handlers::get_course_catalog)) .route("/ingest", post(handlers::ingest_course)) @@ -353,7 +353,7 @@ async fn main() { .route("/lti/jwks", get(jwks::lti_jwks_handler)) .route("/lti/deep-linking/response", post(lti::lti_deep_linking_response)) .merge(protected_routes) - // Security headers + // Encabezados de seguridad (Security headers) .layer(SetResponseHeaderLayer::overriding( http::header::STRICT_TRANSPORT_SECURITY, http::HeaderValue::from_static("max-age=31536000; includeSubDomains"), @@ -379,7 +379,7 @@ async fn main() { .layer(axum::Extension(mysql_pool)); let addr = SocketAddr::from(([0, 0, 0, 0], 3002)); - tracing::info!("LMS Service listening on {} with rate limiting and security headers", addr); + tracing::info!("LMS Service escuchando en {} con limitación de tasa y encabezados de seguridad", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, public_routes).await.unwrap(); } diff --git a/services/lms-service/src/openapi.rs b/services/lms-service/src/openapi.rs index 1902e61..545c22e 100644 --- a/services/lms-service/src/openapi.rs +++ b/services/lms-service/src/openapi.rs @@ -150,7 +150,7 @@ pub struct ApiDoc; tag = "Cursos", request_body = IngestCourseRequest, responses( - (status = 200, description = "Curso ingestada exitosamente"), + (status = 200, description = "Curso ingestado exitosamente"), (status = 400, description = "Payload inválido o JSON mal formado"), (status = 500, description = "Error interno del servidor"), ) @@ -227,11 +227,11 @@ pub fn get_course_outline() {} /// /// | id | nombre | descripcion | /// |----|--------|-------------| -/// | 1 | CA | Continuous Assessment | -/// | 2 | MWT | Midterm Written Test | -/// | 3 | MOT | Midterm Oral Test | -/// | 5 | FOT | Final Oral Test | -/// | 6 | FWT | Final written test | +/// | 1 | CA | Evaluación Continua (Continuous Assessment) | +/// | 2 | MWT | Examen Escrito de Mitad de Ciclo (Midterm Written Test) | +/// | 3 | MOT | Examen Oral de Mitad de Ciclo (Midterm Oral Test) | +/// | 5 | FOT | Examen Oral Final (Final Oral Test) | +/// | 6 | FWT | Examen Escrito Final (Final Written Test) | #[utoipa::path( get, path = "/tipo-nota", diff --git a/services/lms-service/src/portfolio.rs b/services/lms-service/src/portfolio.rs index e126186..8b958fa 100644 --- a/services/lms-service/src/portfolio.rs +++ b/services/lms-service/src/portfolio.rs @@ -19,11 +19,11 @@ pub async fn get_public_profile( .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; + .ok_or((StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()))?; let is_public: bool = user.get("is_public_profile"); if !is_public { - return Err((StatusCode::FORBIDDEN, "This profile is private".to_string())); + return Err((StatusCode::FORBIDDEN, "Este perfil es privado".to_string())); } let badges = sqlx::query_as::( @@ -98,7 +98,7 @@ pub async fn award_badge( Json(payload): Json, ) -> Result { if claims.role == "student" { - return Err((StatusCode::FORBIDDEN, "Only admins can award badges manually".to_string())); + return Err((StatusCode::FORBIDDEN, "Solo los administradores pueden otorgar insignias manualmente".to_string())); } sqlx::query("INSERT INTO user_badges (user_id, badge_id, awarded_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING") diff --git a/services/lms-service/src/predictive.rs b/services/lms-service/src/predictive.rs index 93e5abc..3e6f778 100644 --- a/services/lms-service/src/predictive.rs +++ b/services/lms-service/src/predictive.rs @@ -16,7 +16,7 @@ pub async fn get_course_dropout_risks( claims: Claims, ) -> Result>, (StatusCode, String)> { if claims.role == "student" { - return Err((StatusCode::FORBIDDEN, "Only instructors can view risk reports".to_string())); + return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden ver informes de riesgo".to_string())); } calculate_risks_for_course(&pool, course_id, claims.org).await @@ -34,7 +34,7 @@ pub async fn get_course_dropout_risks( .bind(claims.org) .fetch_all(&pool) .await - .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch risks: {}", e)))?; + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los riesgos: {}", e)))?; let risks: Vec = rows.into_iter().map(|row| { DropoutRisk { @@ -106,8 +106,8 @@ pub async fn calculate_risks_for_course( }; let reasons = vec![ - DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Grade: {:.0}%", avg_grade * 100.0) }, - DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} actions in last week", last_activity_count) }, + DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Calificación: {:.0}%", avg_grade * 100.0) }, + DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} acciones en la última semana", last_activity_count) }, ]; sqlx::query( diff --git a/shared/common/src/ai.rs b/shared/common/src/ai.rs index 211736a..5632f9d 100644 --- a/shared/common/src/ai.rs +++ b/shared/common/src/ai.rs @@ -1,33 +1,33 @@ -//! AI Utilities for OpenCCB -//! Provides embedding generation and other AI helper functions +//! Utilidades de IA para OpenCCB +//! Proporciona generación de embeddings y otras funciones de ayuda de IA use serde::{Deserialize, Serialize}; use thiserror::Error; -/// Default embedding model for Ollama +/// Modelo de embedding por defecto para Ollama pub const DEFAULT_EMBEDDING_MODEL: &str = "nomic-embed-text"; -/// Default Ollama URL +/// URL de Ollama por defecto pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434"; -/// Embedding dimensions for nomic-embed-text +/// Dimensiones del embedding para nomic-embed-text pub const EMBEDDING_DIMENSIONS: usize = 768; -/// Model selection for different use cases +/// Selección de modelo para diferentes casos de uso #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelType { - /// Fast conversational AI (chat, tutor, Q&A) + /// IA conversacional rápida (chat, tutor, preguntas y respuestas) Chat, - /// Complex reasoning (analysis, recommendations, feedback) + /// Razonamiento complejo (análisis, recomendaciones, retroalimentación) Complex, - /// Advanced tasks (course generation, detailed analysis) + /// Tareas avanzadas (generación de cursos, análisis detallado) Advanced, - /// Embedding generation + /// Generación de embeddings Embedding, } impl ModelType { - /// Get the model name for this type from environment + /// Obtener el nombre del modelo para este tipo desde el entorno pub fn get_model(&self) -> String { match self { ModelType::Chat => { @@ -45,34 +45,34 @@ impl ModelType { } } - /// Get recommended temperature for this model type + /// Obtener la temperatura recomendada para este tipo de modelo pub fn get_temperature(&self) -> f32 { match self { - ModelType::Chat => 0.7, // Balanced creativity/accuracy - ModelType::Complex => 0.5, // More focused reasoning - ModelType::Advanced => 0.6, // Balanced for analysis - ModelType::Embedding => 0.0, // Deterministic for embeddings + ModelType::Chat => 0.7, // Equilibrio entre creatividad y precisión + ModelType::Complex => 0.5, // Razonamiento más enfocado + ModelType::Advanced => 0.6, // Equilibrado para análisis + ModelType::Embedding => 0.0, // Determinista para embeddings } } - /// Get max tokens for this model type + /// Obtener los tokens máximos para este tipo de modelo pub fn get_max_tokens(&self) -> u32 { match self { ModelType::Chat => 1024, ModelType::Complex => 2048, ModelType::Advanced => 4096, - ModelType::Embedding => 0, // Not applicable + ModelType::Embedding => 0, // No aplicable } } } #[derive(Error, Debug)] pub enum AiError { - #[error("Ollama request failed: {0}")] + #[error("Solicitud a Ollama fallida: {0}")] OllamaRequest(String), - #[error("Invalid embedding response: {0}")] + #[error("Respuesta de embedding inválida: {0}")] InvalidResponse(String), - #[error("Model not available: {0}")] + #[error("Modelo no disponible: {0}")] ModelNotAvailable(String), } @@ -83,19 +83,19 @@ pub struct EmbeddingResponse { pub model: String, } -/// Get Ollama URL from environment or default +/// Obtener la URL de Ollama desde el entorno o por defecto pub fn get_ollama_url() -> String { std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| DEFAULT_OLLAMA_URL.to_string()) } -/// Get embedding model from environment or default +/// Obtener el modelo de embedding desde el entorno o por defecto pub fn get_embedding_model() -> String { std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| DEFAULT_EMBEDDING_MODEL.to_string()) } -/// Get the best model for a specific task +/// Obtener el mejor modelo para una tarea específica pub fn get_model_for_task(task: &str) -> String { - // Task-based model selection + // Selección de modelo basada en la tarea match task.to_lowercase().as_str() { t if t.contains("chat") || t.contains("tutor") || t.contains("conversation") => { ModelType::Chat.get_model() @@ -116,22 +116,22 @@ pub fn get_model_for_task(task: &str) -> String { } } -/// Create a reqwest client that accepts invalid certificates (for dev with self-signed certs) +/// Crear un cliente reqwest que acepte certificados inválidos (para desarrollo con certificados autofirmados) fn create_insecure_client() -> Result { reqwest::Client::builder() .danger_accept_invalid_certs(true) .danger_accept_invalid_hostnames(true) .build() - .map_err(|e| AiError::OllamaRequest(format!("Failed to create HTTP client: {}", e))) + .map_err(|e| AiError::OllamaRequest(format!("Error al crear el cliente HTTP: {}", e))) } -/// Generate embedding for text using Ollama +/// Generar embedding para texto usando Ollama /// -/// # Arguments -/// * `client` - reqwest::Client instance -/// * `ollama_url` - Base URL for Ollama (e.g., "http://localhost:11434") -/// * `model` - Embedding model name (default: "nomic-embed-text") -/// * `text` - Text to embed +/// # Argumentos +/// * `client` - instancia de reqwest::Client +/// * `ollama_url` - URL base para Ollama (e.g., "http://localhost:11434") +/// * `model` - Nombre del modelo de embedding (por defecto: "nomic-embed-text") +/// * `text` - Texto a incrustar pub async fn generate_embedding( client: &reqwest::Client, ollama_url: &str, @@ -148,25 +148,25 @@ pub async fn generate_embedding( })) .send() .await - .map_err(|e| AiError::OllamaRequest(format!("Request failed: {}", e)))?; + .map_err(|e| AiError::OllamaRequest(format!("Solicitud fallida: {}", e)))?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await.unwrap_or_default(); return Err(AiError::OllamaRequest( - format!("Ollama API error ({}): {}", status, error_text) + format!("Error de la API de Ollama ({}): {}", status, error_text) )); } let embedding_response: EmbeddingResponse = response .json() .await - .map_err(|e| AiError::InvalidResponse(format!("Failed to parse response: {}", e)))?; + .map_err(|e| AiError::InvalidResponse(format!("Error al analizar la respuesta: {}", e)))?; Ok(embedding_response) } -/// Generate embeddings for multiple texts in batch +/// Generar embeddings para múltiples textos en lote pub async fn generate_embeddings_batch( client: &reqwest::Client, ollama_url: &str, @@ -183,8 +183,8 @@ pub async fn generate_embeddings_batch( Ok(embeddings) } -/// Convert a vector of f32 to pgvector-compatible format -/// PostgreSQL vector format: "[0.1,0.2,0.3,...]" +/// Convertir un vector de f32 a un formato compatible con pgvector +/// Formato de vector de PostgreSQL: "[0.1,0.2,0.3,...]" pub fn embedding_to_pgvector(embedding: &[f32]) -> String { let formatted: Vec = embedding .iter() @@ -193,12 +193,12 @@ pub fn embedding_to_pgvector(embedding: &[f32]) -> String { format!("[{}]", formatted.join(",")) } -/// Parse pgvector format back to Vec +/// Analizar el formato pgvector de vuelta a Vec pub fn pgvector_to_embedding(pgvector: &str) -> Result, String> { let trimmed = pgvector.trim().trim_start_matches('[').trim_end_matches(']'); trimmed .split(',') - .map(|s| s.trim().parse::().map_err(|e| format!("Parse error: {}", e))) + .map(|s| s.trim().parse::().map_err(|e| format!("Error de análisis: {}", e))) .collect() } diff --git a/shared/common/src/health.rs b/shared/common/src/health.rs index b3a18bf..93fd493 100644 --- a/shared/common/src/health.rs +++ b/shared/common/src/health.rs @@ -1,11 +1,11 @@ -//! Health check endpoints for monitoring and observability +//! Endpoints de verificación de salud para monitoreo y observabilidad use axum::{Json, Router, routing::get, extract::State}; use serde_json::json; use sqlx::PgPool; use std::time::Instant; -/// Health check state shared across requests +/// Estado de verificación de salud compartido entre peticiones #[derive(Clone)] pub struct HealthState { pub start_time: Instant, @@ -21,7 +21,7 @@ impl Default for HealthState { } } -/// Basic health check endpoint +/// Endpoint básico de verificación de salud pub async fn health_check() -> Json { Json(json!({ "status": "healthy", @@ -29,7 +29,7 @@ pub async fn health_check() -> Json { })) } -/// Detailed readiness check including database connectivity +/// Verificación detallada de disponibilidad que incluye la conectividad con la base de datos pub async fn readiness_check(pool: PgPool) -> Json { let db_status = match pool.acquire().await { Ok(_) => "connected", @@ -45,7 +45,7 @@ pub async fn readiness_check(pool: PgPool) -> Json { })) } -/// Liveness check with uptime information +/// Verificación de vitalidad (liveness) con información de tiempo de actividad (uptime) pub async fn liveness_check(state: State) -> Json { let uptime = state.start_time.elapsed(); @@ -57,7 +57,7 @@ pub async fn liveness_check(state: State) -> Json Router { Router::new() .route("/health", get(health_check)) diff --git a/shared/common/src/middleware.rs b/shared/common/src/middleware.rs index 9e1f568..c7e9060 100644 --- a/shared/common/src/middleware.rs +++ b/shared/common/src/middleware.rs @@ -28,7 +28,7 @@ pub async fn org_extractor_middleware( let token = if let Some(token_str) = auth_header.and_then(|s: &str| s.strip_prefix("Bearer ")) { token_str.to_string() } else { - // Check for preview_token in query string + // Verificar si hay preview_token en la cadena de consulta let query = req.uri().query().unwrap_or_default(); let preview_token = query .split('&') diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 09a26aa..79b162b 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -11,7 +11,7 @@ pub struct Course { pub title: String, pub description: Option, pub instructor_id: Uuid, - pub pacing_mode: String, // "self_paced" or "instructor_led" + pub pacing_mode: String, // "self_paced" o "instructor_led" pub start_date: Option>, pub end_date: Option>, pub passing_percentage: i32, @@ -121,7 +121,7 @@ pub struct GradingCategory { pub name: String, pub weight: i32, // 0-100 pub drop_count: i32, - pub tipo_nota_id: Option, // Maps to idTipoNota in external MySQL system + pub tipo_nota_id: Option, // Se mapea con idTipoNota en el sistema MySQL externo pub created_at: DateTime, } @@ -131,7 +131,7 @@ pub struct UserGrade { pub user_id: Uuid, pub course_id: Uuid, pub lesson_id: Uuid, - pub score: f32, // 0.0 to 1.0 + pub score: f32, // 0.0 a 1.0 pub attempts_count: i32, pub metadata: Option, pub created_at: DateTime, @@ -187,7 +187,7 @@ pub struct Enrollment { pub user_id: Uuid, pub organization_id: Uuid, pub course_id: Uuid, - pub external_id: Option, // idDetalleContrato from the external system + pub external_id: Option, // idDetalleContrato del sistema externo pub enrolled_at: DateTime, } @@ -242,7 +242,7 @@ pub struct LtiLaunchClaims { #[serde(rename = "sub")] pub subject: String, #[serde(rename = "aud")] - pub audience: serde_json::Value, // Can be string or array + pub audience: serde_json::Value, // Puede ser una cadena o un array #[serde(rename = "exp")] pub expires_at: i64, #[serde(rename = "iat")] @@ -363,7 +363,7 @@ pub struct Transaction { pub course_id: Uuid, pub amount: f64, pub currency: String, - pub status: String, // "pending", "success", "failure" + pub status: String, // "pending", "success", "failure" (pendiente, éxito, falla) pub provider_reference: Option, pub created_at: DateTime, pub updated_at: DateTime, @@ -377,7 +377,7 @@ pub struct User { pub email: String, pub password_hash: String, pub full_name: String, - pub role: String, // admin, instructor, student + pub role: String, // admin (administrador), instructor, student (estudiante) pub xp: i32, pub level: i32, pub avatar_url: Option, @@ -538,7 +538,7 @@ pub struct Recommendation { pub title: String, pub description: String, pub lesson_id: Option, - pub priority: String, // "high", "medium", "low" + pub priority: String, // "high", "medium", "low" (alta, media, baja) pub reason: String, } @@ -596,7 +596,7 @@ pub struct AuditLog { } -// Discussion Forums Models +// Modelos de Foros de Discusión #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct DiscussionThread { pub id: Uuid, @@ -633,7 +633,7 @@ pub struct DiscussionVote { pub organization_id: Uuid, pub post_id: Uuid, pub user_id: Uuid, - pub vote_type: String, // 'upvote' or 'downvote' + pub vote_type: String, // 'upvote' o 'downvote' pub created_at: DateTime, } @@ -646,10 +646,10 @@ pub struct DiscussionSubscription { pub created_at: DateTime, } -// Response DTOs for Discussion APIs +// DTOs de respuesta para las APIs de Discusión #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct ThreadWithAuthor { - // Thread fields + // Campos del hilo pub id: Uuid, pub organization_id: Uuid, pub course_id: Uuid, @@ -662,17 +662,17 @@ pub struct ThreadWithAuthor { pub view_count: i32, pub created_at: DateTime, pub updated_at: DateTime, - // Author info + // Información del autor pub author_name: String, pub author_avatar: Option, - // Aggregated data + // Datos agregados pub post_count: i64, pub has_endorsed_answer: bool, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct PostWithAuthor { - // Post fields + // Campos de la publicación pub id: Uuid, pub organization_id: Uuid, pub thread_id: Uuid, @@ -686,14 +686,14 @@ pub struct PostWithAuthor { // Author info pub author_name: String, pub author_avatar: Option, - // User interaction - pub user_vote: Option, // 'upvote', 'downvote', or null - // Nested replies (not from DB, populated manually) + // Interacción del usuario + pub user_vote: Option, // 'upvote', 'downvote' o nulo + // Respuestas anidadas (no desde la BD, pobladas manualmente) #[sqlx(skip)] pub replies: Vec, } -// Course Announcements +// Anuncios del Curso #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct CourseAnnouncement { pub id: Uuid, @@ -711,7 +711,7 @@ pub struct CourseAnnouncement { #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct AnnouncementWithAuthor { - // Announcement fields + // Campos del anuncio pub id: Uuid, pub organization_id: Uuid, pub course_id: Uuid, @@ -728,7 +728,7 @@ pub struct AnnouncementWithAuthor { pub cohort_ids: Option>, } -// Student Notes +// Notas del Estudiante #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct StudentNote { pub id: Uuid, @@ -744,7 +744,7 @@ pub struct SaveNotePayload { pub content: String, } -// Cohorts & Groups +// Cohortes y Grupos #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Cohort { pub id: Uuid, @@ -784,7 +784,7 @@ pub struct StudentGradeReport { pub last_active_at: Option>, } -// Peer Assessment +// Evaluación por Pares #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct CourseSubmission { pub id: Uuid, @@ -832,7 +832,7 @@ pub struct SubmissionWithReviews { pub average_score: Option, } -// Content Libraries +// Librerías de Contenido #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct LibraryBlock { pub id: Uuid, @@ -894,8 +894,8 @@ pub struct DropoutRisk { pub course_id: Uuid, pub user_id: Uuid, pub risk_level: DropoutRiskLevel, - pub score: f32, // 0.0 to 1.0 (Higher means higher risk) - pub reasons: Option, // e.g., ["low_grades", "inactivity"] + pub score: f32, // 0.0 a 1.0 (Más alto significa mayor riesgo) + pub reasons: Option, // ej., ["low_grades", "inactivity"] pub last_calculated_at: DateTime, pub created_at: DateTime, pub updated_at: DateTime, @@ -921,7 +921,7 @@ mod tests { let lesson = Lesson { id: lesson_id, - organization_id: course_id, // Use course_id as proxy for org_id in test + organization_id: course_id, // Usar course_id como proxi para org_id en la prueba module_id, title: "Test Lesson".to_string(), content_type: "activity".to_string(), @@ -1040,7 +1040,7 @@ mod tests { } } -// ==================== Advanced Grading / Rubrics ==================== +// ==================== Calificación Avanzada / Rúbricas ==================== #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Rubric { @@ -1112,7 +1112,7 @@ pub struct AssessmentScore { pub feedback: Option, pub created_at: DateTime, } -// ==================== Learning Sequences / Dependencies ==================== +// ==================== Secuencias de Aprendizaje / Dependencias ==================== #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct LessonDependency { @@ -1124,7 +1124,7 @@ pub struct LessonDependency { pub created_at: DateTime, } -// ==================== Live Learning (Meetings) ==================== +// ==================== Aprendizaje en Vivo (Reuniones) ==================== #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Meeting { @@ -1134,7 +1134,7 @@ pub struct Meeting { pub title: String, pub description: Option, pub provider: String, // "jitsi" | "bbb" - pub meeting_id: String, // Room name or external ID + pub meeting_id: String, // Nombre de la sala o ID externo pub start_at: DateTime, pub duration_minutes: i32, pub join_url: Option, @@ -1143,7 +1143,7 @@ pub struct Meeting { pub updated_at: DateTime, } -// ==================== Student portfolio & Badges ==================== +// ==================== Portafolio del Estudiante e Insignias ==================== #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Badge { @@ -1177,7 +1177,7 @@ pub struct PublicProfile { pub completed_courses_count: i64, } -// ==================== Test Templates ==================== +// ==================== Plantillas de Examen ==================== #[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)] #[sqlx(type_name = "course_level", rename_all = "lowercase")] @@ -1206,15 +1206,15 @@ pub enum CourseType { #[sqlx(type_name = "test_type")] pub enum TestType { #[sqlx(rename = "CA")] - CA, // Continuous Assessment + CA, // Evaluación Continua #[sqlx(rename = "MWT")] - MWT, // Midterm Written Test + MWT, // Examen Escrito de Mitad de Ciclo #[sqlx(rename = "MOT")] - MOT, // Midterm Oral Test + MOT, // Examen Oral de Mitad de Ciclo #[sqlx(rename = "FOT")] - FOT, // Final Oral Test + FOT, // Examen Oral Final #[sqlx(rename = "FWT")] - FWT, // Final Written Test + FWT, // Examen Escrito Final } impl std::fmt::Display for TestType { @@ -1233,17 +1233,17 @@ impl std::fmt::Display for TestType { pub struct TestTemplate { pub id: Uuid, pub organization_id: Uuid, - pub mysql_course_id: Option, // Reference to imported MySQL course + pub mysql_course_id: Option, // Referencia al curso de MySQL importado pub name: String, pub description: Option, - pub level: Option, // Deprecated: use mysql_course_id instead - pub course_type: Option, // Deprecated: use mysql_course_id instead + pub level: Option, // Depreciado: use mysql_course_id en su lugar + pub course_type: Option, // Depreciado: use mysql_course_id en su lugar pub test_type: TestType, pub duration_minutes: i32, - pub passing_score: i32, // 0-100 percentage + pub passing_score: i32, // Porcentaje 0-100 pub total_points: i32, pub instructions: Option, - pub template_data: serde_json::Value, // Complete test structure with sections and questions + pub template_data: serde_json::Value, // Estructura completa del examen con secciones y preguntas pub tags: Option>, pub is_active: bool, pub usage_count: i32, @@ -1261,7 +1261,7 @@ pub struct TestTemplateSection { pub section_order: i32, pub points: i32, pub instructions: Option, - pub section_data: Option, // Section-specific configuration + pub section_data: Option, // Configuración específica de la sección pub created_at: DateTime, } @@ -1277,7 +1277,7 @@ pub struct TestTemplateQuestion { pub correct_answer: Option, // Can be index, array of indices, or text pub explanation: Option, pub points: i32, - pub metadata: Option, // Additional question metadata + pub metadata: Option, // Metadatos adicionales de la pregunta pub created_at: DateTime, } @@ -1285,9 +1285,9 @@ pub struct TestTemplateQuestion { pub struct CreateTestTemplatePayload { pub name: String, pub description: Option, - pub mysql_course_id: Option, // Reference to imported MySQL course (preferred) - pub level: Option, // Fallback if mysql_course_id not provided - pub course_type: Option, // Fallback if mysql_course_id not provided + pub mysql_course_id: Option, // Referencia al curso de MySQL importado (preferido) + pub level: Option, // Alternativa si no se proporciona mysql_course_id + pub course_type: Option, // Alternativa si no se proporciona mysql_course_id pub test_type: TestType, pub duration_minutes: i32, pub passing_score: i32, @@ -1327,7 +1327,7 @@ pub struct ApplyTemplatePayload { pub grading_category_id: Option, } -// ==================== Question Bank ==================== +// ==================== Banco de Preguntas ==================== #[derive(Debug, Serialize, Deserialize, Clone, Copy, sqlx::Type, PartialEq)] #[sqlx(type_name = "question_bank_type")] @@ -1389,7 +1389,7 @@ pub struct QuestionBank { pub points: i32, pub difficulty: Option, pub tags: Option>, - pub skill_assessed: Option, // reading, listening, speaking, writing + pub skill_assessed: Option, // lectura (reading), escucha (listening), habla (speaking), escritura (writing) pub source: Option, pub source_metadata: Option, pub imported_mysql_id: Option, @@ -1401,10 +1401,10 @@ pub struct QuestionBank { pub created_by: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, - pub embedding: Option, // PGVector embedding for semantic search + pub embedding: Option, // Embedding de PGVector para búsqueda semántica pub embedding_updated_at: Option>, - pub source_asset_id: Option, // audio/video asset that originated this RAG chunk - pub unit_number: Option, // syllabus unit number from ZIP folder structure + pub source_asset_id: Option, // Activo de audio/video que originó este fragmento RAG + pub unit_number: Option, // Número de unidad del sílabo desde la estructura de carpetas ZIP } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -1419,7 +1419,7 @@ pub struct CreateQuestionBankPayload { pub tags: Option>, pub media_url: Option, pub media_type: Option, - pub skill_assessed: Option, // reading, listening, speaking, writing + pub skill_assessed: Option, // lectura (reading), escucha (listening), habla (speaking), escritura (writing) } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -1453,8 +1453,8 @@ pub struct QuestionBankFilters { pub has_audio: Option, } -// ==================== AUDIO RESPONSE MODELS ==================== -// For speaking practice exercises with AI + Teacher evaluation +// ==================== MODELOS DE RESPUESTA DE AUDIO ==================== +// Para ejercicios de práctica de habla con evaluación de IA + Profesor #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] diff --git a/shared/common/src/token_limits.rs b/shared/common/src/token_limits.rs index 24ec8ab..640f260 100644 --- a/shared/common/src/token_limits.rs +++ b/shared/common/src/token_limits.rs @@ -1,5 +1,5 @@ -//! AI Token Limit Utilities -//! Provides functions to check and enforce monthly token limits +//! Utilidades de Límite de Tokens de IA +//! Proporciona funciones para verificar y hacer cumplir los límites mensuales de tokens use sqlx::{PgPool, FromRow}; use uuid::Uuid; @@ -23,16 +23,16 @@ pub struct TokenLimitError { pub reset_date: DateTime, } -/// Verify if user has available tokens for AI operations +/// Verificar si el usuario tiene tokens disponibles para operaciones de IA /// -/// # Arguments -/// * `pool` - Database connection pool -/// * `user_id` - User UUID -/// * `estimated_tokens` - Estimated tokens for this operation (default: 1000) +/// # Argumentos +/// * `pool` - Pool de conexión a la base de datos +/// * `user_id` - UUID del usuario +/// * `estimated_tokens` - Tokens estimados para esta operación (por defecto: 1000) /// -/// # Returns -/// * `Ok(TokenLimitCheck)` - User has available tokens -/// * `Err(TokenLimitError)` - User exceeded limit +/// # Retornos +/// * `Ok(TokenLimitCheck)` - El usuario tiene tokens disponibles +/// * `Err(TokenLimitError)` - El usuario excedió el límite pub async fn check_ai_token_limit( pool: &PgPool, user_id: Uuid, @@ -46,9 +46,9 @@ pub async fn check_ai_token_limit( .fetch_one(pool) .await .map_err(|e| { - tracing::error!("Failed to check token limit: {}", e); + tracing::error!("Error al verificar el límite de tokens: {}", e); TokenLimitError { - error: "Failed to verify token limit".to_string(), + error: "Error al verificar el límite de tokens".to_string(), monthly_limit: 0, used_tokens: 0, remaining_tokens: 0, @@ -58,7 +58,7 @@ pub async fn check_ai_token_limit( if !result.has_available_tokens { tracing::warn!( - "User {} exceeded token limit: {}/{} (reset: {})", + "El usuario {} excedió el límite de tokens: {}/{} (reinicio: {})", user_id, result.used_tokens, result.monthly_limit, @@ -67,7 +67,7 @@ pub async fn check_ai_token_limit( return Err(TokenLimitError { error: format!( - "Monthly AI token limit exceeded. Used: {} / Limit: {}. Reset date: {}", + "Límite mensual de tokens de IA excedido. Usados: {} / Límite: {}. Fecha de reinicio: {}", result.used_tokens, result.monthly_limit, result.reset_date.format("%Y-%m-%d %H:%M UTC") @@ -82,7 +82,7 @@ pub async fn check_ai_token_limit( Ok(result) } -/// Get formatted token usage message for API responses +/// Obtener el mensaje formateado de uso de tokens para las respuestas de la API pub fn format_token_limit_message(used: i64, limit: i32, reset: DateTime) -> String { let percentage = (used as f64 / limit as f64 * 100.0).round(); format!( diff --git a/shared/common/src/webhooks.rs b/shared/common/src/webhooks.rs index fdde3ef..f706e23 100644 --- a/shared/common/src/webhooks.rs +++ b/shared/common/src/webhooks.rs @@ -23,7 +23,7 @@ impl WebhookService { .await { Ok(w) => w, Err(e) => { - tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e); + tracing::error!("Error al obtener los webhooks para la org {}: {}", org_id, e); return; } }; @@ -54,7 +54,7 @@ impl WebhookService { Ok(response) => { if !response.status().is_success() { tracing::warn!( - "Webhook delivery to {} (event: {}) failed with status {}", + "La entrega del webhook a {} (evento: {}) falló con el estado {}", url, event_type, response.status() @@ -63,7 +63,7 @@ impl WebhookService { } Err(e) => { tracing::error!( - "Failed to deliver webhook to {} (event: {}): {}", + "Error al entregar el webhook a {} (evento: {}): {}", url, event_type, e