chore: update code structure for improved readability and maintainability
This commit is contained in:
+1
-1
@@ -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`.)*
|
||||
|
||||
|
||||
@@ -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 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",
|
||||
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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 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 {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user