chore: update code structure for improved readability and maintainability
This commit is contained in:
+1
-1
@@ -91,7 +91,7 @@
|
|||||||
## 🚀 Fases Estratégicas (Nuevas)
|
## 🚀 Fases Estratégicas (Nuevas)
|
||||||
|
|
||||||
### Fase 32: IA de Moderación y Ética 🛡️
|
### 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] **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`.)*
|
- [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`.)*
|
||||||
|
|
||||||
|
|||||||
@@ -62,22 +62,36 @@ pub struct ReviewAiLogResponse {
|
|||||||
pub reviewed: bool,
|
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> {
|
fn risk_signals_from_log(response: &str, has_rag_context: bool, output_tokens: i32) -> Vec<String> {
|
||||||
let mut signals = Vec::new();
|
let mut signals = Vec::new();
|
||||||
let response_lc = response.to_lowercase();
|
let response_lc = response.to_lowercase();
|
||||||
|
|
||||||
|
// ── Señal 1: ausencia de contexto RAG ────────────────────────────────────
|
||||||
if !has_rag_context {
|
if !has_rag_context {
|
||||||
signals.push("missing_rag_context".to_string());
|
signals.push("missing_rag_context".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Señal 2: tokens de salida excesivos ──────────────────────────────────
|
||||||
if output_tokens >= 900 {
|
if output_tokens >= 900 {
|
||||||
signals.push("high_output_tokens".to_string());
|
signals.push("high_output_tokens".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Señal 3: respuesta muy larga en caracteres ───────────────────────────
|
||||||
if response.chars().count() >= 2200 {
|
if response.chars().count() >= 2200 {
|
||||||
signals.push("long_response".to_string());
|
signals.push("long_response".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Señal 4: lenguaje de certeza absoluta ────────────────────────────────
|
||||||
let absolute_claim_markers = [
|
let absolute_claim_markers = [
|
||||||
"siempre",
|
"siempre",
|
||||||
"nunca",
|
"nunca",
|
||||||
@@ -85,8 +99,10 @@ fn risk_signals_from_log(response: &str, has_rag_context: bool, output_tokens: i
|
|||||||
"sin duda",
|
"sin duda",
|
||||||
"100%",
|
"100%",
|
||||||
"completamente seguro",
|
"completamente seguro",
|
||||||
|
"es un hecho que",
|
||||||
|
"está demostrado que",
|
||||||
|
"es imposible que",
|
||||||
];
|
];
|
||||||
|
|
||||||
if absolute_claim_markers
|
if absolute_claim_markers
|
||||||
.iter()
|
.iter()
|
||||||
.any(|marker| response_lc.contains(marker))
|
.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());
|
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
|
if !has_rag_context
|
||||||
&& citation_like_markers
|
&& citation_like_markers
|
||||||
.iter()
|
.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());
|
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
|
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 {
|
fn build_excerpt(text: &str, max_chars: usize) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for ch in text.chars().take(max_chars) {
|
for ch in text.chars().take(max_chars) {
|
||||||
@@ -189,6 +302,7 @@ pub async fn list_ai_audit_logs(
|
|||||||
if risk_signals.is_empty() {
|
if risk_signals.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let risk_score = weighted_risk_score(&risk_signals);
|
||||||
|
|
||||||
items.push(AiAuditItem {
|
items.push(AiAuditItem {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
@@ -198,7 +312,7 @@ pub async fn list_ai_audit_logs(
|
|||||||
model: row.get("model"),
|
model: row.get("model"),
|
||||||
output_tokens,
|
output_tokens,
|
||||||
has_rag_context,
|
has_rag_context,
|
||||||
risk_score: risk_signals.len() as i32,
|
risk_score,
|
||||||
risk_signals,
|
risk_signals,
|
||||||
response_excerpt: build_excerpt(&response, 240),
|
response_excerpt: build_excerpt(&response, 240),
|
||||||
reviewed: row.get("reviewed"),
|
reviewed: row.get("reviewed"),
|
||||||
@@ -268,3 +382,133 @@ pub async fn review_ai_audit_log(
|
|||||||
reviewed: payload.reviewed,
|
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 1–2
|
||||||
|
pub medium: i64, // score 3–5
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ async fn main() {
|
|||||||
"/ai/audit/logs/{id}/review",
|
"/ai/audit/logs/{id}/review",
|
||||||
post(handlers_ai_audit::review_ai_audit_log),
|
post(handlers_ai_audit::review_ai_audit_log),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/audit/metrics",
|
||||||
|
get(handlers_ai_audit::get_ai_audit_metrics),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/ai/data-ethics/summary",
|
"/ai/data-ethics/summary",
|
||||||
get(handlers_data_ethics::get_data_ethics_summary),
|
get(handlers_data_ethics::get_data_ethics_summary),
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertCircle, BrainCircuit, Calendar, CheckCircle2, Filter, RefreshCw, ShieldAlert, X } from 'lucide-react';
|
import { AlertCircle, BarChart2, BrainCircuit, Calendar, CheckCircle2, Filter, RefreshCw, ShieldAlert, X } from 'lucide-react';
|
||||||
import { AiAuditItem, lmsApi } from '@/lib/api';
|
import { AiAuditItem, AiAuditMetrics, lmsApi } from '@/lib/api';
|
||||||
|
|
||||||
type ReviewFilter = 'all' | 'pending' | 'reviewed';
|
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 = [
|
const ALL_SIGNALS = [
|
||||||
'missing_rag_context',
|
'missing_rag_context',
|
||||||
'high_output_tokens',
|
'high_output_tokens',
|
||||||
'long_response',
|
'long_response',
|
||||||
'absolute_claim_language',
|
'absolute_claim_language',
|
||||||
'citation_without_rag',
|
'citation_without_rag',
|
||||||
|
'knowledge_disclaimer',
|
||||||
|
'url_fabrication',
|
||||||
|
'sensitive_data_mention',
|
||||||
|
'high_certainty_no_rag',
|
||||||
|
'repeated_content',
|
||||||
] as const;
|
] 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];
|
type RiskSignal = (typeof ALL_SIGNALS)[number];
|
||||||
|
|
||||||
function formatSignal(signal: string) {
|
function formatSignal(signal: string) {
|
||||||
@@ -35,6 +43,10 @@ export default function AdminAiAuditPage() {
|
|||||||
const [workingId, setWorkingId] = useState<string | null>(null);
|
const [workingId, setWorkingId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
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
|
// Filtros de estado
|
||||||
const [filter, setFilter] = useState<ReviewFilter>('pending');
|
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(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
loadMetrics();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -103,6 +125,83 @@ export default function AdminAiAuditPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<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 flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -1756,6 +1756,11 @@ export const lmsApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}, true),
|
}, true),
|
||||||
|
getAiAuditMetrics: (days = 30): Promise<AiAuditMetrics> =>
|
||||||
|
apiFetch('/ai/audit/metrics', {
|
||||||
|
method: 'GET',
|
||||||
|
query: { days }
|
||||||
|
}, true),
|
||||||
getAiDataEthicsSummary: (
|
getAiDataEthicsSummary: (
|
||||||
days = 30,
|
days = 30,
|
||||||
limit = 40
|
limit = 40
|
||||||
@@ -1864,6 +1869,23 @@ export interface AiAuditListResponse {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiAuditWeightedScoreDist {
|
||||||
|
low: number; // score 1–2
|
||||||
|
medium: number; // score 3–5
|
||||||
|
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 {
|
export interface AiDataEthicsEventItem {
|
||||||
id: string;
|
id: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user