Compare commits

...
8 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
urieljarethandClaude Opus 5 7682fcb86b Coolify: pasar las variables MINIMAX_* al contenedor web
El compose filtra que variables llegan al contenedor. Sin declararlas aqui,
ponerlas en el panel de Coolify no habria servido de nada: el boton de generar
propuesta habria fallado con 'Falta MINIMAX_API_KEY' aun estando configurada.

Editados los dos archivos, como exige AGENTS.md: docker-compose.yaml es una copia
identica de coolify.yml que existe para la deteccion por defecto de Coolify, y
editar solo uno despliega el viejo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 19:03:35 -06:00
urieljarethandClaude Opus 5 74c0374a2a Propuesta IA: correcciones encontradas probando contra la API real de MiniMax
Tres defectos que solo se veian llamando al modelo de verdad.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:59:42 -06:00
20 changed files with 810 additions and 83 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); });
+7
View File
@@ -40,6 +40,13 @@ services:
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_* # Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
# generar devuelve un error claro; el resto de la app funciona igual.
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue # Seed inicial: pon RUN_SEED=true solo en el primer despliegue
- RUN_SEED=${RUN_SEED:-false} - RUN_SEED=${RUN_SEED:-false}
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]} - SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
+7
View File
@@ -40,6 +40,13 @@ services:
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_* # Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
# generar devuelve un error claro; el resto de la app funciona igual.
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue # Seed inicial: pon RUN_SEED=true solo en el primer despliegue
- RUN_SEED=${RUN_SEED:-false} - RUN_SEED=${RUN_SEED:-false}
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]} - SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
@@ -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);
+41 -4
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 }, { nombre: "s", valor: 52 }, { nombre: "c", valor: 400 }], 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);
@@ -156,6 +182,17 @@ async function main() {
check("el anexo calcula el ratio", txtA.includes("75.3"), "125280/166400"); 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 interpreta el ratio", txtA.includes("objecion probable"));
check("el anexo sugiere responder con alcance", txtA.includes("no con descuento")); check("el anexo sugiere responder con alcance", txtA.includes("no con descuento"));
check("el anexo desglosa de donde sale el valor anual", txtA.includes("De donde sale el valor anual"));
check("el anexo muestra los factores con su confianza", txtA.includes("confirmado") && txtA.includes("estimado"));
// R5b: una cifra sostenida solo por estimaciones debe llevar alerta visible.
const soloEstimado = await generarAnexoInternoPDF({
...datos,
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } },
});
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
// Robustez del branding: la API de configuracion no valida los valores. // Robustez del branding: la API de configuracion no valida los valores.
for (const malo of ["</style><script>", "rojo", "#GGG", ""]) { for (const malo of ["</style><script>", "rojo", "#GGG", ""]) {
+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;
+18 -5
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 }, { nombre: "b", valor: 52 }], 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",
@@ -55,10 +55,23 @@ 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("el calculo 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 }], 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("montoAnualMXN acepta null (sin cifras)", check("sin monto, factores vacio es lo correcto",
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: null } }] } }).success); diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [], montoAnualMXN: null }] } }).success);
// Una dimension SIN la clave montoAnualMXN: es lo que manda el modelo cuando no tiene
// 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",
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",
!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);
check("urgencia fuera del semaforo se rechaza", check("urgencia fuera del semaforo se rechaza",
+9 -3
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 }, { nombre: "semanas", valor: 52 }, { nombre: "costo por hora", valor: 400 }], 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,12 +72,17 @@ 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 = 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.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 no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
p = base(); p.redaccion.beneficios[0].hallazgoId = "D99"; p = base(); p.redaccion.beneficios[0].hallazgoId = "D99";
check("R6 detecta un beneficio colgante", tiene(validarPropuesta(p, econ, fuentes), "R6")); check("R6 detecta un beneficio colgante", tiene(validarPropuesta(p, econ, fuentes), "R6"));
@@ -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 || "",
}); });
+182 -7
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,13 +545,26 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"} {guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
</button> </button>
<a {/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
href={`/api/propuesta-ia/${cotizacionId}/pdf`} invitar a un clic que va a fallar. La descarga del anexo interno
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50" sigue disponible: es la que ayuda a entender que corregir. */}
> {bloqueantes.length > 0 ? (
<Download className="w-4 h-4" /> <span
Propuesta consultiva (PDF) 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"
</a> title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF) bloqueada
</span>
) : (
<a
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"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF)
</a>
)}
<a <a
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`} href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
+92 -9
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(
@@ -126,7 +205,11 @@ export async function llamarConHerramienta<T>(
})), })),
{ {
type: "text" as const, type: "text" as const,
text: `La llamada no cumple el schema. Corrige EXACTAMENTE estos errores y vuelve a llamar a ${opts.herramienta.nombre}:\n\n${ultimoError}`, text:
`La llamada no cumple el schema. Corrige EXACTAMENTE estos errores y vuelve a ` +
`llamar a ${opts.herramienta.nombre}:\n\n${ultimoError}\n\n` +
`Revisa que cada objeto este en el array que le corresponde y que no falte ` +
`ningun campo obligatorio. No agregues campos que el schema no declara.`,
}, },
], ],
} }
+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,
}; };
} }
+128 -21
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) {
y += hDesc + 10; doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(descripcion, L, y, { width: W * 0.68 });
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) => {
@@ -389,6 +461,41 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
linea("Red flags detectadas", String(d.propuesta.hechos.redFlags.length)); linea("Red flags detectadas", String(d.propuesta.hechos.redFlags.length));
y += 6; y += 6;
// Desglose del valor anual. Va aqui porque es el denominador del ratio: si esa cifra
// esta inflada, el ratio miente y el asesor toma una decision de precio con un dato
// malo. Verificado en produccion que el modelo puede omitir un factor (una tasa de
// conversion) y aun asi cuadrar la aritmetica.
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
seccion("De donde sale el valor anual");
for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) {
const monto = dim.montoAnualMXN;
const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`;
y = need(14, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
y += 13;
if (dim.factores.length) {
const desglose = dim.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
const h = txtH(desglose, W - 10, 8);
y = need(h + 8, y);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 });
y += h + 8;
}
}
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
(dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado")
);
if (sinConfirmar) {
const alerta =
"Ojo: hay una cifra que descansa entera en factores estimados. Antes de fiarte del ratio, " +
"revisa que no falte un factor — la aritmetica puede cuadrar y aun asi estar inflada.";
const h = txtH(alerta, W, 8.5);
y = need(h + 12, y);
doc.font("Helvetica-Bold").fontSize(8.5).fillColor("#b91c1c").text(alerta, L, y, { width: W });
y += h + 14;
}
y += 4;
}
if (r) { if (r) {
const nota = const nota =
r.lectura === "subcotizado" r.lectura === "subcotizado"
+5 -2
View File
@@ -52,7 +52,10 @@ export async function generarPropuesta(opts: {
"Registra las citas literales, los hechos, los pendientes y las red flags extraidos del material de la reunion.", "Registra las citas literales, los hechos, los pendientes y las red flags extraidos del material de la reunion.",
schema: hechosSchema, schema: hechosSchema,
}, },
maxIntentos: 3, // 5 intentos: verificado contra la API real que MiniMax se equivoca de array de
// vez en cuando con schemas anidados (mete campos de "hechos" dentro de "citas").
// Es el paso fundacional; si falla, no hay documento. El cache abarata el reintento.
maxIntentos: 5,
maxTokens: 8000, maxTokens: 8000,
}); });
pasos.push({ paso: "extraccion", intentos: r1.intentos, uso: r1.uso }); pasos.push({ paso: "extraccion", intentos: r1.intentos, uso: r1.uso });
@@ -67,7 +70,7 @@ export async function generarPropuesta(opts: {
"Registra los hallazgos con su semaforo de urgencia, el valor anual del problema y los resultados de negocio a lograr.", "Registra los hallazgos con su semaforo de urgencia, el valor anual del problema y los resultados de negocio a lograr.",
schema: diagnosticoSchema, schema: diagnosticoSchema,
}, },
maxIntentos: 3, maxIntentos: 4,
maxTokens: 8000, maxTokens: 8000,
}); });
pasos.push({ paso: "diagnostico", intentos: r2.intentos, uso: r2.uso }); pasos.push({ paso: "diagnostico", intentos: r2.intentos, uso: r2.uso });
+11 -3
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".`;
@@ -187,9 +189,15 @@ Reglas de este paso:
desde los anuncios"). desde los anuncios").
- Intenta cubrir el semaforo completo, incluido al menos un verde. Si no hay evidencia de - Intenta cubrir el semaforo completo, incluido al menos un verde. Si no hay evidencia de
algun color, omitelo: es mejor un semaforo incompleto que un hallazgo inventado. algun color, omitelo: es mejor un semaforo incompleto que un hallazgo inventado.
- En valorProblema, cada dimension lleva su calculo desglosado en factores, y la - En valorProblema, si declaras un montoAnualMXN tienes que mostrar al menos dos
aritmetica tiene que cuadrar: montoAnualMXN debe ser el producto o la suma de sus factores que lo expliquen, y la aritmetica debe cuadrar: el monto es el producto o la
factores. Si no tienes cifras, deja montoAnualMXN en null y marca por_validar. suma de sus factores. Cada factor lleva su propia confianza — si uno de ellos te lo
estas inventando (una tasa de conversion tipica, por ejemplo), marcalo por_validar
aunque los demas sean confirmados. Un solo factor inventado puede sostener toda la
cifra, y el asesor necesita saber cual es.
- Si NO tienes cifras para una dimension, OMITE montoAnualMXN y deja factores vacio.
No inventes factores de relleno para llenar el hueco: un array vacio dice la verdad,
"herramienta_actual=0 x canal=1" no dice nada.
- Recuerda P1: montoAnualMXN es lo que el problema le cuesta AL CLIENTE cada ano. No es - Recuerda P1: montoAnualMXN es lo que el problema le cuesta AL CLIENTE cada ano. No es
un precio de E3 ni tiene relacion con lo que cotizamos. un precio de E3 ni tiene relacion con lo que cotizamos.
- Los resultados a lograr son de negocio y medibles. "Mejorar la presencia digital" no es - Los resultados a lograr son de negocio y medibles. "Mejorar la presencia digital" no es
+55 -18
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,19 +83,48 @@ export const diagnosticoSchema = z
valorProblema: z.object({ valorProblema: z.object({
dimensiones: z dimensiones: z
.array( .array(
z.object({ z
tipo: z.enum(DIMENSION_COSTO), .object({
descripcion: z.string().min(10), tipo: z.enum(DIMENSION_COSTO),
calculo: z.object({ descripcion: z.string().min(10),
factores: z.array(z.object({ nombre: z.string(), valor: z.number() })).min(2), // Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
// 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
// modelo a inventar relleno. La exigencia se aplica solo si hay monto.
factores: z
.array(
z.object({
nombre: z.string(),
valor: z.number(),
// Sin este campo el modelo metia la incertidumbre dentro del nombre
// ("tasa_conversion (por_validar)=0.1"). Mejor dato que convencion.
confianza: z.enum(CONFIANZA),
})
)
.max(6),
// .default(null) y no solo .nullable(): verificado contra la API real que
// el modelo OMITE el campo en vez de mandar null, que es lo natural para
// un LLM. Exigirlo presente quemaba los tres intentos.
montoAnualMXN: z montoAnualMXN: z
.number() .number()
.nullable() .nullable()
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. null si no hay cifras."), .default(null)
}), .describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
confianza: z.enum(CONFIANZA), confianza: z.enum(CONFIANZA),
hechos: z.array(z.string().regex(ID_HECHO)), hechos: z.array(z.string().regex(ID_HECHO)),
}) })
.superRefine((d, ctx) => {
if (d.montoAnualMXN !== null && d.factores.length < 2) {
ctx.addIssue({
code: "custom",
path: ["factores"],
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
});
}
})
) )
.max(4), .max(4),
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."), notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
@@ -116,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()
@@ -132,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()
+56 -8
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) {
@@ -171,6 +197,28 @@ export function validarPropuesta(
} }
}); });
// ── R5b (advertencia): el valor anual manda sobre el ratio precio/valor, y ese ratio
// es el que te dice si subcotizaste. Una cifra que descansa solo en factores
// estimados puede estar inflada un orden de magnitud sin que R5 lo note: R5 verifica
// que los factores multipliquen al monto, no que no FALTE un factor.
// Observado contra la API real: el modelo produjo 3,120,000 omitiendo la tasa de
// 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.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
if (d.montoAnualMXN === null) return;
const confirmados = d.factores.filter((f) => f.confianza === "confirmado").length;
if (confirmados === 0) {
avisos.push({
regla: "R5b",
severidad: "advertencia",
ruta: `valorProblema.dimensiones[${i}]`,
mensaje:
`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.`,
});
}
});
// ── R6 (advertencia): referencias colgantes entre pasos. // ── R6 (advertencia): referencias colgantes entre pasos.
const idsHallazgo = new Set(p.diagnostico.hallazgos.map((h) => h.id)); const idsHallazgo = new Set(p.diagnostico.hallazgos.map((h) => h.id));
p.redaccion.beneficios.forEach((b, i) => { p.redaccion.beneficios.forEach((b, i) => {