chore: update code structure for improved readability and maintainability

This commit is contained in:
2026-04-24 10:16:45 -04:00
parent e72f479639
commit 466f74b717
6 changed files with 377 additions and 8 deletions
+1 -1
View File
@@ -91,7 +91,7 @@
## 🚀 Fases Estratégicas (Nuevas)
### Fase 32: IA de Moderación y Ética 🛡️
- [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] **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. Señales endurecidas a 10 reglas con score ponderado + endpoint `GET /ai/audit/metrics` con distribución de scores y ranking de señales.)*
- [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`.)*
+247 -3
View File
@@ -62,22 +62,36 @@ pub struct ReviewAiLogResponse {
pub reviewed: bool,
}
/// Peso de cada señal para calcular el risk_score ponderado.
/// Señales de mayor impacto tienen peso > 1.
fn signal_weight(signal: &str) -> i32 {
match signal {
"sensitive_data_mention" => 3,
"url_fabrication" | "citation_without_rag" | "knowledge_disclaimer" => 2,
_ => 1,
}
}
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();
// ── Señal 1: ausencia de contexto RAG ────────────────────────────────────
if !has_rag_context {
signals.push("missing_rag_context".to_string());
}
// ── Señal 2: tokens de salida excesivos ──────────────────────────────────
if output_tokens >= 900 {
signals.push("high_output_tokens".to_string());
}
// ── Señal 3: respuesta muy larga en caracteres ───────────────────────────
if response.chars().count() >= 2200 {
signals.push("long_response".to_string());
}
// ── Señal 4: lenguaje de certeza absoluta ────────────────────────────────
let absolute_claim_markers = [
"siempre",
"nunca",
@@ -85,8 +99,10 @@ fn risk_signals_from_log(response: &str, has_rag_context: bool, output_tokens: i
"sin duda",
"100%",
"completamente seguro",
"es un hecho que",
"está demostrado que",
"es imposible que",
];
if absolute_claim_markers
.iter()
.any(|marker| response_lc.contains(marker))
@@ -94,7 +110,18 @@ fn risk_signals_from_log(response: &str, has_rag_context: bool, output_tokens: i
signals.push("absolute_claim_language".to_string());
}
let citation_like_markers = ["segun el documento", "fuente:", "referencia:", "[1]", "[2]"];
// ── Señal 5: citas/referencias sin contexto RAG ──────────────────────────
let citation_like_markers = [
"segun el documento",
"según el documento",
"fuente:",
"referencia:",
"[1]",
"[2]",
"[3]",
"(ver bibliografía)",
"de acuerdo con la fuente",
];
if !has_rag_context
&& citation_like_markers
.iter()
@@ -103,9 +130,95 @@ fn risk_signals_from_log(response: &str, has_rag_context: bool, output_tokens: i
signals.push("citation_without_rag".to_string());
}
// ── Señal 6: IA admite desconocer (paradoja: responde igualmente) ────────
let disclaimer_markers = [
"no tengo información sobre",
"no puedo confirmar",
"no tengo acceso a",
"desconozco",
"no estoy seguro de si",
"no cuento con información",
"no tengo conocimiento de",
"mis datos de entrenamiento",
"más allá de mi conocimiento",
];
if disclaimer_markers
.iter()
.any(|marker| response_lc.contains(marker))
{
signals.push("knowledge_disclaimer".to_string());
}
// ── Señal 7: URLs fabricadas sin RAG ─────────────────────────────────────
if !has_rag_context && (response_lc.contains("https://") || response_lc.contains("http://")) {
signals.push("url_fabrication".to_string());
}
// ── Señal 8: datos sensibles/personales mencionados ──────────────────────
let sensitive_markers = [
"contraseña",
"password",
"cédula",
"cedula",
"número de tarjeta",
"numero de tarjeta",
"cvv",
" pin ",
"número de cuenta",
"numero de cuenta",
"datos bancarios",
"información personal",
"número de identificación",
];
if sensitive_markers
.iter()
.any(|marker| response_lc.contains(marker))
{
signals.push("sensitive_data_mention".to_string());
}
// ── Señal 9: certeza alta sin RAG ("la respuesta es…") ───────────────────
let high_certainty_markers = [
"la respuesta correcta es",
"la solución correcta es",
"la respuesta es ",
"el resultado exacto es",
"está comprobado que",
"es correcto afirmar que",
"queda demostrado que",
];
if !has_rag_context
&& high_certainty_markers
.iter()
.any(|marker| response_lc.contains(marker))
{
signals.push("high_certainty_no_rag".to_string());
}
// ── Señal 10: contenido repetido (posible loop/alucinación) ──────────────
{
let sentences: Vec<&str> = response
.split(['.', '\n'])
.map(str::trim)
.filter(|s| s.chars().count() >= 40)
.collect();
let mut seen = std::collections::HashMap::<&str, usize>::new();
for s in &sentences {
*seen.entry(s).or_insert(0) += 1;
}
if seen.values().any(|&count| count >= 3) {
signals.push("repeated_content".to_string());
}
}
signals
}
/// Calcula el score ponderado sumando el peso de cada señal detectada.
pub fn weighted_risk_score(signals: &[String]) -> i32 {
signals.iter().map(|s| signal_weight(s)).sum()
}
fn build_excerpt(text: &str, max_chars: usize) -> String {
let mut out = String::new();
for ch in text.chars().take(max_chars) {
@@ -189,6 +302,7 @@ pub async fn list_ai_audit_logs(
if risk_signals.is_empty() {
continue;
}
let risk_score = weighted_risk_score(&risk_signals);
items.push(AiAuditItem {
id: row.get("id"),
@@ -198,7 +312,7 @@ pub async fn list_ai_audit_logs(
model: row.get("model"),
output_tokens,
has_rag_context,
risk_score: risk_signals.len() as i32,
risk_score,
risk_signals,
response_excerpt: build_excerpt(&response, 240),
reviewed: row.get("reviewed"),
@@ -268,3 +382,133 @@ pub async fn review_ai_audit_log(
reviewed: payload.reviewed,
}))
}
// ─────────────────────────────────────────────────────────────────────────────
// Métricas de auditoría IA
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct AiAuditMetricsFilters {
pub days: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct AiAuditMetrics {
pub days: i32,
pub total_chat_logs: i64,
pub total_flagged: i64,
pub total_reviewed: i64,
pub flagged_pct: f64,
pub reviewed_pct: f64,
pub signal_counts: std::collections::HashMap<String, i64>,
pub weighted_score_distribution: WeightedScoreDist,
}
#[derive(Debug, Serialize)]
pub struct WeightedScoreDist {
pub low: i64, // score 12
pub medium: i64, // score 35
pub high: i64, // score ≥ 6
}
pub async fn get_ai_audit_metrics(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Query(filters): Query<AiAuditMetricsFilters>,
) -> Result<Json<AiAuditMetrics>, (StatusCode, String)> {
ensure_audit_reviewer_role(&claims)?;
let days = filters.days.unwrap_or(30).clamp(1, 365);
// Totales agregados de la BD
let totals = sqlx::query(
r#"
SELECT
COUNT(*)::BIGINT AS total_chat,
SUM(CASE WHEN COALESCE((request_metadata->>'audit_reviewed')::boolean, false) THEN 1 ELSE 0 END)::BIGINT AS reviewed
FROM ai_usage_logs
WHERE organization_id = $1
AND request_type = 'chat'
AND created_at >= NOW() - ($2 || ' days')::interval
"#,
)
.bind(org_ctx.id)
.bind(days)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error métricas de auditoría: {}", e)))?;
let total_chat_logs: i64 = totals.get("total_chat");
let total_reviewed: i64 = totals.get("reviewed");
// Escaneamos respuestas para computar señales en memoria
let rows = sqlx::query(
r#"
SELECT output_tokens, response, request_metadata
FROM ai_usage_logs
WHERE organization_id = $1
AND request_type = 'chat'
AND created_at >= NOW() - ($2 || ' days')::interval
"#,
)
.bind(org_ctx.id)
.bind(days)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error escaneando logs: {}", e)))?;
let mut signal_counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
let mut total_flagged: i64 = 0;
let mut dist = WeightedScoreDist { low: 0, medium: 0, high: 0 };
for row in &rows {
let metadata: Option<Value> = row.get("request_metadata");
let has_rag = metadata
.as_ref()
.and_then(|m| m.get("has_rag"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output_tokens: i32 = row.get("output_tokens");
let response: String = row.get::<Option<String>, _>("response").unwrap_or_default();
let signals = risk_signals_from_log(&response, has_rag, output_tokens);
if signals.is_empty() {
continue;
}
total_flagged += 1;
let score = weighted_risk_score(&signals);
match score {
1..=2 => dist.low += 1,
3..=5 => dist.medium += 1,
_ => dist.high += 1,
}
for s in signals {
*signal_counts.entry(s).or_insert(0) += 1;
}
}
let flagged_pct = if total_chat_logs > 0 {
(total_flagged as f64 / total_chat_logs as f64) * 100.0
} else {
0.0
};
let reviewed_pct = if total_flagged > 0 {
(total_reviewed as f64 / total_flagged as f64) * 100.0
} else {
0.0
};
Ok(Json(AiAuditMetrics {
days,
total_chat_logs,
total_flagged,
total_reviewed,
flagged_pct,
reviewed_pct,
signal_counts,
weighted_score_distribution: dist,
}))
}
+4
View File
@@ -250,6 +250,10 @@ async fn main() {
"/ai/audit/logs/{id}/review",
post(handlers_ai_audit::review_ai_audit_log),
)
.route(
"/ai/audit/metrics",
get(handlers_ai_audit::get_ai_audit_metrics),
)
.route(
"/ai/data-ethics/summary",
get(handlers_data_ethics::get_data_ethics_summary),
+102 -3
View File
@@ -1,20 +1,28 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { AlertCircle, BrainCircuit, Calendar, CheckCircle2, Filter, RefreshCw, ShieldAlert, X } from 'lucide-react';
import { AiAuditItem, lmsApi } from '@/lib/api';
import { AlertCircle, BarChart2, BrainCircuit, Calendar, CheckCircle2, Filter, RefreshCw, ShieldAlert, X } from 'lucide-react';
import { AiAuditItem, AiAuditMetrics, lmsApi } from '@/lib/api';
type ReviewFilter = 'all' | 'pending' | 'reviewed';
// Todas las señales que el backend puede emitir
// Todas las señales que el backend puede emitir (sincronizadas con handlers_ai_audit.rs)
const ALL_SIGNALS = [
'missing_rag_context',
'high_output_tokens',
'long_response',
'absolute_claim_language',
'citation_without_rag',
'knowledge_disclaimer',
'url_fabrication',
'sensitive_data_mention',
'high_certainty_no_rag',
'repeated_content',
] as const;
// Señales de peso alto (score ponderado ≥ 2)
const HIGH_WEIGHT_SIGNALS = new Set(['sensitive_data_mention', 'url_fabrication', 'citation_without_rag', 'knowledge_disclaimer']);
type RiskSignal = (typeof ALL_SIGNALS)[number];
function formatSignal(signal: string) {
@@ -35,6 +43,10 @@ export default function AdminAiAuditPage() {
const [workingId, setWorkingId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
// Métricas de auditoría
const [metrics, setMetrics] = useState<AiAuditMetrics | null>(null);
const [metricsOpen, setMetricsOpen] = useState(false);
// Filtros de estado
const [filter, setFilter] = useState<ReviewFilter>('pending');
@@ -59,8 +71,18 @@ export default function AdminAiAuditPage() {
}
};
const loadMetrics = async () => {
try {
const m = await lmsApi.getAiAuditMetrics(30);
setMetrics(m);
} catch (err) {
console.error('Error loading AI audit metrics', err);
}
};
useEffect(() => {
loadData();
loadMetrics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -103,6 +125,83 @@ export default function AdminAiAuditPage() {
return (
<div className="space-y-6">
{/* ── Panel de métricas (30 días) ─────────────────────────────── */}
{metrics && (
<section className="rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-white/10 dark:bg-slate-900">
<button
onClick={() => setMetricsOpen((o) => !o)}
className="flex w-full items-center justify-between rounded-2xl p-4 text-left hover:bg-slate-50 dark:hover:bg-white/5"
>
<span className="flex items-center gap-2 font-bold text-slate-700 dark:text-slate-200">
<BarChart2 size={16} className="text-indigo-500" />
Métricas de riesgo (últimos 30 días)
</span>
<span className="text-xs font-semibold text-slate-400">
{metricsOpen ? '▲ ocultar' : '▼ expandir'}
</span>
</button>
{metricsOpen && (
<div className="border-t border-slate-200 p-4 dark:border-white/10">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 mb-4">
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800">
<p className="text-xs font-bold uppercase tracking-wide text-slate-500 dark:text-slate-400">Chats totales</p>
<p className="text-xl font-black text-slate-900 dark:text-white">{metrics.total_chat_logs.toLocaleString('es-ES')}</p>
</div>
<div className="rounded-lg bg-rose-50 p-3 dark:bg-rose-900/20">
<p className="text-xs font-bold uppercase tracking-wide text-rose-600 dark:text-rose-400">Con señales</p>
<p className="text-xl font-black text-rose-700 dark:text-rose-300">
{metrics.total_flagged.toLocaleString('es-ES')}
<span className="ml-1 text-sm font-semibold opacity-70">({metrics.flagged_pct.toFixed(1)}%)</span>
</p>
</div>
<div className="rounded-lg bg-emerald-50 p-3 dark:bg-emerald-900/20">
<p className="text-xs font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">Revisados</p>
<p className="text-xl font-black text-emerald-700 dark:text-emerald-300">
{metrics.total_reviewed.toLocaleString('es-ES')}
<span className="ml-1 text-sm font-semibold opacity-70">({metrics.reviewed_pct.toFixed(1)}%)</span>
</p>
</div>
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800">
<p className="text-xs font-bold uppercase tracking-wide text-slate-500 dark:text-slate-400">Distribución score</p>
<div className="mt-1 flex gap-1 text-xs font-bold">
<span className="rounded bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Bajo {metrics.weighted_score_distribution.low}</span>
<span className="rounded bg-orange-100 px-2 py-1 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">Medio {metrics.weighted_score_distribution.medium}</span>
<span className="rounded bg-rose-100 px-2 py-1 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300">Alto {metrics.weighted_score_distribution.high}</span>
</div>
</div>
</div>
{/* Ranking de señales */}
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Frecuencia por señal</p>
<div className="flex flex-wrap gap-2">
{Object.entries(metrics.signal_counts)
.sort(([, a], [, b]) => b - a)
.map(([signal, count]) => (
<span
key={signal}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-bold ${
HIGH_WEIGHT_SIGNALS.has(signal)
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300'
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
}`}
>
{formatSignal(signal)}
<span className="rounded-full bg-white/60 px-1 text-[10px] dark:bg-black/30">{count}</span>
</span>
))}
{Object.keys(metrics.signal_counts).length === 0 && (
<span className="text-sm text-slate-400">Sin señales detectadas en los últimos 30 días.</span>
)}
</div>
</div>
</div>
)}
</section>
)}
<header className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-slate-900">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
+22
View File
@@ -1756,6 +1756,11 @@ export const lmsApi = {
method: 'POST',
body: JSON.stringify(payload)
}, true),
getAiAuditMetrics: (days = 30): Promise<AiAuditMetrics> =>
apiFetch('/ai/audit/metrics', {
method: 'GET',
query: { days }
}, true),
getAiDataEthicsSummary: (
days = 30,
limit = 40
@@ -1864,6 +1869,23 @@ export interface AiAuditListResponse {
offset: number;
}
export interface AiAuditWeightedScoreDist {
low: number; // score 12
medium: number; // score 35
high: number; // score ≥ 6
}
export interface AiAuditMetrics {
days: number;
total_chat_logs: number;
total_flagged: number;
total_reviewed: number;
flagged_pct: number;
reviewed_pct: number;
signal_counts: Record<string, number>;
weighted_score_distribution: AiAuditWeightedScoreDist;
}
export interface AiDataEthicsEventItem {
id: string;
endpoint: string;
File diff suppressed because one or more lines are too long