Propuesta IA: aplanar el nivel calculo y subir el tope del subtitulo

Segunda tanda de rechazos observados contra datos reales (3 vueltas mas):

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 21:07:39 -06:00
co-authored by Claude Opus 5
parent bdd9319fd0
commit 434a427f70
7 changed files with 187 additions and 68 deletions
+113
View File
@@ -0,0 +1,113 @@
/**
* Caza del error intermitente: corre el pipeline real contra UJ2606UR001 hasta que
* falle, e imprime TODO lo que sepamos de la peticion que lo provoco.
*/
import { cargarEconomia } from "@/lib/propuesta/economia";
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia";
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema, type Hechos, type Diagnostico } from "@/lib/propuesta/schemas";
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "@/lib/propuesta/prompts";
import { prisma } from "@/lib/db";
import type Anthropic from "@anthropic-ai/sdk";
import type { z } from "zod";
import fs from "node:fs";
const LOG = process.argv[2];
// Escritura directa: el buffer de stdout se pierde si el proceso muere.
function log(...a: unknown[]) {
const linea = a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" ");
fs.appendFileSync(LOG, linea + "\n");
}
const console = { log } as unknown as Console;
const NUMERO = "UJ2606UR001";
const TRANSCRIPCION = "Una propuesta de zero to hero para un negocio inicial";
/** Igual que llamarConHerramienta pero registra cada peticion y explota con detalle. */
async function correrPaso(nombre: string, msg: string, schema: z.ZodType, maxTokens: number, maxIntentos: number) {
const cliente = crearCliente();
const tools = [{ name: nombre, description: "Registra el resultado.", input_schema: aJsonSchema(schema) as never }];
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: msg }];
for (let intento = 1; intento <= maxIntentos; intento++) {
let res: Anthropic.Message;
try {
res = await cliente.messages.create({
model: modelo(), max_tokens: maxTokens,
system: [{ type: "text", text: SYSTEM_BASE, cache_control: { type: "ephemeral" } }],
tools, messages: mensajes,
});
} catch (e) {
const err = e as { status?: number; message?: string; error?: unknown };
console.log(`\n*** ERROR EN ${nombre} intento ${intento} ***`);
console.log(" status:", err.status);
console.log(" message:", String(err.message).slice(0, 600));
if (err.error) console.log(" error:", JSON.stringify(err.error).slice(0, 800));
console.log("\n --- FORMA DE LOS MENSAJES ENVIADOS ---");
mensajes.forEach((m, i) => {
const c = m.content;
const tipos = typeof c === "string" ? "string" : (c as { type: string }[]).map((b) => b.type).join(",");
console.log(` [${i}] role=${m.role} bloques=${tipos}`);
});
throw new Error("REPRODUCIDO");
}
const bloques = res.content.map((b) => b.type).join(",");
console.log(` ${nombre} intento ${intento}: stop=${res.stop_reason} bloques=[${bloques}] out=${res.usage.output_tokens}`);
const tus = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const ok = tus.find((b) => b.name === nombre);
if (ok) {
const parsed = schema.safeParse(ok.input);
if (parsed.success) return parsed.data;
const errTxt = (await import("zod")).z.prettifyError(parsed.error);
console.log(` zod rechazo: ${errTxt.split("\n")[0]}`);
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: [
...tus.map((b) => ({ type: "tool_result" as const, tool_use_id: b.id, is_error: true, content: errTxt })),
{ type: "text" as const, text: "Corrige y reintenta." },
] },
);
continue;
}
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: `Debes llamar a ${nombre}.` },
);
}
return null;
}
async function unaVuelta(entrada: ContextoEntrada, econ: Awaited<ReturnType<typeof cargarEconomia>>, n: number) {
console.log(`\n===== VUELTA ${n} =====`);
const h = (await correrPaso("registrar_hechos", mensajePaso1(entrada), hechosSchema, 8000, 5)) as Hechos | null;
if (!h) { console.log(" extraccion agoto intentos"); return; }
const d = (await correrPaso("registrar_diagnostico", mensajePaso2(entrada, h, econ), diagnosticoSchema, 8000, 4)) as Diagnostico | null;
if (!d) { console.log(" diagnostico agoto intentos"); return; }
const r = await correrPaso("registrar_propuesta", mensajePaso3(entrada, h, d, econ), redaccionSchema, 12000, 4);
console.log(r ? " vuelta completa OK" : " redaccion agoto intentos");
}
async function main() {
const cot = await prisma.cotizacion.findFirst({ where: { numero: NUMERO }, include: { cliente: true, servicios: true } });
if (!cot) throw new Error("no encontrada");
const econ = await cargarEconomia(cot.id);
const entrada: ContextoEntrada = {
transcripcion: TRANSCRIPCION,
notas: cot.servicios.map((s) => s.notas).filter(Boolean).join("\n"),
observaciones: cot.observaciones || "",
cliente: cot.cliente.nombre, empresa: cot.cliente.empresa || "", proyecto: cot.proyecto,
};
console.log(`${econ.partidas.length} partidas`);
for (let i = 1; i <= 3; i++) {
try { await unaVuelta(entrada, econ, i); }
catch (e) {
if (e instanceof Error && e.message === "REPRODUCIDO") { console.log("\n>>> error reproducido, deteniendo"); break; }
throw e;
}
}
await prisma.$disconnect();
}
main().catch((e) => { console.error("fallo:", e); process.exit(1); });
+2 -2
View File
@@ -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"] }, { id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
], ],
valorProblema: { valorProblema: {
dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", calculo: { factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }], dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.", notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
}, },
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }], resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
@@ -189,7 +189,7 @@ async function main() {
const soloEstimado = await generarAnexoInternoPDF({ const soloEstimado = await generarAnexoInternoPDF({
...datos, ...datos,
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema, propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 } }] } } }, dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } },
}); });
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada")); check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada")); check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
+13 -7
View File
@@ -46,7 +46,7 @@ const diagBase = {
valorProblema: { valorProblema: {
dimensiones: [{ dimensiones: [{
tipo: "tiempo", descripcion: "x".repeat(12), tipo: "tiempo", descripcion: "x".repeat(12),
calculo: { factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416 }, factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416,
confianza: "estimado", hechos: [], confianza: "estimado", hechos: [],
}], }],
notaMetodologia: "n", notaMetodologia: "n",
@@ -56,15 +56,21 @@ const diagBase = {
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success, check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? "")); JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
check("con monto, exige al menos 2 factores", check("con monto, exige al menos 2 factores",
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 } }] } }).success); !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 }] } }).success);
check("sin monto, factores vacio es lo correcto", check("sin monto, factores vacio es lo correcto",
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [], montoAnualMXN: null } }] } }).success); diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [], montoAnualMXN: null }] } }).success);
check("omitir montoAnualMXN se tolera y queda en null", // Una dimension SIN la clave montoAnualMXN: es lo que manda el modelo cuando no tiene
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).success); // cifras, y por eso el campo lleva .default(null) en vez de solo .nullable().
const dimSinMonto = { tipo: "tiempo", descripcion: "x".repeat(12), factores: [], confianza: "por_validar", hechos: [] };
const sinMonto = { ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [dimSinMonto] } };
check("omitir montoAnualMXN se tolera", diagnosticoSchema.safeParse(sinMonto).success,
JSON.stringify(diagnosticoSchema.safeParse(sinMonto).error?.issues?.[0] ?? ""));
check("al omitirlo, Zod lo rellena con null", check("al omitirlo, Zod lo rellena con null",
diagnosticoSchema.parse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).valorProblema.dimensiones[0].calculo.montoAnualMXN === null); diagnosticoSchema.parse(sinMonto).valorProblema.dimensiones[0].montoAnualMXN === null);
check("la dimension quedo plana: sin nivel `calculo`",
!JSON.stringify(aJsonSchema(diagnosticoSchema)).includes('"calculo"'));
check("un factor sin confianza se rechaza", check("un factor sin confianza se rechaza",
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 } }] } }).success); !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 }] } }).success);
check("exige al menos un hallazgo", check("exige al menos un hallazgo",
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success); !diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
+4 -4
View File
@@ -30,7 +30,7 @@ const base = (): PropuestaConsultiva => ({
diagnostico: { diagnostico: {
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes del fin de semana se pierden.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }], hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes del fin de semana se pierden.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }],
valorProblema: { valorProblema: {
dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", calculo: { factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }], dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
notaMetodologia: "Ocho horas por semana a costo cargado.", notaMetodologia: "Ocho horas por semana a costo cargado.",
}, },
resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }], resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }],
@@ -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 = []; p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = [];
check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4")); check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4"));
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = 999999; p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = 999999;
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5")); check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].calculo.factores = []; p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].factores = [];
check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5")); check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5"));
p = base(); p = base();
p.diagnostico.valorProblema.dimensiones[0].calculo.factores = p.diagnostico.valorProblema.dimensiones[0].calculo.factores.map((f) => ({ ...f, confianza: "estimado" as const })); p.diagnostico.valorProblema.dimensiones[0].factores = p.diagnostico.valorProblema.dimensiones[0].factores.map((f) => ({ ...f, confianza: "estimado" as const }));
check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b")); check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b"));
check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b")); check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
+6 -6
View File
@@ -187,7 +187,7 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
if (dims.length) { if (dims.length) {
titulo("Lo que cuesta no resolverlo"); titulo("Lo que cuesta no resolverlo");
for (const dim of dims) { for (const dim of dims) {
const monto = dim.calculo.montoAnualMXN; const monto = dim.montoAnualMXN;
const linea = `${dim.descripcion}${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`; const linea = `${dim.descripcion}${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`;
const h = txtH(linea, W - 14, 9); const h = txtH(linea, W - 14, 9);
y = need(h + 14, y); y = need(h + 14, y);
@@ -434,7 +434,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
y += 24; y += 24;
const valorAnual = const valorAnual =
d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.calculo.montoAnualMXN ?? 0), 0) || null; d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.montoAnualMXN ?? 0), 0) || null;
const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual); const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual);
const seccion = (t: string) => { const seccion = (t: string) => {
@@ -468,13 +468,13 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) { if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
seccion("De donde sale el valor anual"); seccion("De donde sale el valor anual");
for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) { for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) {
const monto = dim.calculo.montoAnualMXN; const monto = dim.montoAnualMXN;
const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`; const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`;
y = need(14, y); y = need(14, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y); doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
y += 13; y += 13;
if (dim.calculo.factores.length) { if (dim.factores.length) {
const desglose = dim.calculo.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x "); const desglose = dim.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
const h = txtH(desglose, W - 10, 8); const h = txtH(desglose, W - 10, 8);
y = need(h + 8, y); y = need(h + 8, y);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 }); doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 });
@@ -482,7 +482,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
} }
} }
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some( const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
(dim) => dim.calculo.montoAnualMXN !== null && !dim.calculo.factores.some((f) => f.confianza === "confirmado") (dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado")
); );
if (sinConfirmar) { if (sinConfirmar) {
const alerta = const alerta =
+18 -18
View File
@@ -83,47 +83,47 @@ export const diagnosticoSchema = z
valorProblema: z.object({ valorProblema: z.object({
dimensiones: z dimensiones: z
.array( .array(
z.object({ z
.object({
tipo: z.enum(DIMENSION_COSTO), tipo: z.enum(DIMENSION_COSTO),
descripcion: z.string().min(10), descripcion: z.string().min(10),
calculo: z // Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
.object({ // nivel extra no aportaba nada semantico y era donde el modelo se perdia:
// devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Observado
// en 3 de 6 corridas contra datos reales.
//
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al // Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
// modelo a inventar relleno. Verificado contra la API real: produjo // modelo a inventar relleno. La exigencia se aplica solo si hay monto.
// "herramienta_de_seguimiento_actual=0 x canal_principal=1", que no es
// un calculo. La exigencia se aplica solo si hay monto (superRefine).
factores: z factores: z
.array( .array(
z.object({ z.object({
nombre: z.string(), nombre: z.string(),
valor: z.number(), valor: z.number(),
// Sin este campo el modelo metia la incertidumbre dentro del // Sin este campo el modelo metia la incertidumbre dentro del nombre
// nombre ("tasa_conversion (por_validar)=0.1"). Mejor que sea // ("tasa_conversion (por_validar)=0.1"). Mejor dato que convencion.
// un dato y no una convencion de nomenclatura.
confianza: z.enum(CONFIANZA), confianza: z.enum(CONFIANZA),
}) })
) )
.max(6), .max(6),
// .default(null) y no solo .nullable(): verificado contra la API real // .default(null) y no solo .nullable(): verificado contra la API real que
// que el modelo OMITE el campo en vez de mandar null explicito, que es // el modelo OMITE el campo en vez de mandar null, que es lo natural para
// lo natural para un LLM. Exigirlo presente quemaba los tres intentos. // un LLM. Exigirlo presente quemaba los tres intentos.
montoAnualMXN: z montoAnualMXN: z
.number() .number()
.nullable() .nullable()
.default(null) .default(null)
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."), .describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
confianza: z.enum(CONFIANZA),
hechos: z.array(z.string().regex(ID_HECHO)),
}) })
.superRefine((c, ctx) => { .superRefine((d, ctx) => {
if (c.montoAnualMXN !== null && c.factores.length < 2) { if (d.montoAnualMXN !== null && d.factores.length < 2) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["factores"], path: ["factores"],
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.", message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
}); });
} }
}),
confianza: z.enum(CONFIANZA),
hechos: z.array(z.string().regex(ID_HECHO)),
}) })
) )
.max(4), .max(4),
@@ -147,7 +147,7 @@ export const redaccionSchema = z
.object({ .object({
hero: z.object({ hero: z.object({
titulo: z.string().min(5).max(90), titulo: z.string().min(5).max(90),
subtitulo: z.string().min(20).max(300), subtitulo: z.string().min(20).max(400),
}), }),
citaDestacadaId: z citaDestacadaId: z
.string() .string()
+6 -6
View File
@@ -181,10 +181,10 @@ export function validarPropuesta(
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar. // ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => { p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
const m = d.calculo.montoAnualMXN; const m = d.montoAnualMXN;
if (m === null) return; if (m === null) return;
const producto = d.calculo.factores.reduce((a, f) => a * f.valor, 1); const producto = d.factores.reduce((a, f) => a * f.valor, 1);
const suma = d.calculo.factores.reduce((a, f) => a + f.valor, 0); const suma = d.factores.reduce((a, f) => a + f.valor, 0);
const base = Math.max(Math.abs(m), 1); const base = Math.max(Math.abs(m), 1);
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02; const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
if (!cuadra) { if (!cuadra) {
@@ -205,15 +205,15 @@ export function validarPropuesta(
// conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la // conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la
// aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10. // aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => { p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
if (d.calculo.montoAnualMXN === null) return; if (d.montoAnualMXN === null) return;
const confirmados = d.calculo.factores.filter((f) => f.confianza === "confirmado").length; const confirmados = d.factores.filter((f) => f.confianza === "confirmado").length;
if (confirmados === 0) { if (confirmados === 0) {
avisos.push({ avisos.push({
regla: "R5b", regla: "R5b",
severidad: "advertencia", severidad: "advertencia",
ruta: `valorProblema.dimensiones[${i}]`, ruta: `valorProblema.dimensiones[${i}]`,
mensaje: mensaje:
`La cifra de ${d.calculo.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` + `La cifra de ${d.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` +
`Revisa el desglose antes de fiarte del ratio precio/valor.`, `Revisa el desglose antes de fiarte del ratio precio/valor.`,
}); });
} }