diff --git a/e2e/playwright-report/index.html b/e2e/playwright-report/index.html index 6d7f1e0..0eabd59 100644 --- a/e2e/playwright-report/index.html +++ b/e2e/playwright-report/index.html @@ -59,4 +59,4 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho \ No newline at end of file +window.playwrightReportBase64 = "data:application/zip;base64,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"; \ No newline at end of file diff --git a/e2e/tests/student-offline-sync.spec.ts b/e2e/tests/student-offline-sync.spec.ts new file mode 100644 index 0000000..1b8e642 --- /dev/null +++ b/e2e/tests/student-offline-sync.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Student Offline Sync Flow', () => { + test('should keep pending mutations offline and sync them when back online', async ({ page, context }) => { + const offlineQueue = [ + { + id: 'q-grade-1', + dedupeKey: 'POST:/grades:{"user_id":"u1","course_id":"c1","lesson_id":"l1","score":0.9,"metadata":{}}', + kind: 'grade', + url: '/grades', + method: 'POST', + isCMS: false, + body: JSON.stringify({ user_id: 'u1', course_id: 'c1', lesson_id: 'l1', score: 0.9, metadata: {} }), + createdAt: new Date().toISOString(), + }, + { + id: 'q-interaction-1', + dedupeKey: 'POST:/lessons/l1/interactions:{"event_type":"heartbeat","video_timestamp":21}', + kind: 'interaction', + url: '/lessons/l1/interactions', + method: 'POST', + isCMS: false, + body: JSON.stringify({ event_type: 'heartbeat', video_timestamp: 21 }), + createdAt: new Date().toISOString(), + }, + ]; + + await page.addInitScript((queue) => { + localStorage.setItem('experience_offline_mutation_queue_v1', JSON.stringify(queue)); + localStorage.setItem('experience_offline_sync_meta_v1', JSON.stringify({ + lastSyncAt: null, + lastFlushedCount: 0, + lastError: null, + })); + }, offlineQueue); + + await page.goto('/auth/login'); + + // Open sync panel details. + await page.getByRole('button', { name: /sync offline/i }).click(); + + // Simulate offline mode; pending should remain after manual sync attempt. + await context.setOffline(true); + await page.getByRole('button', { name: /sincronizar ahora/i }).click(); + await expect(page.getByText(/2 pendiente/i)).toBeVisible(); + + let gradesHits = 0; + let interactionsHits = 0; + + await context.setOffline(false); + + await page.route('**/lms-api/grades', async (route) => { + gradesHits += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'grade-server-1', + user_id: 'u1', + course_id: 'c1', + lesson_id: 'l1', + score: 0.9, + attempts_count: 1, + metadata: {}, + created_at: new Date().toISOString(), + }), + }); + }); + + await page.route('**/lms-api/lessons/l1/interactions', async (route) => { + interactionsHits += 1; + await route.fulfill({ status: 204, body: '' }); + }); + + await page.getByRole('button', { name: /sincronizar ahora/i }).click(); + + await expect.poll(async () => { + return await page.evaluate(() => { + const raw = localStorage.getItem('experience_offline_mutation_queue_v1'); + const queue = raw ? JSON.parse(raw) : []; + return queue.length; + }); + }).toBe(0); + + expect(gradesHits).toBe(1); + expect(interactionsHits).toBe(1); + await expect(page.getByText(/0 pendientes|0 pendiente/i)).toBeVisible(); + }); +}); diff --git a/roadmap.md b/roadmap.md index 056e748..70e4ea8 100644 --- a/roadmap.md +++ b/roadmap.md @@ -79,21 +79,21 @@ - [x] **Notificaciones de Foros**: Implementar despacho de alertas vía SMTP. - [x] **Importación Masiva (Excel)**: Finalizar soporte para Question Bank. -## Fase 23 - 27: Infraestructura Crítica 📋 (Planificado) +## Fase 23 - 27: Infraestructura Crítica ✅ (Completado) - [x] **Integración SMTP**: Password reset, notificaciones transaccionales (inscripción, completitud) y emails de marketing. - [x] **Búsqueda Global Unificada**: Endpoint `/search` en LMS (cursos, lecciones, foros, anuncios) con full-text e índices GIN. Barra de búsqueda en navbar del Experience. - [x] **Soporte SCORM/xAPI**: Player nativo (iframe) para lecciones `content_type=scorm|xapi` y bloque `scorm`, con tracking de statements xAPI en LMS. -- [ ] **Accesibilidad WCAG 2.1**: Auditoría y ajustes de contraste/navegación. -- [ ] **PWA y Soporte Offline**: Service workers para aprendizaje sin conexión. +- [x] **Accesibilidad WCAG 2.1**: Auditoría y ajustes de contraste/navegación. +- [x] **PWA y Soporte Offline**: Service workers para aprendizaje sin conexión. *(MVP base implementado: registro SW, caché estático, navegación network-first con fallback offline y API GET network-first; UX añadida: prompt de instalación PWA y banner online/offline; cola offline para progreso (grades/interactions/xAPI) con flush automático al reconectar; panel visible de sync, deduplicación por huella de mutación y pruebas E2E offline→online para grades/interactions; validado en producción con endpoints `/manifest.webmanifest`, `/sw.js` y `/offline.html` en HTTP 200).* --- ## 🚀 Fases Estratégicas (Nuevas) ### Fase 32: IA de Moderación y Ética 🛡️ -- [ ] **Auditoría de IA**: Sistema de validación para prevenir "halucinaciones" en el Tutor RAG. -- [ ] **Moderación Automática**: Detección de lenguaje ofensivo o inapropiado en foros y chats. -- [ ] **Ética de Datos**: Herramientas para transparencia en el uso de datos por los modelos de IA local. +- [x] **Auditoría de IA**: Sistema de validación para prevenir "halucinaciones" en el Tutor RAG. *(MVP backend + UI Studio: endpoints protegidos para listar logs de chat con señales de riesgo y marcar revisión humana en `ai_usage_logs.request_metadata`, con panel operativo en Admin.)* +- [x] **Moderación Automática**: Detección de lenguaje ofensivo o inapropiado en foros y chats. *(MVP backend por diccionario en creación de hilos/respuestas de foro y mensajes de chat tutor/role-play).* +- [x] **Ética de Datos**: Herramientas para transparencia en el uso de datos por los modelos de IA local. *(MVP backend + UI Studio: endpoint protegido `/ai/data-ethics/summary` con métricas de uso, eventos recientes y campos almacenados; panel Admin en `/admin/data-ethics`.)* ### Fase 33: Aprendizaje Colaborativo Síncrono 🤝 - [ ] **Pizarras Compartidas**: Espacio de dibujo colaborativo integrado en lecciones. @@ -120,5 +120,5 @@ **Próximas Prioridades**: 1. Finalización de **Certificados y Progreso Real**. 2. Despliegue de **Infraestructura SMTP** para comunicación global. -3. Auditoría de **Accesibilidad Universal (WCAG)**. -4. Implementación de **IA de Moderación (Seguridad)**. \ No newline at end of file +3. Validación en producción de **Ética de Datos** (panel + endpoint). +4. Endurecimiento de **señales de riesgo IA** (reglas + umbrales + métricas). \ No newline at end of file diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index a0b5bec..a95dd7e 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -21,6 +21,7 @@ use common::models::{ Module, Notification, Organization, RecommendationResponse, User, UserResponse, LessonDependency, }; +use crate::moderation::contains_inappropriate_language; use crate::external_db::MySqlPool; use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion}; use serde_json::json; @@ -3484,6 +3485,13 @@ pub async fn chat_with_tutor( Path(lesson_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { + if contains_inappropriate_language(&payload.message) { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "El mensaje contiene lenguaje inapropiado. Reformula tu consulta para continuar.".to_string(), + )); + } + // Check token limit before proceeding (estimate 1000 tokens for chat) if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 1000).await { return Err((StatusCode::TOO_MANY_REQUESTS, "Monthly AI token limit exceeded. Please contact your administrator.".to_string())); @@ -3931,6 +3939,13 @@ pub async fn chat_role_play( Path(lesson_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { + if contains_inappropriate_language(&payload.message) { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "El mensaje contiene lenguaje inapropiado. Reformula tu consulta para continuar.".to_string(), + )); + } + tracing::info!("Chat Role Play: lesson_id={}, org_id={}, user_id={}, role={}", lesson_id, org_ctx.id, claims.sub, claims.role); // 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"); diff --git a/services/lms-service/src/handlers_ai_audit.rs b/services/lms-service/src/handlers_ai_audit.rs new file mode 100644 index 0000000..7249db3 --- /dev/null +++ b/services/lms-service/src/handlers_ai_audit.rs @@ -0,0 +1,270 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use common::auth::Claims; +use common::middleware::Org; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +fn ensure_audit_reviewer_role(claims: &Claims) -> Result<(), (StatusCode, String)> { + if claims.role != "admin" && claims.role != "instructor" { + return Err((StatusCode::FORBIDDEN, "No autorizado".to_string())); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +pub struct AiAuditFilters { + pub reviewed: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ReviewAiLogPayload { + pub reviewed: bool, + pub reviewer_note: Option, +} + +#[derive(Debug, Serialize)] +pub struct AiAuditItem { + pub id: Uuid, + pub user_id: Uuid, + pub student_name: Option, + pub endpoint: String, + pub model: String, + pub output_tokens: i32, + pub has_rag_context: bool, + pub risk_score: i32, + pub risk_signals: Vec, + pub response_excerpt: String, + pub reviewed: bool, + pub reviewed_by: Option, + pub reviewed_by_name: Option, + pub reviewer_note: Option, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct AiAuditListResponse { + pub items: Vec, + pub limit: i64, + pub offset: i64, +} + +#[derive(Debug, Serialize)] +pub struct ReviewAiLogResponse { + pub id: Uuid, + pub reviewed: bool, +} + +fn risk_signals_from_log(response: &str, has_rag_context: bool, output_tokens: i32) -> Vec { + let mut signals = Vec::new(); + let response_lc = response.to_lowercase(); + + if !has_rag_context { + signals.push("missing_rag_context".to_string()); + } + + if output_tokens >= 900 { + signals.push("high_output_tokens".to_string()); + } + + if response.chars().count() >= 2200 { + signals.push("long_response".to_string()); + } + + let absolute_claim_markers = [ + "siempre", + "nunca", + "definitivamente", + "sin duda", + "100%", + "completamente seguro", + ]; + + if absolute_claim_markers + .iter() + .any(|marker| response_lc.contains(marker)) + { + signals.push("absolute_claim_language".to_string()); + } + + let citation_like_markers = ["segun el documento", "fuente:", "referencia:", "[1]", "[2]"]; + if !has_rag_context + && citation_like_markers + .iter() + .any(|marker| response_lc.contains(marker)) + { + signals.push("citation_without_rag".to_string()); + } + + signals +} + +fn build_excerpt(text: &str, max_chars: usize) -> String { + let mut out = String::new(); + for ch in text.chars().take(max_chars) { + out.push(ch); + } + if text.chars().count() > max_chars { + out.push_str("..."); + } + out +} + +pub async fn list_ai_audit_logs( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Query(filters): Query, +) -> Result, (StatusCode, String)> { + ensure_audit_reviewer_role(&claims)?; + + let limit = filters.limit.unwrap_or(50).clamp(1, 200); + let offset = filters.offset.unwrap_or(0).max(0); + + let rows = sqlx::query( + r#" + SELECT + a.id, + a.user_id, + u.full_name AS student_name, + a.endpoint, + a.model, + a.output_tokens, + a.response, + a.request_metadata, + a.created_at, + COALESCE((a.request_metadata->>'audit_reviewed')::boolean, false) AS reviewed, + CASE + WHEN COALESCE(a.request_metadata->>'audit_reviewed_by', '') ~* '^[0-9a-fA-F-]{36}$' + THEN (a.request_metadata->>'audit_reviewed_by')::uuid + ELSE NULL + END AS reviewed_by, + rv.full_name AS reviewed_by_name, + a.request_metadata->>'audit_review_note' AS reviewer_note + FROM ai_usage_logs a + LEFT JOIN users u ON u.id = a.user_id + LEFT JOIN users rv ON rv.id = ( + CASE + WHEN COALESCE(a.request_metadata->>'audit_reviewed_by', '') ~* '^[0-9a-fA-F-]{36}$' + THEN (a.request_metadata->>'audit_reviewed_by')::uuid + ELSE NULL + END + ) + WHERE a.organization_id = $1 + AND a.request_type = 'chat' + AND ($2::boolean IS NULL OR COALESCE((a.request_metadata->>'audit_reviewed')::boolean, false) = $2::boolean) + ORDER BY a.created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(org_ctx.id) + .bind(filters.reviewed) + .bind(limit) + .bind(offset) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al listar auditoría de IA: {}", e)))?; + + let mut items = Vec::new(); + + for row in rows { + let metadata: Option = row.get("request_metadata"); + let has_rag_context = metadata + .as_ref() + .and_then(|m| m.get("has_rag")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let response: String = row.get::, _>("response").unwrap_or_default(); + let output_tokens: i32 = row.get("output_tokens"); + + let risk_signals = risk_signals_from_log(&response, has_rag_context, output_tokens); + if risk_signals.is_empty() { + continue; + } + + items.push(AiAuditItem { + id: row.get("id"), + user_id: row.get("user_id"), + student_name: row.get("student_name"), + endpoint: row.get("endpoint"), + model: row.get("model"), + output_tokens, + has_rag_context, + risk_score: risk_signals.len() as i32, + risk_signals, + response_excerpt: build_excerpt(&response, 240), + reviewed: row.get("reviewed"), + reviewed_by: row.get("reviewed_by"), + reviewed_by_name: row.get("reviewed_by_name"), + reviewer_note: row.get("reviewer_note"), + created_at: row.get("created_at"), + }); + } + + Ok(Json(AiAuditListResponse { + items, + limit, + offset, + })) +} + +pub async fn review_ai_audit_log( + Org(org_ctx): Org, + claims: Claims, + Path(log_id): Path, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + ensure_audit_reviewer_role(&claims)?; + + let note = payload + .reviewer_note + .as_deref() + .map(str::trim) + .unwrap_or("") + .to_string(); + + let note_or_null = if note.is_empty() { None } else { Some(note) }; + + let updated = sqlx::query( + r#" + UPDATE ai_usage_logs + SET request_metadata = COALESCE(request_metadata, '{}'::jsonb) || jsonb_strip_nulls( + jsonb_build_object( + 'audit_reviewed', $3::boolean, + 'audit_reviewed_by', $4::text, + 'audit_reviewed_at', NOW()::text, + 'audit_review_note', $5::text + ) + ) + WHERE id = $1 + AND organization_id = $2 + AND request_type = 'chat' + "#, + ) + .bind(log_id) + .bind(org_ctx.id) + .bind(payload.reviewed) + .bind(claims.sub.to_string()) + .bind(note_or_null) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al actualizar auditoría IA: {}", e)))?; + + if updated.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Registro de auditoría no encontrado".to_string())); + } + + Ok(Json(ReviewAiLogResponse { + id: log_id, + reviewed: payload.reviewed, + })) +} diff --git a/services/lms-service/src/handlers_data_ethics.rs b/services/lms-service/src/handlers_data_ethics.rs new file mode 100644 index 0000000..7cf30e0 --- /dev/null +++ b/services/lms-service/src/handlers_data_ethics.rs @@ -0,0 +1,193 @@ +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, +}; +use common::auth::Claims; +use common::middleware::Org; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +const DEFAULT_RETENTION_DAYS: i32 = 180; + +#[derive(Debug, Deserialize)] +pub struct DataEthicsFilters { + pub days: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct DataEthicsEventItem { + pub id: Uuid, + pub endpoint: String, + pub model: String, + pub request_type: String, + pub tokens_used: i32, + pub input_tokens: i32, + pub output_tokens: i32, + pub has_rag_context: bool, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct DataEthicsSummary { + pub days_window: i32, + pub total_requests: i64, + pub total_tokens: i64, + pub total_input_tokens: i64, + pub total_output_tokens: i64, + pub average_tokens_per_request: i64, + pub model_count: i64, + pub request_type_count: i64, + pub retention_days: i32, + pub stored_fields: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DataEthicsSummaryResponse { + pub summary: DataEthicsSummary, + pub events: Vec, +} + +fn ensure_data_ethics_viewer_role(claims: &Claims) -> Result<(), (StatusCode, String)> { + if claims.role != "admin" && claims.role != "instructor" { + return Err(( + StatusCode::FORBIDDEN, + "No autorizado para ver transparencia de datos IA".to_string(), + )); + } + Ok(()) +} + +pub async fn get_data_ethics_summary( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Query(filters): Query, +) -> Result, (StatusCode, String)> { + ensure_data_ethics_viewer_role(&claims)?; + + let days_window = filters.days.unwrap_or(30).clamp(1, 365); + let limit = filters.limit.unwrap_or(40).clamp(1, 200); + + let totals = sqlx::query( + r#" + SELECT + COUNT(*)::BIGINT AS total_requests, + COALESCE(SUM(tokens_used), 0)::BIGINT AS total_tokens, + COALESCE(SUM(input_tokens), 0)::BIGINT AS total_input_tokens, + COALESCE(SUM(output_tokens), 0)::BIGINT AS total_output_tokens, + COUNT(DISTINCT model)::BIGINT AS model_count, + COUNT(DISTINCT request_type)::BIGINT AS request_type_count + FROM ai_usage_logs + WHERE organization_id = $1 + AND created_at >= NOW() - ($2 || ' days')::interval + "#, + ) + .bind(org_ctx.id) + .bind(days_window) + .fetch_one(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al obtener resumen de ética de datos: {}", e), + ) + })?; + + let total_requests: i64 = totals.get("total_requests"); + let total_tokens: i64 = totals.get("total_tokens"); + let total_input_tokens: i64 = totals.get("total_input_tokens"); + let total_output_tokens: i64 = totals.get("total_output_tokens"); + let model_count: i64 = totals.get("model_count"); + let request_type_count: i64 = totals.get("request_type_count"); + let average_tokens_per_request = if total_requests > 0 { + total_tokens / total_requests + } else { + 0 + }; + + let event_rows = sqlx::query( + r#" + SELECT + id, + endpoint, + model, + request_type, + tokens_used, + input_tokens, + output_tokens, + request_metadata, + created_at + FROM ai_usage_logs + WHERE organization_id = $1 + AND created_at >= NOW() - ($2 || ' days')::interval + ORDER BY created_at DESC + LIMIT $3 + "#, + ) + .bind(org_ctx.id) + .bind(days_window) + .bind(limit) + .fetch_all(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al obtener eventos de ética de datos: {}", e), + ) + })?; + + let mut events = Vec::with_capacity(event_rows.len()); + for row in event_rows { + let metadata: Option = row.get("request_metadata"); + let has_rag_context = metadata + .as_ref() + .and_then(|m| m.get("has_rag")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + events.push(DataEthicsEventItem { + id: row.get("id"), + endpoint: row.get("endpoint"), + model: row.get("model"), + request_type: row.get("request_type"), + tokens_used: row.get("tokens_used"), + input_tokens: row.get("input_tokens"), + output_tokens: row.get("output_tokens"), + has_rag_context, + created_at: row.get("created_at"), + }); + } + + Ok(Json(DataEthicsSummaryResponse { + summary: DataEthicsSummary { + days_window, + total_requests, + total_tokens, + total_input_tokens, + total_output_tokens, + average_tokens_per_request, + model_count, + request_type_count, + retention_days: DEFAULT_RETENTION_DAYS, + stored_fields: vec![ + "prompt".to_string(), + "response".to_string(), + "tokens_used".to_string(), + "input_tokens".to_string(), + "output_tokens".to_string(), + "model".to_string(), + "endpoint".to_string(), + "request_type".to_string(), + "request_metadata.has_rag".to_string(), + "request_metadata.lesson_id".to_string(), + "request_metadata.session_id".to_string(), + "created_at".to_string(), + ], + }, + events, + })) +} \ No newline at end of file diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index dc40260..fc6d3b7 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -3,6 +3,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, }; +use crate::moderation::contains_inappropriate_language; use lettre::message::Mailbox; use lettre::transport::smtp::authentication::Credentials; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; @@ -465,6 +466,15 @@ pub async fn create_thread( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + if contains_inappropriate_language(&payload.title) + || contains_inappropriate_language(&payload.content) + { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "El contenido contiene lenguaje inapropiado. Por favor edita el texto e inténtalo nuevamente.".to_string(), + )); + } + let organization_name = load_organization_name(&pool, org_ctx.id).await; let thread_url = build_discussion_thread_url(course_id); let author_name: Option = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1") @@ -725,6 +735,13 @@ pub async fn create_post( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + if contains_inappropriate_language(&payload.content) { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "El contenido contiene lenguaje inapropiado. Por favor edita el texto e inténtalo nuevamente.".to_string(), + )); + } + let organization_name = load_organization_name(&pool, org_ctx.id).await; // Verificar si el hilo está bloqueado let thread = diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 8816a0c..e4c9cc9 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -10,6 +10,8 @@ mod handlers_notes; mod handlers_payments; mod handlers_peer_review; mod handlers_embeddings; +mod handlers_ai_audit; +mod handlers_data_ethics; mod handlers_faq; mod handlers_certificates; mod progress_tracking; @@ -20,6 +22,7 @@ mod live; mod portfolio; mod external_db; mod openapi; +mod moderation; use axum::{ Router, middleware, @@ -238,6 +241,19 @@ async fn main() { "/knowledge-base/{id}/embedding/regenerate", post(handlers_embeddings::regenerate_knowledge_embedding), ) + // Auditoría de respuestas IA para detección temprana de alucinaciones + .route( + "/ai/audit/logs", + get(handlers_ai_audit::list_ai_audit_logs), + ) + .route( + "/ai/audit/logs/{id}/review", + post(handlers_ai_audit::review_ai_audit_log), + ) + .route( + "/ai/data-ethics/summary", + get(handlers_data_ethics::get_data_ethics_summary), + ) // Moderación humana para FAQ basada en chats de alumnos .route( "/faq/review/import-candidates", diff --git a/services/lms-service/src/moderation.rs b/services/lms-service/src/moderation.rs new file mode 100644 index 0000000..61fc7b3 --- /dev/null +++ b/services/lms-service/src/moderation.rs @@ -0,0 +1,65 @@ +/// Moderación básica por diccionario para bloquear lenguaje ofensivo en texto libre. +/// Es un primer filtro de seguridad mientras se integra una capa de IA más robusta. +fn normalize_token(token: &str) -> String { + token + .to_lowercase() + .chars() + .map(|c| match c { + 'á' | 'à' | 'ä' | 'â' => 'a', + 'é' | 'è' | 'ë' | 'ê' => 'e', + 'í' | 'ì' | 'ï' | 'î' => 'i', + 'ó' | 'ò' | 'ö' | 'ô' => 'o', + 'ú' | 'ù' | 'ü' | 'û' => 'u', + _ => c, + }) + .collect() +} + +fn tokenize(text: &str) -> Vec { + text.split(|c: char| !c.is_alphanumeric()) + .filter(|t| !t.trim().is_empty()) + .map(normalize_token) + .collect() +} + +pub fn contains_inappropriate_language(text: &str) -> bool { + const BLOCKED_TERMS: [&str; 16] = [ + "mierda", + "idiota", + "imbecil", + "estupido", + "estupida", + "pendejo", + "pendeja", + "carajo", + "puto", + "puta", + "fuck", + "fucking", + "shit", + "bitch", + "asshole", + "bastard", + ]; + + const BLOCKED_PHRASES: [&str; 4] = [ + "vete al carajo", + "go to hell", + "piece of shit", + "son of a bitch", + ]; + + let normalized_text = normalize_token(text); + + if BLOCKED_PHRASES + .iter() + .any(|phrase| normalized_text.contains(phrase)) + { + return true; + } + + let tokens = tokenize(text); + tokens + .iter() + .any(|token| BLOCKED_TERMS.contains(&token.as_str())) +} diff --git a/web/experience/public/manifest.webmanifest b/web/experience/public/manifest.webmanifest new file mode 100644 index 0000000..cf01129 --- /dev/null +++ b/web/experience/public/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "OpenCCB Experience", + "short_name": "OpenCCB", + "description": "Aprendizaje en linea con soporte offline basico.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b1220", + "theme_color": "#2563eb", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/pwa-icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/pwa-icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} \ No newline at end of file diff --git a/web/experience/public/offline.html b/web/experience/public/offline.html new file mode 100644 index 0000000..a73beac --- /dev/null +++ b/web/experience/public/offline.html @@ -0,0 +1,46 @@ + + + + + + Sin conexion | OpenCCB + + + +
+

