Propuesta IA: pipeline de tres pasos y generadores PDF
El documento del cliente sigue la arquitectura argumental de la plantilla de referencia pero en tema claro con PDFKit: la plantilla original es oscura, y en impresion eso depende de una casilla del navegador desactivada por defecto. El anexo interno va en archivo separado, no como seccion oculta. 44 comprobaciones sobre PDFs reales: contenido presente, notas internas y red flags ausentes del documento del cliente, precios inyectados por el codigo, branding invalido tolerado y contenido largo multipagina. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
45f166c116
commit
91db8cc55c
@@ -0,0 +1,195 @@
|
||||
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 }, { nombre: "s", valor: 52 }, { nombre: "c", valor: 400 }], 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"));
|
||||
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user