Files
cotizador/scripts/verificar-propuesta-pdf.ts
T
urieljarethandClaude Opus 5 74c0374a2a Propuesta IA: correcciones encontradas probando contra la API real de MiniMax
Tres defectos que solo se veian llamando al modelo de verdad.

1. min(2) en los factores obligaba a inventar relleno. Con una dimension sin
   cifras, MiniMax produjo "herramienta_de_seguimiento_actual=0 x canal=1" solo
   para satisfacer la restriccion. Ahora los dos factores se exigen unicamente
   cuando hay montoAnualMXN.

2. El modelo OMITE montoAnualMXN en vez de mandar null explicito, que es lo
   natural para un LLM. Exigirlo presente quemaba los tres intentos del paso.
   Ahora es .default(null) y la omision se tolera.

3. Sin confianza por factor, el modelo la metia dentro del nombre
   ("tasa_conversion (por_validar)=0.1"). Ahora es un campo.

Y el hallazgo que mas importa, porque es comercial y no de formato: el modelo
puede OMITIR un factor y aun asi cuadrar la aritmetica. En una corrida calculo
40 mensajes x 0.5 sin contestar x 52 semanas x 3,000 de utilidad POR CLIENTE =
3,120,000, asumiendo que cada mensaje sin responder es un cliente perdido. R5 lo
acepto porque los factores si multiplican al monto: R5 no puede ver lo que falta.

El ratio habria dicho "subcotizado" con el denominador inflado diez veces. Como
no se puede impedir que el modelo produzca estimaciones plausibles y erroneas, lo
que se hace es que el asesor las cache de un vistazo:
- R5b avisa cuando una cifra no tiene ni un factor confirmado.
- El anexo interno desglosa cada factor con su confianza, y alerta en rojo si el
  valor descansa entero en estimaciones.

Ademas, el presupuesto de reintentos sube (5 en extraccion, 4 en los otros dos):
MiniMax se equivoca de array de vez en cuando con schemas anidados, y la
extraccion es el paso fundacional. Una corrida real necesito los 5.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:59:42 -06:00

207 lines
11 KiB
TypeScript

