From 45f166c11650f18e74cc4a2d6aa36ffd9e5d352d Mon Sep 17 00:00:00 2001 From: urieljareth Date: Tue, 28 Jul 2026 18:17:30 -0600 Subject: [PATCH] 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) --- scripts/verificar-propuesta-validacion.ts | 117 +++++++++++++ src/lib/propuesta/validacion.ts | 204 ++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 scripts/verificar-propuesta-validacion.ts create mode 100644 src/lib/propuesta/validacion.ts diff --git a/scripts/verificar-propuesta-validacion.ts b/scripts/verificar-propuesta-validacion.ts new file mode 100644 index 0000000..de89285 --- /dev/null +++ b/scripts/verificar-propuesta-validacion.ts @@ -0,0 +1,117 @@ +import { validarPropuesta, normalizar, hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion"; +import type { PropuestaConsultiva } from "@/lib/propuesta/schemas"; +import type { DatosEconomicos } from "@/lib/propuesta/economia"; + +let fallas = 0; +function check(n: string, ok: boolean, d = "") { + console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`); + if (!ok) fallas++; +} +const tiene = (a: Aviso[], regla: string) => a.some((x) => x.regla === regla); + +const CITA = "no llevamos control de quien contesta el whatsapp el fin de semana"; + +const econ: DatosEconomicos = { + partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }], + totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true }, + moneda: "MXN", +}; + +const base = (): PropuestaConsultiva => ({ + hechos: { + citas: [{ id: "C01", textoLiteral: CITA, quienLoDijo: "el socio" }], + hechos: [{ id: "H01", enunciado: "No hay control de la atencion", confianza: "confirmado", citas: ["C01"] }], + materialesPendientes: [{ texto: "Accesos al dominio", bloqueaEntrega: true }], + decisionesPendientes: [{ texto: "Definir responsable de atencion", quienDecide: "el socio" }], + mencionesFueraDeAlcance: [], + redFlags: [], + }, + diagnostico: { + hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes del fin de semana se pierden.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }], + valorProblema: { + dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", calculo: { factores: [{ nombre: "horas por semana", valor: 8 }, { nombre: "semanas", valor: 52 }, { nombre: "costo por hora", valor: 400 }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }], + notaMetodologia: "Ocho horas por semana a costo cargado.", + }, + resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }], + }, + redaccion: { + hero: { titulo: "Propuesta de digitalizacion", subtitulo: "Ordenar la atencion para dejar de perder prospectos." }, + citaDestacadaId: "C01", + alcance: [{ refPartida: "P01", descripcionResultado: "Un lugar a donde mandar a los prospectos que llegan de los anuncios." }], + beneficios: [{ etiqueta: "Respuesta", texto: "Cada mensaje queda registrado y con responsable.", hallazgoId: "D01" }], + exclusiones: [{ texto: "No incluye migrar el historico de pedidos de los ultimos 4 anos.", razon: "Se evalua en fase 2." }], + backlogEvolucion: [], + notasInternas: ["El socio es quien decide."], + }, +}); + +const fuentes = { textoCliente: `El socio dijo: ${CITA}. Nada mas.`, textoInterno: "" }; + +check("normalizar quita acentos y puntuacion", normalizar("¿Como estas, Juan?") === "como estas juan"); +check("normalizar colapsa espacios", normalizar(" a b ") === "a b"); + +const limpia = validarPropuesta(base(), econ, fuentes); +check("una propuesta valida no produce bloqueantes", !hayBloqueantes(limpia), JSON.stringify(limpia.slice(0, 2))); + +let p = base(); p.redaccion.alcance[0].refPartida = "P99"; +check("R2 detecta una partida inventada", tiene(validarPropuesta(p, econ, fuentes), "R2")); + +p = base(); p.redaccion.alcance = []; +check("R2b avisa de una partida cotizada y no descrita", tiene(validarPropuesta(p, econ, fuentes), "R2b")); + +p = base(); p.redaccion.citaDestacadaId = "C09"; +check("R3 detecta una cita inexistente", tiene(validarPropuesta(p, econ, fuentes), "R3")); + +p = base(); p.hechos.citas[0].textoLiteral = "esto nunca lo dijo nadie en la reunion"; +check("R3 detecta una cita no verificable", tiene(validarPropuesta(p, econ, fuentes), "R3")); + +p = base(); p.hechos.citas[0].textoLiteral = "NO LLEVAMOS CONTROL, de quien contesta... el WhatsApp el fin de semana!"; +check("R3 tolera acentos, mayusculas y puntuacion", !tiene(validarPropuesta(p, econ, fuentes), "R3")); + +p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = []; +check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4")); + +p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = 999999; +check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5")); + +p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = null; +check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5")); + +p = base(); p.redaccion.beneficios[0].hallazgoId = "D99"; +check("R6 detecta un beneficio colgante", tiene(validarPropuesta(p, econ, fuentes), "R6")); + +p = base(); p.redaccion.beneficios[0].texto = "Lo integramos con HubSpot sin problema alguno."; +check("MARCA detecta otro CRM", tiene(validarPropuesta(p, econ, fuentes), "MARCA")); + +p = base(); p.redaccion.hero.subtitulo = "Una inversion de 1,800 USD al mes para tu negocio."; +check("MONEDA detecta USD", tiene(validarPropuesta(p, econ, fuentes), "MONEDA")); + +p = base(); p.redaccion.beneficios[0].texto = "Te garantizamos la posicion numero uno en Google."; +check("GARANTIA detecta la promesa", tiene(validarPropuesta(p, econ, fuentes), "GARANTIA")); + +p = base(); p.redaccion.hero.subtitulo = "El sueldo de quien atiende sale muy caro hoy en dia."; +check("PII advierte sobre tema salarial", tiene(validarPropuesta(p, econ, fuentes), "PII")); +check("PII es advertencia, no bloqueante", + validarPropuesta(p, econ, fuentes).filter((a) => a.regla === "PII").every((a) => a.severidad === "advertencia")); + +// ── R0: la fuga de notas internas, que es la razon de que exista ── +const interno = "el socio pidio descuento antes de entender el alcance del proyecto completo"; +p = base(); p.redaccion.hero.subtitulo = `Contexto del proyecto: ${interno}.`; +check("R0 detecta la fuga de notas internas", tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0")); +check("R0 es bloqueante", + validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }).filter((a) => a.regla === "R0").every((a) => a.severidad === "bloqueante")); +check("R0 no dispara si no hay fuga", !tiene(validarPropuesta(base(), econ, { ...fuentes, textoInterno: interno }), "R0")); +check("R0 sin notas internas no hace nada", !tiene(validarPropuesta(base(), econ, fuentes), "R0")); + +// La fuga tambien se detecta en un hallazgo, no solo en el hero. +p = base(); p.diagnostico.hallazgos[0].descripcion = `Observado: ${interno}.`; +check("R0 mira todos los campos visibles, no solo el hero", + tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0")); + +// Las notasInternas del propio objeto NO deben disparar R0: no se imprimen. +p = base(); p.redaccion.notasInternas = [interno]; +check("R0 no se queja de notasInternas (no se imprimen)", + !tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0")); + +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); diff --git a/src/lib/propuesta/validacion.ts b/src/lib/propuesta/validacion.ts new file mode 100644 index 0000000..da45e9b --- /dev/null +++ b/src/lib/propuesta/validacion.ts @@ -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 { + const palabras = normalizar(texto).split(" ").filter(Boolean); + const out = new Set(); + 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"); +}