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 =
+43 -43
View File
@@ -83,48 +83,48 @@ export const diagnosticoSchema = z
valorProblema: z.object({ valorProblema: z.object({
dimensiones: z dimensiones: z
.array( .array(
z.object({ z
tipo: z.enum(DIMENSION_COSTO), .object({
descripcion: z.string().min(10), tipo: z.enum(DIMENSION_COSTO),
calculo: z descripcion: z.string().min(10),
.object({ // Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al // nivel extra no aportaba nada semantico y era donde el modelo se perdia:
// modelo a inventar relleno. Verificado contra la API real: produjo // devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Observado
// "herramienta_de_seguimiento_actual=0 x canal_principal=1", que no es // en 3 de 6 corridas contra datos reales.
// un calculo. La exigencia se aplica solo si hay monto (superRefine). //
factores: z // Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
.array( // modelo a inventar relleno. La exigencia se aplica solo si hay monto.
z.object({ factores: z
nombre: z.string(), .array(
valor: z.number(), z.object({
// Sin este campo el modelo metia la incertidumbre dentro del nombre: z.string(),
// nombre ("tasa_conversion (por_validar)=0.1"). Mejor que sea valor: z.number(),
// un dato y no una convencion de nomenclatura. // Sin este campo el modelo metia la incertidumbre dentro del nombre
confianza: z.enum(CONFIANZA), // ("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 .max(6),
// que el modelo OMITE el campo en vez de mandar null explicito, que es // .default(null) y no solo .nullable(): verificado contra la API real que
// lo natural para un LLM. Exigirlo presente quemaba los tres intentos. // el modelo OMITE el campo en vez de mandar null, que es lo natural para
montoAnualMXN: z // un LLM. Exigirlo presente quemaba los tres intentos.
.number() montoAnualMXN: z
.nullable() .number()
.default(null) .nullable()
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."), .default(null)
}) .describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
.superRefine((c, ctx) => { confianza: z.enum(CONFIANZA),
if (c.montoAnualMXN !== null && c.factores.length < 2) { hechos: z.array(z.string().regex(ID_HECHO)),
ctx.addIssue({ })
code: "custom", .superRefine((d, ctx) => {
path: ["factores"], if (d.montoAnualMXN !== null && d.factores.length < 2) {
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.", 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)), }
}) })
) )
.max(4), .max(4),
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."), notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
@@ -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.`,
}); });
} }