From 434a427f705a08cbbb1b5d968147e0ccb1216695 Mon Sep 17 00:00:00 2001 From: urieljareth Date: Tue, 28 Jul 2026 21:07:39 -0600 Subject: [PATCH] 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) --- cazar-tmp.ts | 113 ++++++++++++++++++++++ scripts/verificar-propuesta-pdf.ts | 4 +- scripts/verificar-propuesta-schemas.ts | 20 ++-- scripts/verificar-propuesta-validacion.ts | 8 +- src/lib/propuesta/pdf.ts | 12 +-- src/lib/propuesta/schemas.ts | 86 ++++++++-------- src/lib/propuesta/validacion.ts | 12 +-- 7 files changed, 187 insertions(+), 68 deletions(-) create mode 100644 cazar-tmp.ts diff --git a/cazar-tmp.ts b/cazar-tmp.ts new file mode 100644 index 0000000..21a6d6d --- /dev/null +++ b/cazar-tmp.ts @@ -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>, 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); }); diff --git a/scripts/verificar-propuesta-pdf.ts b/scripts/verificar-propuesta-pdf.ts index dcc48f4..c4ce942 100644 --- a/scripts/verificar-propuesta-pdf.ts +++ b/scripts/verificar-propuesta-pdf.ts @@ -73,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, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }], + dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }], notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.", }, resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }], @@ -189,7 +189,7 @@ async function main() { const soloEstimado = await generarAnexoInternoPDF({ ...datos, propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema, - dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 } }] } } }, + dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } }, }); check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada")); check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada")); diff --git a/scripts/verificar-propuesta-schemas.ts b/scripts/verificar-propuesta-schemas.ts index 4822940..5322c83 100644 --- a/scripts/verificar-propuesta-schemas.ts +++ b/scripts/verificar-propuesta-schemas.ts @@ -46,7 +46,7 @@ const diagBase = { valorProblema: { dimensiones: [{ tipo: "tiempo", descripcion: "x".repeat(12), - calculo: { factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416 }, + factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416, confianza: "estimado", hechos: [], }], notaMetodologia: "n", @@ -56,15 +56,21 @@ const diagBase = { check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success, JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? "")); check("con monto, exige al menos 2 factores", - !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 } }] } }).success); + !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 }] } }).success); check("sin monto, factores vacio es lo correcto", - diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [], montoAnualMXN: null } }] } }).success); -check("omitir montoAnualMXN se tolera y queda en null", - diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).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({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).valorProblema.dimensiones[0].calculo.montoAnualMXN === null); + diagnosticoSchema.parse(sinMonto).valorProblema.dimensiones[0].montoAnualMXN === null); +check("la dimension quedo plana: sin nivel `calculo`", + !JSON.stringify(aJsonSchema(diagnosticoSchema)).includes('"calculo"')); check("un factor sin confianza se rechaza", - !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 } }] } }).success); + !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 }] } }).success); check("exige al menos un hallazgo", !diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success); diff --git a/scripts/verificar-propuesta-validacion.ts b/scripts/verificar-propuesta-validacion.ts index 19d42af..a8a7208 100644 --- a/scripts/verificar-propuesta-validacion.ts +++ b/scripts/verificar-propuesta-validacion.ts @@ -30,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, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }], + dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }], notaMetodologia: "Ocho horas por semana a costo cargado.", }, resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }], @@ -72,14 +72,14 @@ check("R3 tolera acentos, mayusculas y puntuacion", !tiene(validarPropuesta(p, e p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = []; 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.diagnostico.valorProblema.dimensiones[0].calculo.factores = []; +p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].factores = []; check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5")); p = base(); -p.diagnostico.valorProblema.dimensiones[0].calculo.factores = p.diagnostico.valorProblema.dimensiones[0].calculo.factores.map((f) => ({ ...f, confianza: "estimado" as const })); +p.diagnostico.valorProblema.dimensiones[0].factores = p.diagnostico.valorProblema.dimensiones[0].factores.map((f) => ({ ...f, confianza: "estimado" as const })); check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b")); check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b")); diff --git a/src/lib/propuesta/pdf.ts b/src/lib/propuesta/pdf.ts index d56277a..d015cf5 100644 --- a/src/lib/propuesta/pdf.ts +++ b/src/lib/propuesta/pdf.ts @@ -187,7 +187,7 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise 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); @@ -434,7 +434,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise 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) => { @@ -468,13 +468,13 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x "); + 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 }); @@ -482,7 +482,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise dim.calculo.montoAnualMXN !== null && !dim.calculo.factores.some((f) => f.confianza === "confirmado") + (dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado") ); if (sinConfirmar) { const alerta = diff --git a/src/lib/propuesta/schemas.ts b/src/lib/propuesta/schemas.ts index c835f1d..bbe9321 100644 --- a/src/lib/propuesta/schemas.ts +++ b/src/lib/propuesta/schemas.ts @@ -83,48 +83,48 @@ export const diagnosticoSchema = z valorProblema: z.object({ dimensiones: z .array( - z.object({ - tipo: z.enum(DIMENSION_COSTO), - descripcion: z.string().min(10), - calculo: z - .object({ - // Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al - // modelo a inventar relleno. Verificado contra la API real: produjo - // "herramienta_de_seguimiento_actual=0 x canal_principal=1", que no es - // un calculo. La exigencia se aplica solo si hay monto (superRefine). - factores: z - .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 que sea - // un dato y no una convencion de nomenclatura. - 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 explicito, que es - // lo natural para un LLM. Exigirlo presente quemaba los tres intentos. - montoAnualMXN: z - .number() - .nullable() - .default(null) - .describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."), - }) - .superRefine((c, ctx) => { - if (c.montoAnualMXN !== null && c.factores.length < 2) { - ctx.addIssue({ - code: "custom", - path: ["factores"], - message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.", - }); - } - }), - confianza: z.enum(CONFIANZA), - hechos: z.array(z.string().regex(ID_HECHO)), - }) + z + .object({ + tipo: z.enum(DIMENSION_COSTO), + descripcion: z.string().min(10), + // 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() + .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."), @@ -147,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() diff --git a/src/lib/propuesta/validacion.ts b/src/lib/propuesta/validacion.ts index c02767d..0904a58 100644 --- a/src/lib/propuesta/validacion.ts +++ b/src/lib/propuesta/validacion.ts @@ -181,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) { @@ -205,15 +205,15 @@ export function validarPropuesta( // 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.calculo.montoAnualMXN === null) return; - const confirmados = d.calculo.factores.filter((f) => f.confianza === "confirmado").length; + 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.calculo.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` + + `La cifra de ${d.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` + `Revisa el desglose antes de fiarte del ratio precio/valor.`, }); }