Propuesta IA: reglas de validacion R0-R6 y filtro de contenido
R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son una ENTRADA que el modelo recibe en el prompt y puede copiar literalmente. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f375b3564
commit
45f166c116
@@ -0,0 +1,204 @@
|
||||
import type { PropuestaConsultiva } from "./schemas";
|
||||
import type { DatosEconomicos } from "./economia";
|
||||
|
||||
/**
|
||||
* Validacion post-generacion. Corre en codigo, nunca en la IA.
|
||||
*
|
||||
* R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son una
|
||||
* ENTRADA que el modelo recibe en el prompt y puede copiar literalmente en cualquier
|
||||
* campo de prosa. Comparar el bloque interno que produjo el modelo contra la salida
|
||||
* no detecta eso.
|
||||
*/
|
||||
|
||||
export interface Aviso {
|
||||
regla: string;
|
||||
severidad: "bloqueante" | "advertencia";
|
||||
mensaje: string;
|
||||
ruta?: string;
|
||||
}
|
||||
|
||||
/** Normaliza para comparar citas contra las fuentes: sin acentos, en minusculas, sin
|
||||
* puntuacion y con espacios colapsados. Laxa a proposito, para sobrevivir al ruido de
|
||||
* transcripcion automatica sin volverse inutil. */
|
||||
export function normalizar(s: string): string {
|
||||
return s
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const OTRO_CRM = /\b(hubspot|salesforce|pipedrive|zoho|go\s*high\s*level|gohighlevel|clientify|kommo|monday\s*crm)\b/i;
|
||||
const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(ventas|resultados|posici)/i;
|
||||
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i;
|
||||
const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i;
|
||||
|
||||
/** Todo el texto que termina impreso en el documento del cliente. */
|
||||
function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
|
||||
const out: { ruta: string; texto: string }[] = [
|
||||
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
|
||||
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
|
||||
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
|
||||
];
|
||||
p.diagnostico.hallazgos.forEach((h, i) => {
|
||||
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
|
||||
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
|
||||
});
|
||||
p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
|
||||
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
|
||||
);
|
||||
p.diagnostico.resultados.forEach((r, i) => out.push({ ruta: `resultados[${i}]`, texto: r.enunciado }));
|
||||
p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado }));
|
||||
p.redaccion.beneficios.forEach((b, i) =>
|
||||
out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` })
|
||||
);
|
||||
p.redaccion.exclusiones.forEach((e, i) => out.push({ ruta: `exclusiones[${i}]`, texto: e.texto }));
|
||||
p.redaccion.backlogEvolucion.forEach((b, i) => out.push({ ruta: `backlog[${i}]`, texto: b.problema }));
|
||||
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto }));
|
||||
p.hechos.decisionesPendientes.forEach((d, i) => out.push({ ruta: `decisiones[${i}]`, texto: d.texto }));
|
||||
return out;
|
||||
}
|
||||
|
||||
function ngramas(texto: string, n: number): Set<string> {
|
||||
const palabras = normalizar(texto).split(" ").filter(Boolean);
|
||||
const out = new Set<string>();
|
||||
for (let i = 0; i + n <= palabras.length; i++) out.add(palabras.slice(i, i + n).join(" "));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validarPropuesta(
|
||||
p: PropuestaConsultiva,
|
||||
econ: DatosEconomicos,
|
||||
fuentes: { textoCliente: string; textoInterno: string }
|
||||
): Aviso[] {
|
||||
const avisos: Aviso[] = [];
|
||||
const visible = textoVisible(p);
|
||||
|
||||
// ── R0 (bloqueante): el texto interno del asesor no puede aparecer en el documento
|
||||
// del cliente. Es entrada, no salida; ninguna otra regla lo mira.
|
||||
if (fuentes.textoInterno.trim()) {
|
||||
const internos = ngramas(fuentes.textoInterno, 7);
|
||||
if (internos.size) {
|
||||
for (const { ruta, texto } of visible) {
|
||||
for (const g of ngramas(texto, 7)) {
|
||||
if (internos.has(g)) {
|
||||
avisos.push({
|
||||
regla: "R0",
|
||||
severidad: "bloqueante",
|
||||
ruta,
|
||||
mensaje: `Texto de las notas internas aparece en el documento del cliente: "${g}"`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── R2 (bloqueante): ninguna partida inventada.
|
||||
const refsReales = new Set(econ.partidas.map((x) => x.refPartida));
|
||||
for (const a of p.redaccion.alcance) {
|
||||
if (!refsReales.has(a.refPartida)) {
|
||||
avisos.push({
|
||||
regla: "R2",
|
||||
severidad: "bloqueante",
|
||||
ruta: `alcance ${a.refPartida}`,
|
||||
mensaje: `La partida ${a.refPartida} no existe en la cotizacion.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Advertencia si el modelo se salto partidas reales.
|
||||
const refsUsadas = new Set(p.redaccion.alcance.map((a) => a.refPartida));
|
||||
for (const real of econ.partidas) {
|
||||
if (!refsUsadas.has(real.refPartida)) {
|
||||
avisos.push({
|
||||
regla: "R2b",
|
||||
severidad: "advertencia",
|
||||
ruta: real.refPartida,
|
||||
mensaje: `La partida "${real.nombre}" (${real.refPartida}) esta cotizada pero no aparece descrita.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── R3 (bloqueante): la cita destacada existe y es literal.
|
||||
const cita = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
|
||||
if (!cita) {
|
||||
avisos.push({
|
||||
regla: "R3",
|
||||
severidad: "bloqueante",
|
||||
mensaje: `citaDestacadaId ${p.redaccion.citaDestacadaId} no corresponde a ninguna cita extraida.`,
|
||||
});
|
||||
} else if (fuentes.textoCliente.trim()) {
|
||||
if (!normalizar(fuentes.textoCliente).includes(normalizar(cita.textoLiteral))) {
|
||||
avisos.push({
|
||||
regla: "R3",
|
||||
severidad: "bloqueante",
|
||||
ruta: cita.id,
|
||||
mensaje: `La cita destacada no aparece literal en las fuentes: "${cita.textoLiteral.slice(0, 70)}..."`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── R4 (advertencia): un hallazgo "confirmado" necesita respaldo.
|
||||
p.diagnostico.hallazgos.forEach((h, i) => {
|
||||
if (h.confianza === "confirmado" && h.citas.length + h.hechos.length === 0) {
|
||||
avisos.push({
|
||||
regla: "R4",
|
||||
severidad: "advertencia",
|
||||
ruta: `hallazgos[${i}]`,
|
||||
mensaje: `"${h.titulo}" se declara confirmado sin citas ni hechos. Deberia ser por_validar.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
|
||||
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
||||
const m = d.calculo.montoAnualMXN;
|
||||
if (m === null) return;
|
||||
const producto = d.calculo.factores.reduce((a, f) => a * f.valor, 1);
|
||||
const suma = d.calculo.factores.reduce((a, f) => a + f.valor, 0);
|
||||
const base = Math.max(Math.abs(m), 1);
|
||||
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
|
||||
if (!cuadra) {
|
||||
avisos.push({
|
||||
regla: "R5",
|
||||
severidad: "bloqueante",
|
||||
ruta: `valorProblema.dimensiones[${i}]`,
|
||||
mensaje: `montoAnualMXN ${m} no cuadra con sus factores (producto ${producto}, suma ${suma}).`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── R6 (advertencia): referencias colgantes entre pasos.
|
||||
const idsHallazgo = new Set(p.diagnostico.hallazgos.map((h) => h.id));
|
||||
p.redaccion.beneficios.forEach((b, i) => {
|
||||
if (!idsHallazgo.has(b.hallazgoId)) {
|
||||
avisos.push({
|
||||
regla: "R6",
|
||||
severidad: "advertencia",
|
||||
ruta: `beneficios[${i}]`,
|
||||
mensaje: `El beneficio apunta al hallazgo ${b.hallazgoId}, que no existe.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Filtro de contenido sobre lo visible ──
|
||||
for (const { ruta, texto } of visible) {
|
||||
if (OTRO_CRM.test(texto))
|
||||
avisos.push({ regla: "MARCA", severidad: "bloqueante", ruta, mensaje: "Menciona un CRM que no es Bucefalo." });
|
||||
if (MONTO_USD.test(texto))
|
||||
avisos.push({ regla: "MONEDA", severidad: "bloqueante", ruta, mensaje: "Contiene un monto o referencia en USD." });
|
||||
if (PROMESA_GARANTIA.test(texto))
|
||||
avisos.push({ regla: "GARANTIA", severidad: "bloqueante", ruta, mensaje: "Promete un resultado garantizado." });
|
||||
if (TEMA_SENSIBLE.test(texto))
|
||||
avisos.push({ regla: "PII", severidad: "advertencia", ruta, mensaje: "Menciona un tema laboral, salarial o de salud. Revisalo antes de enviar." });
|
||||
}
|
||||
|
||||
return avisos;
|
||||
}
|
||||
|
||||
export function hayBloqueantes(avisos: Aviso[]): boolean {
|
||||
return avisos.some((a) => a.severidad === "bloqueante");
|
||||
}
|
||||
Reference in New Issue
Block a user