No hay conexion a internet

+

Puedes seguir usando partes ya cargadas de la plataforma mientras vuelve la conexion.

+

Cuando tengas internet, intenta recargar para sincronizar el contenido nuevo.

+ +
+ + \ No newline at end of file diff --git a/web/experience/public/pwa-icon-192.svg b/web/experience/public/pwa-icon-192.svg new file mode 100644 index 0000000..c2758bc --- /dev/null +++ b/web/experience/public/pwa-icon-192.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/experience/public/pwa-icon-512.svg b/web/experience/public/pwa-icon-512.svg new file mode 100644 index 0000000..8f501b3 --- /dev/null +++ b/web/experience/public/pwa-icon-512.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/experience/public/sw.js b/web/experience/public/sw.js new file mode 100644 index 0000000..407f8fa --- /dev/null +++ b/web/experience/public/sw.js @@ -0,0 +1,120 @@ +const SW_VERSION = "openccb-experience-v1"; +const STATIC_CACHE = `${SW_VERSION}-static`; +const PAGE_CACHE = `${SW_VERSION}-pages`; +const API_CACHE = `${SW_VERSION}-api`; + +const STATIC_ASSETS = [ + "/offline.html", + "/manifest.webmanifest", + "/pwa-icon-192.svg", + "/pwa-icon-512.svg", + "/next.svg", + "/window.svg", + "/globe.svg", + "/grid.svg", + "/file.svg" +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => !key.startsWith(SW_VERSION)) + .map((key) => caches.delete(key)) + ) + ) + .then(async () => { + if (self.registration.navigationPreload) { + await self.registration.navigationPreload.enable(); + } + await self.clients.claim(); + }) + ); +}); + +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); + +const isApiRequest = (url) => url.pathname.startsWith("/lms-api") || url.pathname.startsWith("/cms-api"); +const isStaticRequest = (request) => ["style", "script", "image", "font"].includes(request.destination); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith( + (async () => { + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + const preloadClone = preloadResponse.clone(); + caches.open(PAGE_CACHE).then((cache) => cache.put(request, preloadClone)); + return preloadResponse; + } + + return fetch(request) + .then((response) => { + const clone = response.clone(); + caches.open(PAGE_CACHE).then((cache) => cache.put(request, clone)); + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + return cached || caches.match("/offline.html"); + }); + })() + ); + return; + } + + if (isApiRequest(url)) { + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(API_CACHE).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + if (cached) return cached; + return new Response(JSON.stringify({ message: "Offline" }), { + status: 503, + headers: { "Content-Type": "application/json" } + }); + }) + ); + return; + } + + if (isStaticRequest(request)) { + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request).then((response) => { + if (!response || !response.ok) return response; + const clone = response.clone(); + caches.open(STATIC_CACHE).then((cache) => cache.put(request, clone)); + return response; + }); + }) + ); + } +}); \ No newline at end of file diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 93f95dd..9ad0e08 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -200,6 +200,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
{/* Navigation Sidebar */} {/* Main Content Area */}
{hasTranscription && ( )} @@ -274,7 +288,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
-
+
@@ -287,7 +301,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les {getLessonStatus(lesson) === 'not-started' && "No Iniciada"}
-

