Compare commits

...
6 Commits
Author SHA1 Message Date
urieljarethandClaude Opus 5 3a1eaf6e5b Propuesta IA: aflojar las restricciones que peleaban con una respuesta honesta
Medicion de 3 vueltas mas contra datos reales. El aplanado del commit anterior
NO redujo los reintentos: subieron de 3 a 10. Se mantiene porque quitar ese
nivel es correcto de todos modos, pero no cumplio su proposito.

Lo que si salio de esa medicion son dos restricciones mias que provocaban el
rechazo:

- min(5) en los textos de materiales, decisiones, menciones y red flags. Con una
  transcripcion pobre no hay nada que listar, asi que el modelo mete "N/A" o un
  guion para rellenar y falla. Ocurrio 4 veces SEGUIDAS en una vuelta. Baja a
  min(3), y el prompt ahora dice explicitamente que devolver el array vacio es la
  respuesta correcta y que no rellene.
- beneficios.etiqueta.max(40) se pasaba en 2 de 3 vueltas. Sube a 70.

Es el mismo patron que el tope de 40 partidas: el schema peleandose con la
realidad, no el modelo fallando.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:25:20 -06:00
urieljarethandClaude Opus 5 0c127b44c4 Propuesta IA: cerrar los hallazgos medios de la auditoria
El mas urgente lo cause yo al hacer que el bloqueo bloqueara de verdad: si un
aviso bloqueante caia en un campo que el panel no dejaba editar, el asesor
quedaba sin salida salvo regenerar. Ocho campos eran editables y bastantes mas
se imprimen al cliente. Ahora se pueden editar tambien la cita destacada y su
autor, las dimensiones del valor y su nota de metodologia, los resultados con su
metrica y periodo, los materiales, las decisiones con quien decide, y el backlog.
El boton de descarga del documento del cliente aparece deshabilitado cuando hay
bloqueantes, en vez de invitar a un clic que devuelve 409.

Doble propuesta: se rechaza generar. El documento asume UNA lista de alcance y
UNA caja de totales, asi que sumaria las dos opciones —que son alternativas
excluyentes— y mostraria un total que no existe. Soportarlas es rediseniar el
documento; mientras tanto es mejor no producir uno incorrecto. Hoy no hay
cotizaciones dobles en produccion, asi que no bloquea a nadie.

La transcripcion ahora se guarda con la propuesta (columna nueva, migracion
aditiva). Solo vivia en memoria durante la generacion, asi que al editar y
revalidar, R3 se quedaba sin fuente contra la cual comprobar que la cita
destacada siguiera siendo literal, y el aviso desaparecia solo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:15:03 -06:00
urieljarethandClaude Opus 5 434a427f70 Propuesta IA: aplanar el nivel calculo y subir el tope del subtitulo
Segunda tanda de rechazos observados contra datos reales (3 vueltas mas):

- Cada dimension del valor del problema tenia sus factores dentro de un objeto
  `calculo`. Ese nivel no aportaba nada semantico y era justo donde el modelo se
  perdia: devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Ahora
  `factores` y `montoAnualMXN` cuelgan de la dimension. Un nivel menos de
  anidamiento en el punto exacto donde fallaba.
- subtitulo.max(300) se quedaba corto. Sube a 400.

La primera tanda de arreglos ya habia bajado los rechazos de 7 a 3 en tres
vueltas, y de 3 vueltas con reintento en la redaccion a solo 1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:07:39 -06:00
urieljarethandClaude Opus 5 bdd9319fd0 Propuesta IA: corregir lo que encontro la auditoria de herramientas y plantillas
La auditoria confirma lo que se pedia verificar: la IA SOLO puede usar las tres
herramientas internas. Una por llamada, ninguna se ejecuta jamas (el input del
modelo solo va a schema.safeParse, no hay despachador), y no se envia ningun
tool del proveedor, web search, ejecucion de codigo, MCP ni beta.

Lo que NO cumplia era la otra mitad, la correspondencia con las plantillas:

CRITICO - el documento cobraba un IVA que la cotizacion no cobra. Cada partida
se imprimia a precio SIN IVA y debajo un unico total CON IVA, rematado con
"Importes con IVA incluido", mientras el PDF economico del mismo correo dice
"los precios no incluyen IVA" sobre las mismas cifras. Las lineas no sumaban su
propio total. Ahora la caja de totales desglosa subtotal / IVA / total como la
plantilla autorizada, las lineas siguen sin IVA igual que el otro documento, y
la nota al pie dice lo que de verdad hacen las lineas.

CRITICO - los avisos bloqueantes no bloqueaban nada. hayBloqueantes() no se
llamaba en ningun sitio y la ruta del PDF nunca leia avisos: el documento del
cliente se descargaba igual con una fuga de notas internas dentro. Ahora
devuelve 409 con la lista de lo que hay que corregir. El anexo interno si se
permite: es justo el que el asesor necesita para arreglarlo.

ALTO - seis campos que SI se imprimen al cliente no pasaban por los filtros de
marca, moneda, garantias y PII: el texto y el autor de la cita destacada, la
metrica y el periodo de cada resultado, quien decide cada pendiente, y el
momento del backlog. textoVisible ahora los cubre y queda documentado que debe
seguir a lo que dibuja el PDF.

ALTO - el plan Bucefalo se caia del documento y de sus totales, aunque si
aparece en el PDF economico y en el Excel: cargarEconomia nunca leia la
relacion. El cliente recibia dos documentos con alcances distintos.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:01:34 -06:00
urieljarethandClaude Opus 5 3f46d8a734 Propuesta IA: quitar los rechazos autoinfligidos y tolerar rarezas de MiniMax
Seis vueltas completas del pipeline contra los datos reales de UJ2606UR001
mostraron que la mayoria de los reintentos los provocaba el propio schema, no
el modelo:

- alcance.max(40) contra una cotizacion de 58 partidas garantizaba un rechazo de
  Zod en CADA corrida. Sube a 120. El tope solo acota una respuesta desbocada;
  la completitud del documento ya no depende de este array desde d20007d2.
- titulo.max(80) se quedaba corto y costo un reintento en 2 de 6 corridas.
  Sube a 140.

Y dos rarezas del proveedor, ambas observadas contra la API real:

- MiniMax a veces envuelve los elementos de un array en {item: {...}}. Se
  normaliza antes de validar, solo cuando "item" es la unica clave, para no
  tocar un campo legitimo con ese nombre.
- A veces emite DOS bloques tool_use en una respuesta. Antes se tomaba el
  primero a secas; ahora se prueban todos y gana el que valide.

