Encontrado con datos reales de produccion. UJ2606UR001 tiene 58 partidas y el schema acota alcance a 40, asi que 18 servicios cotizados desaparecian del documento del cliente en silencio: aparecian en el PDF economico pero no en el consultivo, para el mismo envio. El arreglo no es subir el tope. El generador ahora recorre las partidas de la COTIZACION, no las que la IA alcanzo a describir, y usa la prosa de la IA cuando existe. Si falta, imprime el detalle del catalogo (entregables y tiempo de entrega) como respaldo. La completitud la manda la base de datos; la IA solo aporta la redaccion. Es el mismo principio que ya rige para el dinero. Verificado contra la propuesta real que genero produccion: 58 de 58 presentes, cero faltantes. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
218 lines
12 KiB
TypeScript
218 lines
12 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"));
|
|
|
|
// Completitud: la cotizacion manda, no lo que la IA alcanzo a describir.
|
|
// Verificado con UJ2606UR001 en produccion: 58 partidas, la IA describio 40.
|
|
const econGrande = { ...econ, partidas: [...econ.partidas,
|
|
{ refPartida: "P03", servicioCotizadoId: "c3", nombre: "MARCADORNODESCRITA servicio extra", fase: 3, tipoPago: "unico" as const, precio: 5000, tiempoEntrega: "1 semana", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: ["Entregable A", "Entregable B"] },
|
|
] };
|
|
const conNoDescrita = await generarPropuestaPDF({ ...datos, economia: econGrande });
|
|
const txtND = textoDePdf(conNoDescrita);
|
|
check("una partida SIN descripcion de la IA igual aparece", txtND.includes("MARCADORNODESCRITA"));
|
|
check("y aparece con su precio", txtND.includes("5,000"));
|
|
check("y con su detalle del catalogo como respaldo", txtND.includes("Entregable A"));
|
|
|
|
// ── 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);
|
|
});
|