From 91db8cc55ce5ffd11f71d8b5aa409e67193e589d Mon Sep 17 00:00:00 2001 From: urieljareth Date: Tue, 28 Jul 2026 18:22:02 -0600 Subject: [PATCH] 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) --- scripts/verificar-propuesta-pdf.ts | 195 +++++++++++++ src/lib/propuesta/pdf.ts | 444 +++++++++++++++++++++++++++++ src/lib/propuesta/pipeline.ts | 96 +++++++ 3 files changed, 735 insertions(+) create mode 100644 scripts/verificar-propuesta-pdf.ts create mode 100644 src/lib/propuesta/pdf.ts create mode 100644 src/lib/propuesta/pipeline.ts diff --git a/scripts/verificar-propuesta-pdf.ts b/scripts/verificar-propuesta-pdf.ts new file mode 100644 index 0000000..f9ecdb6 --- /dev/null +++ b/scripts/verificar-propuesta-pdf.ts @@ -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 ["