feat: add PWA support with service worker, offline sync, and connectivity banners

- Added PWA icons for 192x192 and 512x512 resolutions.
- Implemented service worker (sw.js) for caching static assets and handling fetch requests.
- Created ConnectivityBanner component to notify users of online/offline status.
- Developed OfflineSyncPanel component to manage and display offline sync status.
- Introduced PwaInstallPrompt component to prompt users for PWA installation.
- Added PwaRegistration component to handle service worker registration and online event handling.
- Created AdminAiAuditPage for AI audit logs with filtering and review functionality.
- Developed AdminDataEthicsPage to display AI data ethics summary and recent events.
This commit is contained in:
2026-04-24 09:59:57 -04:00
parent 4148de5d66
commit e72f479639
32 changed files with 2332 additions and 74 deletions
+15
View File
@@ -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<Uuid>,
Json(payload): Json<ChatPayload>,
) -> Result<Json<ChatResponse>, (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<Uuid>,
Json(payload): Json<ChatRolePlayPayload>,
) -> Result<Json<ChatResponse>, (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");
@@ -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<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct ReviewAiLogPayload {
pub reviewed: bool,
pub reviewer_note: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AiAuditItem {
pub id: Uuid,
pub user_id: Uuid,
pub student_name: Option<String>,
pub endpoint: String,
pub model: String,
pub output_tokens: i32,
pub has_rag_context: bool,
pub risk_score: i32,
pub risk_signals: Vec<String>,
pub response_excerpt: String,
pub reviewed: bool,
pub reviewed_by: Option<Uuid>,
pub reviewed_by_name: Option<String>,
pub reviewer_note: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct AiAuditListResponse {
pub items: Vec<AiAuditItem>,
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<String> {
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<PgPool>,
Query(filters): Query<AiAuditFilters>,
) -> Result<Json<AiAuditListResponse>, (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<Value> = 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::<Option<String>, _>("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<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<ReviewAiLogPayload>,
) -> Result<Json<ReviewAiLogResponse>, (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,
}))
}
@@ -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<i32>,
pub limit: Option<i64>,
}
#[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<chrono::Utc>,
}
#[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<String>,
}
#[derive(Debug, Serialize)]
pub struct DataEthicsSummaryResponse {
pub summary: DataEthicsSummary,
pub events: Vec<DataEthicsEventItem>,
}
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<PgPool>,
Query(filters): Query<DataEthicsFilters>,
) -> Result<Json<DataEthicsSummaryResponse>, (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<Value> = 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,
}))
}
@@ -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<PgPool>,
Json(payload): Json<CreateThreadPayload>,
) -> Result<Json<DiscussionThread>, (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<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
@@ -725,6 +735,13 @@ pub async fn create_post(
State(pool): State<PgPool>,
Json(payload): Json<CreatePostPayload>,
) -> Result<Json<DiscussionPost>, (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 =
+16
View File
@@ -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",
+65
View File
@@ -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<String> {
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()))
}