Sobre el error de tipo de documento que se vio en produccion: NO se reprodujo en
seis corridas completas contra los mismos datos, y la generacion que lo siguio
completo sin problema (la fila quedo en la tabla). La evidencia apunta a un
fallo transitorio del proveedor, no a un defecto determinista nuestro. En vez de
inventar un arreglo para algo que no se puede reproducir, se reintentan los
fallos transitorios (5xx, 429, timeouts, red y los 400 con mensaje de parseo
interno) con espera creciente.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 20:54:48 -06:00
urieljarethandClaude Opus 5 d20007d252 Propuesta IA: el documento lista TODAS las partidas de la cotizacion
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]>
2026-07-28 20:40:43 -06:00
17 changed files with 711 additions and 120 deletions
+113
View File
@@ -0,0 +1,113 @@
/**
* Caza del error intermitente: corre el pipeline real contra UJ2606UR001 hasta que
* falle, e imprime TODO lo que sepamos de la peticion que lo provoco.
*/
import { cargarEconomia } from "@/lib/propuesta/economia";
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia";
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema, type Hechos, type Diagnostico } from "@/lib/propuesta/schemas";
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "@/lib/propuesta/prompts";
import { prisma } from "@/lib/db";
import type Anthropic from "@anthropic-ai/sdk";
import type { z } from "zod";
import fs from "node:fs";
const LOG = process.argv[2];
// Escritura directa: el buffer de stdout se pierde si el proceso muere.
function log(...a: unknown[]) {
const linea = a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" ");
fs.appendFileSync(LOG, linea + "\n");
}
const console = { log } as unknown as Console;
const NUMERO = "UJ2606UR001";
const TRANSCRIPCION = "Una propuesta de zero to hero para un negocio inicial";
/** Igual que llamarConHerramienta pero registra cada peticion y explota con detalle. */
async function correrPaso(nombre: string, msg: string, schema: z.ZodType, maxTokens: number, maxIntentos: number) {
const cliente = crearCliente();
const tools = [{ name: nombre, description: "Registra el resultado.", input_schema: aJsonSchema(schema) as never }];
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: msg }];
for (let intento = 1; intento <= maxIntentos; intento++) {
let res: Anthropic.Message;
try {
res = await cliente.messages.create({
model: modelo(), max_tokens: maxTokens,
system: [{ type: "text", text: SYSTEM_BASE, cache_control: { type: "ephemeral" } }],
tools, messages: mensajes,
});
} catch (e) {
const err = e as { status?: number; message?: string; error?: unknown };
console.log(`\n*** ERROR EN ${nombre} intento ${intento} ***`);
console.log(" status:", err.status);
console.log(" message:", String(err.message).slice(0, 600));
if (err.error) console.log(" error:", JSON.stringify(err.error).slice(0, 800));
console.log("\n --- FORMA DE LOS MENSAJES ENVIADOS ---");
mensajes.forEach((m, i) => {
const c = m.content;
const tipos = typeof c === "string" ? "string" : (c as { type: string }[]).map((b) => b.type).join(",");
console.log(` [${i}] role=${m.role} bloques=${tipos}`);
});
throw new Error("REPRODUCIDO");
}
const bloques = res.content.map((b) => b.type).join(",");
console.log(` ${nombre} intento ${intento}: stop=${res.stop_reason} bloques=[${bloques}] out=${res.usage.output_tokens}`);
const tus = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const ok = tus.find((b) => b.name === nombre);
if (ok) {
const parsed = schema.safeParse(ok.input);
if (parsed.success) return parsed.data;
const errTxt = (await import("zod")).z.prettifyError(parsed.error);
console.log(` zod rechazo: ${errTxt.split("\n")[0]}`);
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: [
...tus.map((b) => ({ type: "tool_result" as const, tool_use_id: b.id, is_error: true, content: errTxt })),
{ type: "text" as const, text: "Corrige y reintenta." },
] },
);
continue;
}
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: `Debes llamar a ${nombre}.` },
);
}
return null;
}
async function unaVuelta(entrada: ContextoEntrada, econ: Awaited<ReturnType<typeof cargarEconomia>>, n: number) {
console.log(`\n===== VUELTA ${n} =====`);
const h = (await correrPaso("registrar_hechos", mensajePaso1(entrada), hechosSchema, 8000, 5)) as Hechos | null;
if (!h) { console.log(" extraccion agoto intentos"); return; }
const d = (await correrPaso("registrar_diagnostico", mensajePaso2(entrada, h, econ), diagnosticoSchema, 8000, 4)) as Diagnostico | null;
if (!d) { console.log(" diagnostico agoto intentos"); return; }
const r = await correrPaso("registrar_propuesta", mensajePaso3(entrada, h, d, econ), redaccionSchema, 12000, 4);
console.log(r ? " vuelta completa OK" : " redaccion agoto intentos");
}
async function main() {
const cot = await prisma.cotizacion.findFirst({ where: { numero: NUMERO }, include: { cliente: true, servicios: true } });
if (!cot) throw new Error("no encontrada");
const econ = await cargarEconomia(cot.id);
const entrada: ContextoEntrada = {
transcripcion: TRANSCRIPCION,
notas: cot.servicios.map((s) => s.notas).filter(Boolean).join("\n"),
observaciones: cot.observaciones || "",
cliente: cot.cliente.nombre, empresa: cot.cliente.empresa || "", proyecto: cot.proyecto,
};
console.log(`${econ.partidas.length} partidas`);
for (let i = 1; i <= 3; i++) {
try { await unaVuelta(entrada, econ, i); }
catch (e) {
if (e instanceof Error && e.message === "REPRODUCIDO") { console.log("\n>>> error reproducido, deteniendo"); break; }
throw e;
}
}
await prisma.$disconnect();
}
main().catch((e) => { console.error("fallo:", e); process.exit(1); });
@@ -0,0 +1,3 @@
-- Guardar la transcripcion usada al generar la propuesta.
-- ADITIVA: columna nullable, no toca ninguna fila existente.
ALTER TABLE "PropuestaIA" ADD COLUMN IF NOT EXISTS "transcripcion" TEXT;
+3
View File
@@ -230,6 +230,9 @@ model PropuestaIA {
estado String @default("borrador") // borrador | aprobada estado String @default("borrador") // borrador | aprobada
contenidoIA Json contenidoIA Json
contenidoEditado Json? contenidoEditado Json?
// Transcripcion sanitizada usada al generar. Sin esto, el PATCH revalida con
// fuentes incompletas y R3 no puede comprobar que la cita destacada sea literal.
transcripcion String?
// Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache. // Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache.
traza Json? traza Json?
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar. // Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
+20 -1
View File
@@ -1,4 +1,4 @@
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia"; import { crearCliente, modelo, normalizarRespuesta } from "@/lib/propuesta/cliente-ia";
// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro. // No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro.
@@ -47,5 +47,24 @@ else delete process.env.MINIMAX_API_KEY;
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase; if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
else delete process.env.MINIMAX_BASE_URL; else delete process.env.MINIMAX_BASE_URL;
// ── Normalizacion de rarezas de MiniMax, observadas contra la API real ──
const igual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
check("desenvuelve {item: X} cuando item es la unica clave",
igual(normalizarRespuesta({ item: { a: 1 } }), { a: 1 }));
check("desenvuelve dentro de arrays",
igual(normalizarRespuesta([{ item: { a: 1 } }, { item: { a: 2 } }]), [{ a: 1 }, { a: 2 }]));
check("desenvuelve en profundidad",
igual(normalizarRespuesta({ lista: [{ item: { x: [{ item: 5 }] } }] }), { lista: [{ x: [5] }] }));
check("NO toca un objeto con item junto a otras claves",
igual(normalizarRespuesta({ item: 1, otro: 2 }), { item: 1, otro: 2 }));
check("deja intactos los valores simples",
normalizarRespuesta("texto") === "texto" && normalizarRespuesta(5) === 5 && normalizarRespuesta(null) === null);
check("no altera un objeto normal",
igual(normalizarRespuesta({ a: 1, b: [1, 2] }), { a: 1, b: [1, 2] }));
check("el caso real observado: alcance envuelto",
igual(normalizarRespuesta({ alcance: [{ item: { refPartida: "P01", descripcionResultado: "x" } }] }),
{ alcance: [{ refPartida: "P01", descripcionResultado: "x" }] }));
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1); process.exit(fallas === 0 ? 0 : 1);
+31 -5
View File
@@ -55,6 +55,7 @@ const econ: DatosEconomicos = {
], ],
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true }, totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
moneda: "MXN", moneda: "MXN",
planBucefalo: null,
}; };
const propuesta: PropuestaConsultiva = { const propuesta: PropuestaConsultiva = {
@@ -72,7 +73,7 @@ const propuesta: PropuestaConsultiva = {
{ id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", 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: { 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"] }], dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", 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.", notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
}, },
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }], resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
@@ -127,15 +128,29 @@ async function main() {
check("imprime el precio de P02", txt.includes("8,000")); check("imprime el precio de P02", txt.includes("8,000"));
check("imprime el total unico con IVA", txt.includes("13,920")); check("imprime el total unico con IVA", txt.includes("13,920"));
check("imprime la mensualidad con IVA", txt.includes("9,280")); check("imprime la mensualidad con IVA", txt.includes("9,280"));
check("declara que los importes llevan IVA", txt.includes("IVA incluido")); // El desglose debe cuadrar: lineas sin IVA + IVA como renglon propio.
check("imprime el subtotal sin IVA", txt.includes("Subtotal pago unico") && txt.includes("12,000"));
check("imprime el IVA como renglon propio", txt.includes("IVA 16%") && txt.includes("1,920"));
check("imprime el total con IVA", txt.includes("Total pago unico"));
check("la nota NO dice que las lineas incluyan IVA", !txt.includes("Importes con IVA incluido"));
check("la nota aclara que las partidas van sin IVA", txt.includes("no incluyen IVA"));
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente")); check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado")); check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
check("el backlog se marca como no comprometido", txt.includes("No comprometido")); check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
// Sin IVA la nota cambia. // Sin IVA no debe aparecer el renglon de IVA.
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } }); const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
check("sin IVA la nota legal cambia", textoDePdf(sinIva).includes("sin IVA")); const txtSinIva = textoDePdf(sinIva);
check("sin IVA no se imprime el renglon de IVA", !txtSinIva.includes("IVA 16%"));
check("sin IVA el total lo declara", txtSinIva.includes("sin IVA"));
// El plan Bucefalo tiene que aparecer: esta en la cotizacion y en los otros dos exportadores.
const conPlan = await generarPropuestaPDF({ ...datos, economia: { ...econ, planBucefalo: { nivel: "premium", precio: 4500 } } });
const txtPlan = textoDePdf(conPlan);
check("el plan Bucefalo aparece en el documento", txtPlan.includes("Bucefalo"));
check("el plan aparece con su nivel", txtPlan.includes("Premium"));
check("el plan aparece con su precio mensual", txtPlan.includes("4,500"));
// Una partida que la IA invento no se dibuja (R2 ya la marco). // Una partida que la IA invento no se dibuja (R2 ya la marco).
const conFantasma = await generarPropuestaPDF({ const conFantasma = await generarPropuestaPDF({
@@ -144,6 +159,17 @@ async function main() {
}); });
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA")); 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 ── // ── Anexo interno ──
const anexo = await generarAnexoInternoPDF(datos); const anexo = await generarAnexoInternoPDF(datos);
const txtA = textoDePdf(anexo); const txtA = textoDePdf(anexo);
@@ -163,7 +189,7 @@ async function main() {
const soloEstimado = await generarAnexoInternoPDF({ const soloEstimado = await generarAnexoInternoPDF({
...datos, ...datos,
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema, 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 } }] } } }, dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], 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 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")); check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
+1
View File
@@ -49,6 +49,7 @@ const econ: DatosEconomicos = {
partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }], partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true }, totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
moneda: "MXN", moneda: "MXN",
planBucefalo: null,
}; };
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" }; const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos; const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
+13 -7
View File
@@ -46,7 +46,7 @@ const diagBase = {
valorProblema: { valorProblema: {
dimensiones: [{ dimensiones: [{
tipo: "tiempo", descripcion: "x".repeat(12), tipo: "tiempo", descripcion: "x".repeat(12),
calculo: { factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416 }, factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416,
confianza: "estimado", hechos: [], confianza: "estimado", hechos: [],
}], }],
notaMetodologia: "n", notaMetodologia: "n",
@@ -56,15 +56,21 @@ const diagBase = {
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success, check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? "")); JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
check("con monto, exige al menos 2 factores", check("con monto, exige al menos 2 factores",
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 } }] } }).success); !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 }] } }).success);
check("sin monto, factores vacio es lo correcto", check("sin monto, factores vacio es lo correcto",
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [], montoAnualMXN: null } }] } }).success); diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [], montoAnualMXN: null }] } }).success);
check("omitir montoAnualMXN se tolera y queda en null", // Una dimension SIN la clave montoAnualMXN: es lo que manda el modelo cuando no tiene
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).success); // cifras, y por eso el campo lleva .default(null) en vez de solo .nullable().
const dimSinMonto = { tipo: "tiempo", descripcion: "x".repeat(12), factores: [], confianza: "por_validar", hechos: [] };
const sinMonto = { ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [dimSinMonto] } };
check("omitir montoAnualMXN se tolera", diagnosticoSchema.safeParse(sinMonto).success,
JSON.stringify(diagnosticoSchema.safeParse(sinMonto).error?.issues?.[0] ?? ""));
check("al omitirlo, Zod lo rellena con null", check("al omitirlo, Zod lo rellena con null",
diagnosticoSchema.parse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).valorProblema.dimensiones[0].calculo.montoAnualMXN === null); diagnosticoSchema.parse(sinMonto).valorProblema.dimensiones[0].montoAnualMXN === null);
check("la dimension quedo plana: sin nivel `calculo`",
!JSON.stringify(aJsonSchema(diagnosticoSchema)).includes('"calculo"'));
check("un factor sin confianza se rechaza", check("un factor sin confianza se rechaza",
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 } }] } }).success); !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 }] } }).success);
check("exige al menos un hallazgo", check("exige al menos un hallazgo",
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success); !diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
+5 -4
View File
@@ -15,6 +15,7 @@ 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: [] }], 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 }, totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
moneda: "MXN", moneda: "MXN",
planBucefalo: null,
}; };
const base = (): PropuestaConsultiva => ({ const base = (): PropuestaConsultiva => ({
@@ -29,7 +30,7 @@ const base = (): PropuestaConsultiva => ({
diagnostico: { 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"] }], 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: { valorProblema: {
dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", calculo: { factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }], dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
notaMetodologia: "Ocho horas por semana a costo cargado.", 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" }], resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }],
@@ -71,14 +72,14 @@ check("R3 tolera acentos, mayusculas y puntuacion", !tiene(validarPropuesta(p, e
p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = []; 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")); check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4"));
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = 999999; p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = 999999;
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5")); check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].calculo.factores = []; p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].factores = [];
check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5")); check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5"));
p = base(); p = base();
p.diagnostico.valorProblema.dimensiones[0].calculo.factores = p.diagnostico.valorProblema.dimensiones[0].calculo.factores.map((f) => ({ ...f, confianza: "estimado" as const })); p.diagnostico.valorProblema.dimensiones[0].factores = p.diagnostico.valorProblema.dimensiones[0].factores.map((f) => ({ ...f, confianza: "estimado" as const }));
check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b")); check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b"));
check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b")); check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
@@ -4,6 +4,7 @@ import { getConfigBranding } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators"; import { sanitizeFilename } from "@/lib/calculators";
import { cargarEconomia } from "@/lib/propuesta/economia"; import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf"; import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
import { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas"; import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente /** GET /api/propuesta-ia/:id/pdf -> documento del cliente
@@ -22,6 +23,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 }); if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 }); if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
// Los avisos bloqueantes tienen que bloquear DE VERDAD el documento del cliente.
// Antes esta ruta ni siquiera leia `avisos`: la severidad "bloqueante" era
// decorativa y el PDF se descargaba igual con una fuga de notas internas o un
// monto en USD dentro. El anexo interno SI se permite: es justamente el que el
// asesor necesita para entender que hay que corregir.
const avisos = Array.isArray(fila.avisos) ? (fila.avisos as unknown as Aviso[]) : [];
if (!anexo && hayBloqueantes(avisos)) {
const cuales = avisos
.filter((a) => a.severidad === "bloqueante")
.map((a) => `${a.regla}${a.ruta ? ` (${a.ruta})` : ""}: ${a.mensaje}`);
return NextResponse.json(
{
error:
"La propuesta tiene avisos bloqueantes sin resolver. Corrigelos en el panel y guarda antes de descargar el documento del cliente.",
bloqueantes: cuales,
},
{ status: 409 }
);
}
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva; const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
const economia = await cargarEconomia(id); const economia = await cargarEconomia(id);
+20 -1
View File
@@ -32,6 +32,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 }); if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
const { cot, notasPartidas } = ctx; const { cot, notasPartidas } = ctx;
// La doble propuesta todavia no esta soportada en el documento consultivo: su
// estructura asume UNA lista de alcance y UNA caja de totales, asi que sumaria las
// dos opciones —que son alternativas excluyentes— y mostraria un total inflado.
// Mejor no generar nada que generar un documento con un total que no existe.
if (cot.esDoble) {
return NextResponse.json(
{
error:
"Esta cotizacion es de doble propuesta y el documento consultivo todavia no las soporta: sumaria las dos opciones como si fueran una sola. Genera la propuesta desde una cotizacion de opcion unica.",
},
{ status: 400 }
);
}
const economia = await cargarEconomia(id); const economia = await cargarEconomia(id);
if (!economia.partidas.length) { if (!economia.partidas.length) {
return NextResponse.json( return NextResponse.json(
@@ -64,6 +78,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
traza: traza as unknown as object, traza: traza as unknown as object,
avisos: avisos as unknown as object, avisos: avisos as unknown as object,
modelo: traza.modelo, modelo: traza.modelo,
transcripcion: transcripcion || null,
}, },
}); });
@@ -113,9 +128,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// La edicion del asesor se revalida: pudo introducir una fuga, un USD o una // La edicion del asesor se revalida: pudo introducir una fuga, un USD o una
// promesa de resultado sin darse cuenta. // promesa de resultado sin darse cuenta.
//
// Se incluye la transcripcion GUARDADA con la propuesta. Antes solo vivia en
// memoria durante la peticion de generacion, asi que al editar R3 se quedaba sin
// fuente contra la cual comprobar la cita literal y el aviso desaparecia solo.
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]); const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, { const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
textoCliente: [ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"), textoCliente: [fila.transcripcion || "", ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
textoInterno: ctx?.cot.observacionesInternas || "", textoInterno: ctx?.cot.observacionesInternas || "",
}); });
+175
View File
@@ -172,6 +172,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante"); const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
const advertencias = avisos.filter((a) => a.severidad === "advertencia"); const advertencias = avisos.filter((a) => a.severidad === "advertencia");
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
if (cargando) { if (cargando) {
return ( return (
@@ -371,6 +372,167 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
))} ))}
</div> </div>
</div> </div>
{/* Los grupos de abajo tambien se imprimen al cliente, asi que tambien
pueden disparar un aviso bloqueante. Si no fueran editables, un
bloqueante ahi no tendria mas salida que regenerar la propuesta. */}
{citaDestacada && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">
Cita destacada
</label>
<p className="text-xs text-muted mb-2">
Se imprime literal. Editala solo para quitar un dato sensible, nunca para
mejorarle la redaccion al cliente.
</p>
<textarea
value={citaDestacada.textoLiteral}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.textoLiteral = e.target.value;
})
}
rows={2}
className={`${INPUT} mb-2`}
/>
<input
value={citaDestacada.quienLoDijo}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.quienLoDijo = e.target.value;
})
}
placeholder="Quien lo dijo (usa el rol, no el nombre)"
className={INPUT}
/>
</div>
)}
{propuesta.diagnostico.valorProblema.dimensiones.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Lo que cuesta no resolverlo
</label>
<div className="space-y-2">
{propuesta.diagnostico.valorProblema.dimensiones.map((dim, i) => (
<div key={i} className="flex gap-2 items-start">
<span className="text-xs text-muted mt-2.5 shrink-0 w-20">{dim.tipo}</span>
<textarea
value={dim.descripcion}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.dimensiones[i].descripcion = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
<textarea
value={propuesta.diagnostico.valorProblema.notaMetodologia}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.notaMetodologia = e.target.value; })}
rows={2}
placeholder="Nota de metodologia"
className={`${INPUT} mt-2`}
/>
</div>
)}
{propuesta.diagnostico.resultados.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Resultados a lograr
</label>
<div className="space-y-2">
{propuesta.diagnostico.resultados.map((r, i) => (
<div key={i} className="space-y-1">
<textarea
value={r.enunciado}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].enunciado = e.target.value; })}
rows={2}
className={INPUT}
/>
<div className="flex gap-2">
<input
value={r.metrica}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].metrica = e.target.value; })}
placeholder="Metrica"
className={INPUT}
/>
<input
value={r.periodoMedicion}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].periodoMedicion = e.target.value; })}
placeholder="Periodo"
className={INPUT}
/>
</div>
</div>
))}
</div>
</div>
)}
{(propuesta.hechos.materialesPendientes.length > 0 ||
propuesta.hechos.decisionesPendientes.length > 0) && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Que necesitamos de ustedes
</label>
<div className="space-y-2">
{propuesta.hechos.materialesPendientes.map((m, i) => (
<input
key={`mat-${i}`}
value={m.texto}
onChange={(e) => editar((p) => { p.hechos.materialesPendientes[i].texto = e.target.value; })}
className={INPUT}
/>
))}
{propuesta.hechos.decisionesPendientes.map((dd, i) => (
<div key={`dec-${i}`} className="flex gap-2">
<textarea
value={dd.texto}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].texto = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={dd.quienDecide}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].quienDecide = e.target.value; })}
placeholder="Quien decide"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</div>
))}
</div>
</div>
)}
{propuesta.redaccion.backlogEvolucion.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Registrado para mas adelante
</label>
<div className="space-y-2">
{propuesta.redaccion.backlogEvolucion.map((b, i) => (
<div key={i} className="flex gap-2">
<textarea
value={b.problema}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].problema = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={b.momentoSugerido}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].momentoSugerido = e.target.value; })}
placeholder="Cuando"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</div>
))}
</div>
</div>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border"> <div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
@@ -383,6 +545,18 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"} {guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
</button> </button>
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
invitar a un clic que va a fallar. La descarga del anexo interno
sigue disponible: es la que ayuda a entender que corregir. */}
{bloqueantes.length > 0 ? (
<span
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm text-muted bg-gray-50 cursor-not-allowed"
title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF) bloqueada
</span>
) : (
<a <a
href={`/api/propuesta-ia/${cotizacionId}/pdf`} href={`/api/propuesta-ia/${cotizacionId}/pdf`}
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50" className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
@@ -390,6 +564,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Propuesta consultiva (PDF) Propuesta consultiva (PDF)
</a> </a>
)}
<a <a
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`} href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
+87 -8
View File
@@ -20,6 +20,30 @@ export interface UsoTokens {
cacheEscritura: number; cacheEscritura: number;
} }
/**
* Normaliza rarezas observadas en las respuestas de MiniMax antes de validar.
*
* Observado contra la API real, sobre datos de produccion: a veces envuelve los
* elementos de un array en un objeto `{item: {...}}` en vez de emitir el objeto
* directamente. Zod lo rechaza con `Unrecognized keys: "item"` y se gasta un
* reintento en algo que se puede corregir aqui sin ambiguedad.
*
* Solo desenvuelve cuando `item` es la UNICA clave: si el objeto trae mas cosas,
* podria ser un campo legitimo y no se toca.
*/
export function normalizarRespuesta(v: unknown): unknown {
if (Array.isArray(v)) return v.map(normalizarRespuesta);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const claves = Object.keys(o);
if (claves.length === 1 && claves[0] === "item") return normalizarRespuesta(o.item);
const salida: Record<string, unknown> = {};
for (const k of claves) salida[k] = normalizarRespuesta(o[k]);
return salida;
}
return v;
}
export function crearCliente(): Anthropic { export function crearCliente(): Anthropic {
const apiKey = process.env.MINIMAX_API_KEY; const apiKey = process.env.MINIMAX_API_KEY;
if (!apiKey) { if (!apiKey) {
@@ -45,6 +69,51 @@ export interface BloqueSystem {
cachear: boolean; cachear: boolean;
} }
/**
* ¿El fallo es del proveedor y vale la pena reintentar?
*
* Motivacion: en produccion aparecio un "unexpected doc type" que NO se pudo reproducir
* en seis corridas completas del pipeline contra los mismos datos, y la generacion que
* lo siguio completo sin problema. Todo apunta a un fallo transitorio del proveedor.
* En vez de adivinar un arreglo para un error que no se puede reproducir, se reintenta
* esa clase de fallo con espera creciente.
*/
function esTransitorio(e: unknown): boolean {
const err = e as { status?: number; message?: string };
if (typeof err?.status === "number") {
// 408 timeout, 409 conflicto, 429 rate limit, 5xx y el 529 de sobrecarga.
if ([408, 409, 429].includes(err.status) || err.status >= 500) return true;
// 400 con mensaje que no describe un problema de nuestro payload: el proveedor
// devuelve errores de parseo internos con 400. Se reintenta una vez por si acaso.
if (err.status === 400 && /unexpected|internal|parse|unknown/i.test(err.message ?? "")) return true;
}
// Fallos de red sin status.
if (!err?.status && /ECONN|ETIMEDOUT|socket|network|fetch failed/i.test(err?.message ?? "")) return true;
return false;
}
const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Llama al proveedor reintentando SOLO los fallos transitorios. Los errores de
* nuestro payload no se reintentan aqui: los corrige el bucle de schema. */
async function crearMensajeConReintentos(
cliente: Anthropic,
cuerpo: Anthropic.MessageCreateParamsNonStreaming,
maxTransitorios = 3
): Promise<Anthropic.Message> {
let ultimo: unknown;
for (let i = 1; i <= maxTransitorios; i++) {
try {
return await cliente.messages.create(cuerpo);
} catch (e) {
ultimo = e;
if (!esTransitorio(e) || i === maxTransitorios) throw e;
await esperar(1000 * 2 ** (i - 1)); // 1s, 2s
}
}
throw ultimo;
}
interface OpcionesLlamada { interface OpcionesLlamada {
system: BloqueSystem[]; system: BloqueSystem[];
mensajeUsuario: string; mensajeUsuario: string;
@@ -77,7 +146,7 @@ export async function llamarConHerramienta<T>(
let ultimoError = ""; let ultimoError = "";
for (let intento = 1; intento <= opts.maxIntentos; intento++) { for (let intento = 1; intento <= opts.maxIntentos; intento++) {
const res = await cliente.messages.create({ const res = await crearMensajeConReintentos(cliente, {
model: modelo(), model: modelo(),
max_tokens: opts.maxTokens, max_tokens: opts.maxTokens,
system, system,
@@ -99,15 +168,25 @@ export async function llamarConHerramienta<T>(
const bloquesTool = res.content.filter( const bloquesTool = res.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use" (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
); );
const correcto = bloquesTool.find((b) => b.name === opts.herramienta.nombre); // El modelo a veces emite DOS tool_use en una misma respuesta (observado contra la
// API real). Se prueban todos los candidatos y gana el primero que valide, en vez
// de quedarse con el primero a secas y desperdiciar un reintento.
const candidatos = bloquesTool.filter((b) => b.name === opts.herramienta.nombre);
let correcto: Anthropic.ToolUseBlock | undefined;
for (const c of candidatos) {
const intentoParse = opts.herramienta.schema.safeParse(normalizarRespuesta(c.input));
if (intentoParse.success) {
return { datos: intentoParse.data as T, uso, intentos: intento };
}
// Se guarda el primero para reportar su error si ninguno valida.
if (!correcto) {
correcto = c;
ultimoError = z.prettifyError(intentoParse.error);
}
}
if (correcto) { if (correcto) {
const parsed = opts.herramienta.schema.safeParse(correcto.input);
if (parsed.success) {
return { datos: parsed.data as T, uso, intentos: intento };
}
ultimoError = z.prettifyError(parsed.error);
// Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar. // Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar.
// Un turno de usuario plano despues de un tool_use devuelve 400. // Un turno de usuario plano despues de un tool_use devuelve 400.
mensajes.push( mensajes.push(
+18 -1
View File
@@ -31,6 +31,10 @@ export interface DatosEconomicos {
partidas: PartidaCanonica[]; partidas: PartidaCanonica[];
totales: TotalesCotizacion; totales: TotalesCotizacion;
moneda: string; moneda: string;
/** Plan Bucefalo contratado, si lo hay. Se cobra mensual y aparece en el PDF
* economico y en el Excel; tiene que aparecer tambien en el consultivo o el
* cliente recibe dos documentos con alcances distintos. */
planBucefalo: { nivel: string; precio: number } | null;
} }
export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> { export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> {
@@ -43,6 +47,7 @@ export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomi
// correo y deben listar las partidas igual. // correo y deben listar las partidas igual.
orderBy: [{ fase: "asc" }, { createdAt: "asc" }], orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
}, },
planBucefalo: true,
}, },
}); });
if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`); if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`);
@@ -62,10 +67,22 @@ export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomi
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [], entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
})); }));
const plan =
cot.planBucefalo && cot.planBucefalo.seleccionado
? { nivel: cot.planBucefalo.nivel, precio: cot.planBucefalo.precio }
: null;
// El plan Bucefalo se cobra mensual: entra a los totales como una partida mensual
// mas, igual que en el PDF economico.
const paraTotales = plan
? [...activos, { tipoPago: "mensual", precio: plan.precio, seleccionado: true }]
: activos;
return { return {
partidas, partidas,
totales: calcularTotalesCotizacion(activos, { incluirIva: cot.incluirIva }), totales: calcularTotalesCotizacion(paraTotales, { incluirIva: cot.incluirIva }),
moneda: cot.moneda, moneda: cot.moneda,
planBucefalo: plan,
}; };
} }
+96 -24
View File
@@ -59,6 +59,16 @@ function hexSeguro(v: string | undefined, porDefecto: string): string {
return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto; return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto;
} }
/** Respaldo cuando la IA no describio una partida: se arma con los datos del
* catalogo, para que la partida nunca desaparezca del documento. */
function detalleDePartida(p: { tiempoEntrega: string; entregables: string[]; tipoPago: string }): string {
const trozos: string[] = [];
if (p.entregables.length) trozos.push(p.entregables.slice(0, 4).join(" · "));
if (p.tiempoEntrega) trozos.push(`Entrega: ${p.tiempoEntrega}`);
if (p.tipoPago === "mensual") trozos.push("Servicio mensual");
return trozos.join(" | ");
}
interface Lienzo { interface Lienzo {
doc: PDFKit.PDFDocument; doc: PDFKit.PDFDocument;
W: number; W: number;
@@ -177,7 +187,7 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
if (dims.length) { if (dims.length) {
titulo("Lo que cuesta no resolverlo"); titulo("Lo que cuesta no resolverlo");
for (const dim of dims) { for (const dim of dims) {
const monto = dim.calculo.montoAnualMXN; const monto = dim.montoAnualMXN;
const linea = `${dim.descripcion}${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`; const linea = `${dim.descripcion}${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`;
const h = txtH(linea, W - 14, 9); const h = txtH(linea, W - 14, 9);
y = need(h + 14, y); y = need(h + 14, y);
@@ -206,14 +216,18 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
y += 8; y += 8;
} }
// ── ALCANCE: la IA pone la prosa, el codigo pone el dinero ── // ── ALCANCE ──
// Se recorren las partidas de la COTIZACION, no las que la IA alcanzo a describir.
// Motivo: el schema acota `alcance` y una cotizacion con mas partidas que ese tope
// dejaria servicios cotizados fuera del documento del cliente, en silencio.
// Verificado con UJ2606UR001: 58 partidas, la IA describio 40, faltaban 18.
// La completitud la manda la base de datos; la IA solo aporta la prosa.
titulo("Alcance de la inversion"); titulo("Alcance de la inversion");
const porRef = new Map(d.economia.partidas.map((p) => [p.refPartida, p])); const descPorRef = new Map(d.propuesta.redaccion.alcance.map((a) => [a.refPartida, a.descripcionResultado]));
for (const a of d.propuesta.redaccion.alcance) { for (const p of d.economia.partidas) {
const p = porRef.get(a.refPartida); const descripcion = descPorRef.get(p.refPartida);
if (!p) continue; // R2 ya lo marco como bloqueante; aqui simplemente no se dibuja
const hNombre = txtH(p.nombre, W * 0.68, 9.5); const hNombre = txtH(p.nombre, W * 0.68, 9.5);
const hDesc = txtH(a.descripcionResultado, W * 0.68, 8); const hDesc = descripcion ? txtH(descripcion, W * 0.68, 8) : 0;
y = need(hNombre + hDesc + 14, y); y = need(hNombre + hDesc + 14, y);
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 }); doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
doc doc
@@ -222,36 +236,94 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
.fillColor(PRIMARY) .fillColor(PRIMARY)
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" }); .text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
y += hNombre + 2; y += hNombre + 2;
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(a.descripcionResultado, L, y, { width: W * 0.68 }); if (descripcion) {
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(descripcion, L, y, { width: W * 0.68 });
y += hDesc + 10; y += hDesc + 10;
} else {
// Sin prosa de la IA: la partida SI aparece, con su detalle del catalogo.
const respaldo = detalleDePartida(p);
if (respaldo) {
const hR = txtH(respaldo, W * 0.68, 8);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(respaldo, L, y, { width: W * 0.68 });
y += hR + 10;
} else {
y += 8;
}
}
}
// El plan Bucefalo es una partida mas del acuerdo: si no se imprime aqui, el
// cliente recibe un documento consultivo con menos alcance que su cotizacion.
if (d.economia.planBucefalo) {
const nivel = d.economia.planBucefalo.nivel;
const etiqueta = `CRM Bucefalo — plan ${nivel.charAt(0).toUpperCase() + nivel.slice(1)}`;
const hE = txtH(etiqueta, W * 0.68, 9.5);
y = need(hE + 22, y);
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(etiqueta, L, y, { width: W * 0.68 });
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(PRIMARY)
.text(`${formatCurrency(d.economia.planBucefalo.precio)} / mes`, L + W * 0.7, y, { width: W * 0.3, align: "right" });
y += hE + 2;
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text("Servicio mensual", L, y, { width: W * 0.68 });
y += 20;
} }
y = need(58, y); y = need(58, y);
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke(); doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
y += 10; y += 10;
// ── CAJA DE TOTALES ──
// Desglose completo (subtotal / IVA / total), como la plantilla autorizada.
//
// Antes se imprimia cada partida a su precio SIN IVA y debajo un unico total CON
// IVA, rematado con la leyenda "Importes con IVA incluido": las lineas no sumaban
// el total, la leyenda contradecia a sus propias lineas, y el PDF economico que va
// en el MISMO correo dice "los precios no incluyen IVA" sobre las mismas cifras.
// Ahora las lineas siguen siendo sin IVA —igual que el otro documento— y el IVA
// aparece como renglon propio.
const t = d.economia.totales; const t = d.economia.totales;
const filaTotal = (etiqueta: string, valor: string) => { const fila = (etiqueta: string, valor: string, fuerte = false) => {
y = need(16, y); y = need(fuerte ? 18 : 15, y);
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(etiqueta, L, y); doc
doc.font("Helvetica-Bold").fontSize(10).fillColor(PRIMARY).text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" }); .font(fuerte ? "Helvetica-Bold" : "Helvetica")
y += 16; .fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? DARK : MUTED)
.text(etiqueta, L, y);
doc
.font("Helvetica-Bold")
.fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? PRIMARY : DARK)
.text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
y += fuerte ? 18 : 15;
}; };
if (t.subtotalUnico > 0) filaTotal("Pago unico", formatCurrency(t.totalUnico));
if (t.subtotalMensual > 0) filaTotal("Mensualidad", formatCurrency(t.totalMensual)); if (t.subtotalUnico > 0) {
y = need(14, y); fila("Subtotal pago unico", formatCurrency(t.subtotalUnico));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaUnico));
fila(t.incluyeIva ? "Total pago unico" : "Total pago unico (sin IVA)", formatCurrency(t.totalUnico), true);
}
if (t.subtotalMensual > 0) {
if (t.subtotalUnico > 0) y += 4;
fila("Subtotal mensual", formatCurrency(t.subtotalMensual));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaMensual));
fila(t.incluyeIva ? "Total mensual" : "Total mensual (sin IVA)", formatCurrency(t.totalMensual), true);
}
y = need(16, y);
doc doc
.font("Helvetica") .font("Helvetica")
.fontSize(7.5) .fontSize(7.5)
.fillColor(MUTED) .fillColor(MUTED)
.text( .text(
t.incluyeIva t.incluyeIva
? "Importes con IVA incluido. Moneda nacional (MXN). Facturacion CFDI." ? "Los precios por partida son en Moneda Nacional (MXN) y no incluyen IVA; el IVA se desglosa arriba. Facturacion CFDI."
: "Importes sin IVA. Moneda nacional (MXN). Facturacion CFDI.", : "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.",
L, L,
y, y,
{ width: W } { width: W }
); );
y += 22; y += 24;
// ── BENEFICIOS ── // ── BENEFICIOS ──
if (d.propuesta.redaccion.beneficios.length) { if (d.propuesta.redaccion.beneficios.length) {
@@ -362,7 +434,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
y += 24; y += 24;
const valorAnual = const valorAnual =
d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.calculo.montoAnualMXN ?? 0), 0) || null; d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.montoAnualMXN ?? 0), 0) || null;
const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual); const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual);
const seccion = (t: string) => { const seccion = (t: string) => {
@@ -396,13 +468,13 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) { if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
seccion("De donde sale el valor anual"); seccion("De donde sale el valor anual");
for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) { for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) {
const monto = dim.calculo.montoAnualMXN; const monto = dim.montoAnualMXN;
const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`; const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`;
y = need(14, y); y = need(14, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y); doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
y += 13; y += 13;
if (dim.calculo.factores.length) { if (dim.factores.length) {
const desglose = dim.calculo.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x "); const desglose = dim.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
const h = txtH(desglose, W - 10, 8); const h = txtH(desglose, W - 10, 8);
y = need(h + 8, y); y = need(h + 8, y);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 }); doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 });
@@ -410,7 +482,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
} }
} }
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some( const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
(dim) => dim.calculo.montoAnualMXN !== null && !dim.calculo.factores.some((f) => f.confianza === "confirmado") (dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado")
); );
if (sinConfirmar) { if (sinConfirmar) {
const alerta = const alerta =
+2
View File
@@ -158,6 +158,8 @@ Reglas de este paso:
ser "por_validar". ser "por_validar".
- En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre - En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre
propio, salvo que sea el interlocutor comercial. propio, salvo que sea el interlocutor comercial.
- Si de alguna de estas listas no hay NADA que decir, devuelvela VACIA. Un array vacio
es la respuesta correcta; no metas "N/A", "ninguno" ni un guion para rellenar.
- En redFlags anota lo que deberia preocuparle al asesor: que pidan descuento antes de - En redFlags anota lo que deberia preocuparle al asesor: que pidan descuento antes de
entender el alcance, que no este presente quien decide, que no haya ninguna cifra del entender el alcance, que no este presente quien decide, que no haya ninguna cifra del
problema, o un "hagan todo y luego vemos".`; problema, o un "hagan todo y luego vemos".`;
+33 -25
View File
@@ -47,14 +47,14 @@ export const hechosSchema = z
) )
.max(40), .max(40),
materialesPendientes: z materialesPendientes: z
.array(z.object({ texto: z.string().min(5), bloqueaEntrega: z.boolean() })) .array(z.object({ texto: z.string().min(3), bloqueaEntrega: z.boolean() }))
.max(15), .max(15),
decisionesPendientes: z decisionesPendientes: z
.array(z.object({ texto: z.string().min(5), quienDecide: z.string() })) .array(z.object({ texto: z.string().min(3), quienDecide: z.string() }))
.max(15), .max(15),
mencionesFueraDeAlcance: z.array(z.string().min(5)).max(15), mencionesFueraDeAlcance: z.array(z.string().min(3)).max(15),
redFlags: z redFlags: z
.array(z.object({ senal: z.string().min(5), severidad: z.enum(["baja", "media", "alta"]) })) .array(z.object({ senal: z.string().min(3), severidad: z.enum(["baja", "media", "alta"]) }))
.max(10), .max(10),
}) })
.strict(); .strict();
@@ -69,7 +69,9 @@ export const diagnosticoSchema = z
urgencia: z urgencia: z
.enum(URGENCIA) .enum(URGENCIA)
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"), .describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
titulo: z.string().min(3).max(80), // 140 y no 80: con 80 el modelo se pasaba y gastaba un reintento. Observado
// en 2 de 6 corridas contra datos reales.
titulo: z.string().min(3).max(140),
descripcion: z.string().min(20), descripcion: z.string().min(20),
confianza: z.enum(CONFIANZA), confianza: z.enum(CONFIANZA),
citas: z.array(z.string().regex(ID_CITA)), citas: z.array(z.string().regex(ID_CITA)),
@@ -81,47 +83,47 @@ export const diagnosticoSchema = z
valorProblema: z.object({ valorProblema: z.object({
dimensiones: z dimensiones: z
.array( .array(
z.object({ z
.object({
tipo: z.enum(DIMENSION_COSTO), tipo: z.enum(DIMENSION_COSTO),
descripcion: z.string().min(10), descripcion: z.string().min(10),
calculo: z // Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
.object({ // nivel extra no aportaba nada semantico y era donde el modelo se perdia:
// devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Observado
// en 3 de 6 corridas contra datos reales.
//
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al // Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
// modelo a inventar relleno. Verificado contra la API real: produjo // modelo a inventar relleno. La exigencia se aplica solo si hay monto.
// "herramienta_de_seguimiento_actual=0 x canal_principal=1", que no es
// un calculo. La exigencia se aplica solo si hay monto (superRefine).
factores: z factores: z
.array( .array(
z.object({ z.object({
nombre: z.string(), nombre: z.string(),
valor: z.number(), valor: z.number(),
// Sin este campo el modelo metia la incertidumbre dentro del // Sin este campo el modelo metia la incertidumbre dentro del nombre
// nombre ("tasa_conversion (por_validar)=0.1"). Mejor que sea // ("tasa_conversion (por_validar)=0.1"). Mejor dato que convencion.
// un dato y no una convencion de nomenclatura.
confianza: z.enum(CONFIANZA), confianza: z.enum(CONFIANZA),
}) })
) )
.max(6), .max(6),
// .default(null) y no solo .nullable(): verificado contra la API real // .default(null) y no solo .nullable(): verificado contra la API real que
// que el modelo OMITE el campo en vez de mandar null explicito, que es // el modelo OMITE el campo en vez de mandar null, que es lo natural para
// lo natural para un LLM. Exigirlo presente quemaba los tres intentos. // un LLM. Exigirlo presente quemaba los tres intentos.
montoAnualMXN: z montoAnualMXN: z
.number() .number()
.nullable() .nullable()
.default(null) .default(null)
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."), .describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
confianza: z.enum(CONFIANZA),
hechos: z.array(z.string().regex(ID_HECHO)),
}) })
.superRefine((c, ctx) => { .superRefine((d, ctx) => {
if (c.montoAnualMXN !== null && c.factores.length < 2) { if (d.montoAnualMXN !== null && d.factores.length < 2) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["factores"], path: ["factores"],
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.", message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
}); });
} }
}),
confianza: z.enum(CONFIANZA),
hechos: z.array(z.string().regex(ID_HECHO)),
}) })
) )
.max(4), .max(4),
@@ -145,7 +147,7 @@ export const redaccionSchema = z
.object({ .object({
hero: z.object({ hero: z.object({
titulo: z.string().min(5).max(90), titulo: z.string().min(5).max(90),
subtitulo: z.string().min(20).max(300), subtitulo: z.string().min(20).max(400),
}), }),
citaDestacadaId: z citaDestacadaId: z
.string() .string()
@@ -161,11 +163,17 @@ export const redaccionSchema = z
.describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."), .describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."),
}) })
) )
.max(40), // 120 y no 40: una cotizacion real (UJ2606UR001) tiene 58 partidas, asi que el
// tope de 40 garantizaba un rechazo de Zod y un reintento desperdiciado en cada
// corrida. Observado en 6 de 6. El tope solo esta para acotar una respuesta
// desbocada; la completitud del documento ya no depende de este array, porque el
// generador recorre las partidas de la cotizacion.
.max(120),
beneficios: z beneficios: z
.array( .array(
z.object({ z.object({
etiqueta: z.string().min(3).max(40), // 70 y no 40: se pasaba en 2 de 3 vueltas contra datos reales.
etiqueta: z.string().min(3).max(70),
texto: z.string().min(20), texto: z.string().min(20),
hallazgoId: z hallazgoId: z
.string() .string()
+37 -11
View File
@@ -35,13 +35,29 @@ const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(vent
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/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; 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 }[] { * TODO el texto que termina impreso en el documento del cliente.
*
* Esta lista tiene que cubrir exactamente lo que dibuja `generarPropuestaPDF`. Es la
* unica fuente de los filtros de marca, moneda, garantias y PII: un campo que se
* imprime y no esta aqui llega al cliente sin que nadie lo mire.
*
* Faltaban seis, todos impresos: el texto y el autor de la cita destacada, la metrica
* y el periodo de cada resultado, quien decide cada pendiente, y el momento sugerido
* del backlog. Si agregas algo al PDF, agregalo aqui en el mismo cambio.
*/
export function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
const out: { ruta: string; texto: string }[] = [ const out: { ruta: string; texto: string }[] = [
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo }, { ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo }, { ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia }, { ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
]; ];
// La cita destacada se imprime literal, con su autor (pdf.ts, seccion Diagnostico).
const destacada = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
if (destacada) {
out.push({ ruta: `citas.${destacada.id}.textoLiteral`, texto: destacada.textoLiteral });
out.push({ ruta: `citas.${destacada.id}.quienLoDijo`, texto: destacada.quienLoDijo });
}
p.diagnostico.hallazgos.forEach((h, i) => { p.diagnostico.hallazgos.forEach((h, i) => {
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo }); out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion }); out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
@@ -49,15 +65,25 @@ function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[]
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion }) out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
); );
p.diagnostico.resultados.forEach((r, i) => out.push({ ruta: `resultados[${i}]`, texto: r.enunciado })); p.diagnostico.resultados.forEach((r, i) => {
out.push({ ruta: `resultados[${i}].enunciado`, texto: r.enunciado });
out.push({ ruta: `resultados[${i}].metrica`, texto: r.metrica });
out.push({ ruta: `resultados[${i}].periodoMedicion`, texto: r.periodoMedicion });
});
p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado })); p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado }));
p.redaccion.beneficios.forEach((b, i) => p.redaccion.beneficios.forEach((b, i) =>
out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` }) 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.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.redaccion.backlogEvolucion.forEach((b, i) => {
out.push({ ruta: `backlog[${i}].problema`, texto: b.problema });
out.push({ ruta: `backlog[${i}].momentoSugerido`, texto: b.momentoSugerido });
});
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto })); 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 })); p.hechos.decisionesPendientes.forEach((d, i) => {
out.push({ ruta: `decisiones[${i}].texto`, texto: d.texto });
out.push({ ruta: `decisiones[${i}].quienDecide`, texto: d.quienDecide });
});
return out; return out;
} }
@@ -155,10 +181,10 @@ export function validarPropuesta(
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar. // ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => { p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
const m = d.calculo.montoAnualMXN; const m = d.montoAnualMXN;
if (m === null) return; if (m === null) return;
const producto = d.calculo.factores.reduce((a, f) => a * f.valor, 1); const producto = d.factores.reduce((a, f) => a * f.valor, 1);
const suma = d.calculo.factores.reduce((a, f) => a + f.valor, 0); const suma = d.factores.reduce((a, f) => a + f.valor, 0);
const base = Math.max(Math.abs(m), 1); const base = Math.max(Math.abs(m), 1);
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02; const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
if (!cuadra) { if (!cuadra) {
@@ -179,15 +205,15 @@ export function validarPropuesta(
// conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la // conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la
// aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10. // aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => { p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
if (d.calculo.montoAnualMXN === null) return; if (d.montoAnualMXN === null) return;
const confirmados = d.calculo.factores.filter((f) => f.confianza === "confirmado").length; const confirmados = d.factores.filter((f) => f.confianza === "confirmado").length;
if (confirmados === 0) { if (confirmados === 0) {
avisos.push({ avisos.push({
regla: "R5b", regla: "R5b",
severidad: "advertencia", severidad: "advertencia",
ruta: `valorProblema.dimensiones[${i}]`, ruta: `valorProblema.dimensiones[${i}]`,
mensaje: mensaje:
`La cifra de ${d.calculo.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` + `La cifra de ${d.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` +
`Revisa el desglose antes de fiarte del ratio precio/valor.`, `Revisa el desglose antes de fiarte del ratio precio/valor.`,
}); });
} }