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:
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user