Compare commits
8
Commits
ce3a38f89d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1eaf6e5b | ||
|
|
0c127b44c4 | ||
|
|
434a427f70 | ||
|
|
bdd9319fd0 | ||
|
|
3f46d8a734 | ||
|
|
d20007d252 | ||
|
|
7682fcb86b | ||
|
|
74c0374a2a |
+113
@@ -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); });
|
||||
@@ -40,6 +40,13 @@ services:
|
||||
# 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}
|
||||
- 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
|
||||
- RUN_SEED=${RUN_SEED:-false}
|
||||
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
|
||||
|
||||
@@ -40,6 +40,13 @@ services:
|
||||
# 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}
|
||||
- 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
|
||||
- RUN_SEED=${RUN_SEED:-false}
|
||||
- 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;
|
||||
@@ -230,6 +230,9 @@ model PropuestaIA {
|
||||
estado String @default("borrador") // borrador | aprobada
|
||||
contenidoIA 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 Json?
|
||||
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -47,5 +47,24 @@ else delete process.env.MINIMAX_API_KEY;
|
||||
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
|
||||
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`);
|
||||
process.exit(fallas === 0 ? 0 : 1);
|
||||
|
||||
@@ -55,6 +55,7 @@ const econ: DatosEconomicos = {
|
||||
],
|
||||
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
|
||||
moneda: "MXN",
|
||||
planBucefalo: null,
|
||||
};
|
||||
|
||||
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"] },
|
||||
],
|
||||
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.",
|
||||
},
|
||||
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 total unico con IVA", txt.includes("13,920"));
|
||||
check("imprime la mensualidad con IVA", txt.includes("9,280"));
|
||||
check("declara que los importes llevan IVA", txt.includes("IVA incluido"));
|
||||
// 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 la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
|
||||
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
|
||||
|
||||
// Sin IVA la nota cambia.
|
||||
// Sin IVA no debe aparecer el renglon de IVA.
|
||||
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).
|
||||
const conFantasma = await generarPropuestaPDF({
|
||||
@@ -144,6 +159,17 @@ async function main() {
|
||||
});
|
||||
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA"));
|
||||
|
||||
// Completitud: la cotizacion manda, no lo que la IA alcanzo a describir.
|
||||
// Verificado con UJ2606UR001 en produccion: 58 partidas, la IA describio 40.
|
||||
const econGrande = { ...econ, partidas: [...econ.partidas,
|
||||
{ refPartida: "P03", servicioCotizadoId: "c3", nombre: "MARCADORNODESCRITA servicio extra", fase: 3, tipoPago: "unico" as const, precio: 5000, tiempoEntrega: "1 semana", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: ["Entregable A", "Entregable B"] },
|
||||
] };
|
||||
const conNoDescrita = await generarPropuestaPDF({ ...datos, economia: econGrande });
|
||||
const txtND = textoDePdf(conNoDescrita);
|
||||
check("una partida SIN descripcion de la IA igual aparece", txtND.includes("MARCADORNODESCRITA"));
|
||||
check("y aparece con su precio", txtND.includes("5,000"));
|
||||
check("y con su detalle del catalogo como respaldo", txtND.includes("Entregable A"));
|
||||
|
||||
// ── Anexo interno ──
|
||||
const anexo = await generarAnexoInternoPDF(datos);
|
||||
const txtA = textoDePdf(anexo);
|
||||
@@ -156,6 +182,17 @@ async function main() {
|
||||
check("el anexo calcula el ratio", txtA.includes("75.3"), "125280/166400");
|
||||
check("el anexo interpreta el ratio", txtA.includes("objecion probable"));
|
||||
check("el anexo sugiere responder con alcance", txtA.includes("no con descuento"));
|
||||
check("el anexo desglosa de donde sale el valor anual", txtA.includes("De donde sale el valor anual"));
|
||||
check("el anexo muestra los factores con su confianza", txtA.includes("confirmado") && txtA.includes("estimado"));
|
||||
|
||||
// R5b: una cifra sostenida solo por estimaciones debe llevar alerta visible.
|
||||
const soloEstimado = await generarAnexoInternoPDF({
|
||||
...datos,
|
||||
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
|
||||
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } },
|
||||
});
|
||||
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
|
||||
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
|
||||
|
||||
// Robustez del branding: la API de configuracion no valida los valores.
|
||||
for (const malo of ["</style><script>", "rojo", "#GGG", ""]) {
|
||||
|
||||
@@ -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: [] }],
|
||||
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
|
||||
moneda: "MXN",
|
||||
planBucefalo: null,
|
||||
};
|
||||
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
|
||||
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
|
||||
|
||||
@@ -46,7 +46,7 @@ const diagBase = {
|
||||
valorProblema: {
|
||||
dimensiones: [{
|
||||
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: [],
|
||||
}],
|
||||
notaMetodologia: "n",
|
||||
@@ -55,10 +55,23 @@ const diagBase = {
|
||||
};
|
||||
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
|
||||
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
|
||||
check("el calculo 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);
|
||||
check("montoAnualMXN acepta null (sin cifras)",
|
||||
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: null } }] } }).success);
|
||||
check("con monto, exige al menos 2 factores",
|
||||
!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",
|
||||
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",
|
||||
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
|
||||
check("urgencia fuera del semaforo se rechaza",
|
||||
|
||||
@@ -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: [] }],
|
||||
totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
|
||||
moneda: "MXN",
|
||||
planBucefalo: null,
|
||||
};
|
||||
|
||||
const base = (): PropuestaConsultiva => ({
|
||||
@@ -29,7 +30,7 @@ const base = (): PropuestaConsultiva => ({
|
||||
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"] }],
|
||||
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.",
|
||||
},
|
||||
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 = [];
|
||||
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"));
|
||||
|
||||
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"));
|
||||
|
||||
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";
|
||||
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 { cargarEconomia } from "@/lib/propuesta/economia";
|
||||
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
|
||||
import { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
|
||||
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
|
||||
|
||||
/** 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 (!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 economia = await cargarEconomia(id);
|
||||
|
||||
|
||||
@@ -32,6 +32,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
||||
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);
|
||||
if (!economia.partidas.length) {
|
||||
return NextResponse.json(
|
||||
@@ -64,6 +78,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
traza: traza as unknown as object,
|
||||
avisos: avisos as unknown as object,
|
||||
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
|
||||
// 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 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 || "",
|
||||
});
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
|
||||
|
||||
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
|
||||
const advertencias = avisos.filter((a) => a.severidad === "advertencia");
|
||||
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
@@ -371,6 +372,167 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
|
||||
))}
|
||||
</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 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"}
|
||||
</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
|
||||
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"
|
||||
@@ -390,6 +564,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
|
||||
<Download className="w-4 h-4" />
|
||||
Propuesta consultiva (PDF)
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
|
||||
|
||||
@@ -20,6 +20,30 @@ export interface UsoTokens {
|
||||
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 {
|
||||
const apiKey = process.env.MINIMAX_API_KEY;
|
||||
if (!apiKey) {
|
||||
@@ -45,6 +69,51 @@ export interface BloqueSystem {
|
||||
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 {
|
||||
system: BloqueSystem[];
|
||||
mensajeUsuario: string;
|
||||
@@ -77,7 +146,7 @@ export async function llamarConHerramienta<T>(
|
||||
let ultimoError = "";
|
||||
|
||||
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
|
||||
const res = await cliente.messages.create({
|
||||
const res = await crearMensajeConReintentos(cliente, {
|
||||
model: modelo(),
|
||||
max_tokens: opts.maxTokens,
|
||||
system,
|
||||
@@ -99,15 +168,25 @@ export async function llamarConHerramienta<T>(
|
||||
const bloquesTool = res.content.filter(
|
||||
(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) {
|
||||
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.
|
||||
// Un turno de usuario plano despues de un tool_use devuelve 400.
|
||||
mensajes.push(
|
||||
@@ -126,7 +205,11 @@ export async function llamarConHerramienta<T>(
|
||||
})),
|
||||
{
|
||||
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.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export interface DatosEconomicos {
|
||||
partidas: PartidaCanonica[];
|
||||
totales: TotalesCotizacion;
|
||||
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> {
|
||||
@@ -43,6 +47,7 @@ export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomi
|
||||
// correo y deben listar las partidas igual.
|
||||
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
planBucefalo: true,
|
||||
},
|
||||
});
|
||||
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[]) : [],
|
||||
}));
|
||||
|
||||
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 {
|
||||
partidas,
|
||||
totales: calcularTotalesCotizacion(activos, { incluirIva: cot.incluirIva }),
|
||||
totales: calcularTotalesCotizacion(paraTotales, { incluirIva: cot.incluirIva }),
|
||||
moneda: cot.moneda,
|
||||
planBucefalo: plan,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+127
-20
@@ -59,6 +59,16 @@ function hexSeguro(v: string | undefined, porDefecto: string): string {
|
||||
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 {
|
||||
doc: PDFKit.PDFDocument;
|
||||
W: number;
|
||||
@@ -177,7 +187,7 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
|
||||
if (dims.length) {
|
||||
titulo("Lo que cuesta no resolverlo");
|
||||
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 h = txtH(linea, W - 14, 9);
|
||||
y = need(h + 14, y);
|
||||
@@ -206,14 +216,18 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
|
||||
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");
|
||||
const porRef = new Map(d.economia.partidas.map((p) => [p.refPartida, p]));
|
||||
for (const a of d.propuesta.redaccion.alcance) {
|
||||
const p = porRef.get(a.refPartida);
|
||||
if (!p) continue; // R2 ya lo marco como bloqueante; aqui simplemente no se dibuja
|
||||
const descPorRef = new Map(d.propuesta.redaccion.alcance.map((a) => [a.refPartida, a.descripcionResultado]));
|
||||
for (const p of d.economia.partidas) {
|
||||
const descripcion = descPorRef.get(p.refPartida);
|
||||
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);
|
||||
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
|
||||
doc
|
||||
@@ -222,36 +236,94 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
|
||||
.fillColor(PRIMARY)
|
||||
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
|
||||
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;
|
||||
} 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);
|
||||
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||
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 filaTotal = (etiqueta: string, valor: string) => {
|
||||
y = need(16, y);
|
||||
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(etiqueta, L, y);
|
||||
doc.font("Helvetica-Bold").fontSize(10).fillColor(PRIMARY).text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
|
||||
y += 16;
|
||||
const fila = (etiqueta: string, valor: string, fuerte = false) => {
|
||||
y = need(fuerte ? 18 : 15, y);
|
||||
doc
|
||||
.font(fuerte ? "Helvetica-Bold" : "Helvetica")
|
||||
.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));
|
||||
y = need(14, y);
|
||||
|
||||
if (t.subtotalUnico > 0) {
|
||||
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
|
||||
.font("Helvetica")
|
||||
.fontSize(7.5)
|
||||
.fillColor(MUTED)
|
||||
.text(
|
||||
t.incluyeIva
|
||||
? "Importes con IVA incluido. Moneda nacional (MXN). Facturacion CFDI."
|
||||
: "Importes sin IVA. 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."
|
||||
: "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.",
|
||||
L,
|
||||
y,
|
||||
{ width: W }
|
||||
);
|
||||
y += 22;
|
||||
y += 24;
|
||||
|
||||
// ── BENEFICIOS ──
|
||||
if (d.propuesta.redaccion.beneficios.length) {
|
||||
@@ -362,7 +434,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
|
||||
y += 24;
|
||||
|
||||
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 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));
|
||||
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) {
|
||||
const nota =
|
||||
r.lectura === "subcotizado"
|
||||
|
||||
@@ -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.",
|
||||
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,
|
||||
});
|
||||
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.",
|
||||
schema: diagnosticoSchema,
|
||||
},
|
||||
maxIntentos: 3,
|
||||
maxIntentos: 4,
|
||||
maxTokens: 8000,
|
||||
});
|
||||
pasos.push({ paso: "diagnostico", intentos: r2.intentos, uso: r2.uso });
|
||||
|
||||
@@ -158,6 +158,8 @@ Reglas de este paso:
|
||||
ser "por_validar".
|
||||
- En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre
|
||||
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
|
||||
entender el alcance, que no este presente quien decide, que no haya ninguna cifra del
|
||||
problema, o un "hagan todo y luego vemos".`;
|
||||
@@ -187,9 +189,15 @@ Reglas de este paso:
|
||||
desde los anuncios").
|
||||
- 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.
|
||||
- En valorProblema, cada dimension lleva su calculo desglosado en factores, y la
|
||||
aritmetica tiene que cuadrar: montoAnualMXN debe ser el producto o la suma de sus
|
||||
factores. Si no tienes cifras, deja montoAnualMXN en null y marca por_validar.
|
||||
- En valorProblema, si declaras un montoAnualMXN tienes que mostrar al menos dos
|
||||
factores que lo expliquen, y la aritmetica debe cuadrar: el monto es el producto o la
|
||||
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
|
||||
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
|
||||
|
||||
@@ -47,14 +47,14 @@ export const hechosSchema = z
|
||||
)
|
||||
.max(40),
|
||||
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),
|
||||
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),
|
||||
mencionesFueraDeAlcance: z.array(z.string().min(5)).max(15),
|
||||
mencionesFueraDeAlcance: z.array(z.string().min(3)).max(15),
|
||||
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),
|
||||
})
|
||||
.strict();
|
||||
@@ -69,7 +69,9 @@ export const diagnosticoSchema = z
|
||||
urgencia: z
|
||||
.enum(URGENCIA)
|
||||
.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),
|
||||
confianza: z.enum(CONFIANZA),
|
||||
citas: z.array(z.string().regex(ID_CITA)),
|
||||
@@ -81,19 +83,48 @@ export const diagnosticoSchema = z
|
||||
valorProblema: z.object({
|
||||
dimensiones: z
|
||||
.array(
|
||||
z.object({
|
||||
z
|
||||
.object({
|
||||
tipo: z.enum(DIMENSION_COSTO),
|
||||
descripcion: z.string().min(10),
|
||||
calculo: z.object({
|
||||
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
|
||||
.number()
|
||||
.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),
|
||||
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),
|
||||
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
|
||||
@@ -116,7 +147,7 @@ export const redaccionSchema = z
|
||||
.object({
|
||||
hero: z.object({
|
||||
titulo: z.string().min(5).max(90),
|
||||
subtitulo: z.string().min(20).max(300),
|
||||
subtitulo: z.string().min(20).max(400),
|
||||
}),
|
||||
citaDestacadaId: z
|
||||
.string()
|
||||
@@ -132,11 +163,17 @@ export const redaccionSchema = z
|
||||
.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
|
||||
.array(
|
||||
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),
|
||||
hallazgoId: z
|
||||
.string()
|
||||
|
||||
@@ -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 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 }[] = [
|
||||
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
|
||||
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
|
||||
{ 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) => {
|
||||
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
|
||||
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) =>
|
||||
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.beneficios.forEach((b, i) =>
|
||||
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.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.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;
|
||||
}
|
||||
|
||||
@@ -155,10 +181,10 @@ export function validarPropuesta(
|
||||
|
||||
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
|
||||
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
||||
const m = d.calculo.montoAnualMXN;
|
||||
const m = d.montoAnualMXN;
|
||||
if (m === null) return;
|
||||
const producto = d.calculo.factores.reduce((a, f) => a * f.valor, 1);
|
||||
const suma = d.calculo.factores.reduce((a, f) => a + f.valor, 0);
|
||||
const producto = d.factores.reduce((a, f) => a * f.valor, 1);
|
||||
const suma = d.factores.reduce((a, f) => a + f.valor, 0);
|
||||
const base = Math.max(Math.abs(m), 1);
|
||||
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
|
||||
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.
|
||||
const idsHallazgo = new Set(p.diagnostico.hallazgos.map((h) => h.id));
|
||||
p.redaccion.beneficios.forEach((b, i) => {
|
||||
|
||||
Reference in New Issue
Block a user