import zlib from "node:zlib";
import { generarPropuestaPDF, generarAnexoInternoPDF, type DatosPropuestaPDF } from "@/lib/propuesta/pdf";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
import type { DatosEconomicos } from "@/lib/propuesta/economia";
/** Extrae el texto de un PDF de PDFKit. Con las fuentes base (Helvetica) no hay
* subsetting ni ToUnicode: el texto va como hex de UN byte por caracter dentro de
* streams comprimidos con Flate. */
function textoDePdf(pdf: Buffer): string {
const crudo = pdf.toString("latin1");
let salida = "";
const re = /stream\r?\n/g;
let m: RegExpExecArray | null;
while ((m = re.exec(crudo)) !== null) {
const ini = m.index + m[0].length;
const fin = crudo.indexOf("endstream", ini);
if (fin === -1) continue;
let bloque = "";
try {
bloque = zlib.inflateSync(Buffer.from(crudo.slice(ini, fin), "latin1")).toString("latin1");
} catch {
continue;
}
// PDFKit alterna dos codificaciones segun el contenido de la cadena:
// hex `<48656c6c6f>` y literal `(Hello) Tj`. Hay que leer las dos.
// OJO: no se puede meter un separador entre trozos hex. PDFKit aplica kerning y
// parte una misma palabra en varios elementos de un arreglo TJ
// (`[<4d41> -20 <524341444f52>]`), asi que un espacio de mas convierte
// "MARCADORVERDE" en "MARCA DORVERDE" y ninguna busqueda lo encuentra.
for (const t of bloque.matchAll(/<([0-9a-fA-F]{2,})>/g)) {
const hex = t[1];
for (let i = 0; i + 2 <= hex.length; i += 2) {
const b = parseInt(hex.slice(i, i + 2), 16);
if (b >= 32 && b < 256) salida += String.fromCharCode(b);
}
}
salida += " ";
for (const t of bloque.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
salida += t[1].replace(/\\([()\\])/g, "$1") + " ";
}
}
return salida;
}
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
const econ: DatosEconomicos = {
partidas: [
{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web institucional", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
{ refPartida: "P02", servicioCotizadoId: "c2", nombre: "Manejo de pauta", fase: 2, tipoPago: "mensual", precio: 8000, tiempoEntrega: "Mensual", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
],
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
moneda: "MXN",
};
const propuesta: PropuestaConsultiva = {
hechos: {
citas: [{ id: "C01", textoLiteral: "MARCADORCITA nadie contesta el fin de semana", quienLoDijo: "el socio" }],
hechos: [{ id: "H01", enunciado: "Sin control de atencion", confianza: "confirmado", citas: ["C01"] }],
materialesPendientes: [{ texto: "MARCADORMATERIAL accesos al dominio", bloqueaEntrega: true }],
decisionesPendientes: [{ texto: "MARCADORDECISION definir responsable", quienDecide: "el socio" }],
mencionesFueraDeAlcance: ["MARCADORFUERA app movil"],
redFlags: [{ senal: "MARCADORFLAG pidio descuento antes de ver el alcance", severidad: "alta" }],
},
diagnostico: {
hallazgos: [
{ id: "D01", urgencia: "rojo", titulo: "MARCADORHALLAZGO", descripcion: "Se pierden prospectos cada fin de semana.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
{ id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
],
valorProblema: {
dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", calculo: { factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }],
notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
},
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
},
redaccion: {
hero: { titulo: "MARCADORTITULO", subtitulo: "MARCADORSUB ordenar la atencion para dejar de perder prospectos." },
citaDestacadaId: "C01",
alcance: [
{ refPartida: "P01", descripcionResultado: "MARCADORALCANCE un lugar a donde mandar prospectos." },
{ refPartida: "P02", descripcionResultado: "MARCADORALCANCE2 cada peso a las busquedas que compran." },
],
beneficios: [{ etiqueta: "Respuesta", texto: "MARCADORBENEFICIO cada mensaje con responsable.", hallazgoId: "D01" }],
exclusiones: [{ texto: "MARCADOREXCLUSION no incluye migrar el historico.", razon: "fase 2" }],
backlogEvolucion: [{ problema: "MARCADORBACKLOG automatizar reportes", momentoSugerido: "tras la fase 1" }],
notasInternas: ["MARCADORINTERNO el socio decide, no el que vino a la reunion"],
},
};
const datos: DatosPropuestaPDF = {
propuesta,
economia: econ,
cliente: "Cliente Prueba",
empresa: "Empresa SA",
asesor: "Asesor",
numero: "UJ2607TEST",
fecha: new Date("2026-07-29"),
vigencia: new Date("2026-08-19"),
proyecto: "MKT Digital",
branding: { colorPrimario: "#2563eb" },
};
async function main() {
const pdf = await generarPropuestaPDF(datos);
const txt = textoDePdf(pdf);
check("se genera el documento del cliente", pdf.length > 2000, `${pdf.length} bytes`);
for (const marca of [
"MARCADORTITULO", "MARCADORSUB", "MARCADORCITA", "MARCADORHALLAZGO", "MARCADORVERDE",
"MARCADORVALOR", "MARCADORMETODO", "MARCADORRESULTADO", "MARCADORALCANCE", "MARCADORALCANCE2",
"MARCADORBENEFICIO", "MARCADOREXCLUSION", "MARCADORMATERIAL", "MARCADORDECISION", "MARCADORBACKLOG",
]) {
check(`el documento del cliente incluye ${marca}`, txt.includes(marca));
}
// Lo que NUNCA debe salir al cliente.
check("NO lleva notas internas", !txt.includes("MARCADORINTERNO"));
check("NO lleva red flags", !txt.includes("MARCADORFLAG"));
check("NO lleva menciones fuera de alcance", !txt.includes("MARCADORFUERA"));
// El dinero lo pone el codigo, no la IA.
check("imprime el precio de P01", txt.includes("12,000"));
check("imprime el precio de P02", txt.includes("8,000"));
check("imprime el total unico con IVA", txt.includes("13,920"));
check("imprime la mensualidad con IVA", txt.includes("9,280"));
check("declara que los importes llevan IVA", txt.includes("IVA incluido"));
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
// Sin IVA la nota cambia.
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
check("sin IVA la nota legal cambia", textoDePdf(sinIva).includes("sin IVA"));
// Una partida que la IA invento no se dibuja (R2 ya la marco).
const conFantasma = await generarPropuestaPDF({
...datos,
propuesta: { ...propuesta, redaccion: { ...propuesta.redaccion, alcance: [...propuesta.redaccion.alcance, { refPartida: "P99", descripcionResultado: "MARCADORFANTASMA inventada" }] } },
});
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA"));
// ── Anexo interno ──
const anexo = await generarAnexoInternoPDF(datos);
const txtA = textoDePdf(anexo);
check("se genera el anexo interno", anexo.length > 1000, `${anexo.length} bytes`);
check("el anexo se marca NO ENVIAR", txtA.includes("NO ENVIAR"));
check("el anexo lleva las notas internas", txtA.includes("MARCADORINTERNO"));
check("el anexo lleva las red flags", txtA.includes("MARCADORFLAG"));
check("el anexo lleva lo que quedo fuera", txtA.includes("MARCADORFUERA"));
// 125280 / 166400 = 75.3% -> objecion probable
check("el anexo calcula el ratio", txtA.includes("75.3"), "125280/166400");
check("el anexo interpreta el ratio", txtA.includes("objecion probable"));
check("el anexo sugiere responder con alcance", txtA.includes("no con descuento"));
check("el anexo desglosa de donde sale el valor anual", txtA.includes("De donde sale el valor anual"));
check("el anexo muestra los factores con su confianza", txtA.includes("confirmado") && txtA.includes("estimado"));
// R5b: una cifra sostenida solo por estimaciones debe llevar alerta visible.
const soloEstimado = await generarAnexoInternoPDF({
...datos,
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 } }] } } },
});
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
// Robustez del branding: la API de configuracion no valida los valores.
for (const malo of ["</style><script>", "rojo", "#GGG", ""]) {
const r = await generarPropuestaPDF({ ...datos, branding: { colorPrimario: malo } });
check(`un color invalido (${JSON.stringify(malo)}) no rompe el PDF`, r.length > 2000);
}
const logoMalo = await generarPropuestaPDF({ ...datos, branding: { logoBase64: "no-es-base64-valido" } });
check("un logo invalido no rompe el PDF", logoMalo.length > 2000);
// Contenido largo: no debe perderse ni romper el pie de pagina.
const largo = await generarPropuestaPDF({
...datos,
propuesta: {
...propuesta,
diagnostico: {
...propuesta.diagnostico,
hallazgos: Array.from({ length: 30 }, (_, i) => ({
id: `D${String(i + 1).padStart(2, "0")}`, urgencia: "ambar" as const,
titulo: `Hallazgo ${i} MARCADORLARGO`, descripcion: "x".repeat(300),
confianza: "estimado" as const, citas: [], hechos: [],
})),
},
},
});
const txtL = textoDePdf(largo);
check("el contenido largo se imprime", txtL.includes("MARCADORLARGO"));
check("el contenido largo conserva las secciones posteriores", txtL.includes("MARCADOREXCLUSION"));
check("el contenido largo genera varias paginas", (largo.toString("latin1").match(/\/Type\s*\/Page[^s]/g) || []).length > 1);
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
}
main().catch((e) => {
console.error("Error inesperado:", e);
process.exit(1);
});