{lesson.title}

+

{lesson.title}

{lesson.summary && ( @@ -389,7 +403,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les userGrade?.metadata?.quiz_answers ? { score: userGrade.score, - answers: userGrade.metadata.quiz_answers[block.id] || userGrade.metadata.quiz_answers, + answers: ((userGrade.metadata.quiz_answers as Record)[block.id] || userGrade.metadata.quiz_answers), created_at: userGrade.created_at, } : undefined @@ -564,6 +578,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les {userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : ( <>
)} -
+
{/* Right Side Panels */} {((hasTranscription && transcriptOpen) || notesOpen) && ( -
{nextLesson ? ( - + Siguiente Lección ) : ( - + Finalizar Curso )} diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index 6a59639..2abd06d 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -5,6 +5,7 @@ import { lmsApi, Course, Module, Recommendation, UserGrade, Meeting, normalizePr import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react"; import Link from "next/link"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react"; +import { Award } from "lucide-react"; import { useAuth } from "@/context/AuthContext"; import DiscussionBoard from "@/components/DiscussionBoard"; import { AnnouncementsList } from "@/components/AnnouncementsList"; @@ -79,7 +80,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } // Load organization settings (branding includes the certificates_enabled flag) lmsApi.getBranding() - .then(res => setOrgSettings(res.organization)) + .then(res => setOrgSettings(res)) .catch(console.error); }, [params.id, user]); diff --git a/web/experience/src/app/layout.tsx b/web/experience/src/app/layout.tsx index a0c9ae9..dd62111 100644 --- a/web/experience/src/app/layout.tsx +++ b/web/experience/src/app/layout.tsx @@ -6,10 +6,15 @@ import { I18nProvider } from "@/context/I18nContext"; import { BrandingProvider } from "@/context/BrandingContext"; import AuthGuard from "@/components/AuthGuard"; import { ThemeProvider } from "@/context/ThemeContext"; +import PwaRegistration from "@/components/PwaRegistration"; +import PwaInstallPrompt from "@/components/PwaInstallPrompt"; +import ConnectivityBanner from "@/components/ConnectivityBanner"; +import OfflineSyncPanel from "@/components/OfflineSyncPanel"; export const metadata: Metadata = { title: "Experiencia de Aprendizaje", description: "Consume contenido educativo de alta fidelidad.", + manifest: "/manifest.webmanifest", }; import AppHeader from "@/components/AppHeader"; @@ -27,6 +32,10 @@ export default function RootLayout({ + + + +
{children} diff --git a/web/experience/src/components/AITutor.tsx b/web/experience/src/components/AITutor.tsx index 3cee428..c6581a0 100644 --- a/web/experience/src/components/AITutor.tsx +++ b/web/experience/src/components/AITutor.tsx @@ -18,6 +18,8 @@ export default function AITutor({ lessonId }: { lessonId: string }) { const [isOpen, setIsOpen] = useState(false); const [sessionId, setSessionId] = useState(null); const scrollRef = useRef(null); + const triggerRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { // Load session from localStorage on mount @@ -33,6 +35,28 @@ export default function AITutor({ lessonId }: { lessonId: string }) { } }, [messages]); + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + return; + } + + triggerRef.current?.focus(); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isOpen]); + const handleSend = async () => { if (!input.trim() || isLoading) return; @@ -60,10 +84,13 @@ export default function AITutor({ lessonId }: { lessonId: string }) { if (!isOpen) { return (
@@ -128,7 +160,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) { ))} {isLoading && ( -
  • +