Propuesta IA: correcciones encontradas probando contra la API real de MiniMax
Tres defectos que solo se veian llamando al modelo de verdad.
1. min(2) en los factores obligaba a inventar relleno. Con una dimension sin
cifras, MiniMax produjo "herramienta_de_seguimiento_actual=0 x canal=1" solo
para satisfacer la restriccion. Ahora los dos factores se exigen unicamente
cuando hay montoAnualMXN.
2. El modelo OMITE montoAnualMXN en vez de mandar null explicito, que es lo
natural para un LLM. Exigirlo presente quemaba los tres intentos del paso.
Ahora es .default(null) y la omision se tolera.
3. Sin confianza por factor, el modelo la metia dentro del nombre
("tasa_conversion (por_validar)=0.1"). Ahora es un campo.
Y el hallazgo que mas importa, porque es comercial y no de formato: el modelo
puede OMITIR un factor y aun asi cuadrar la aritmetica. En una corrida calculo
40 mensajes x 0.5 sin contestar x 52 semanas x 3,000 de utilidad POR CLIENTE =
3,120,000, asumiendo que cada mensaje sin responder es un cliente perdido. R5 lo
acepto porque los factores si multiplican al monto: R5 no puede ver lo que falta.
El ratio habria dicho "subcotizado" con el denominador inflado diez veces. Como
no se puede impedir que el modelo produzca estimaciones plausibles y erroneas, lo
que se hace es que el asesor las cache de un vistazo:
- R5b avisa cuando una cifra no tiene ni un factor confirmado.
- El anexo interno desglosa cada factor con su confianza, y alerta en rojo si el
valor descansa entero en estimaciones.
Ademas, el presupuesto de reintentos sube (5 en extraccion, 4 en los otros dos):
MiniMax se equivoca de array de vez en cuando con schemas anidados, y la
extraccion es el paso fundacional. Una corrida real necesito los 5.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ce3a38f89d
commit
74c0374a2a
@@ -72,7 +72,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", 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"] }],
|
||||
notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
|
||||
},
|
||||
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
|
||||
@@ -156,6 +156,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], calculo: { 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", ""]) {
|
||||
|
||||
@@ -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 },
|
||||
calculo: { factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416 },
|
||||
confianza: "estimado", hechos: [],
|
||||
}],
|
||||
notaMetodologia: "n",
|
||||
@@ -55,10 +55,17 @@ 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], calculo: { 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);
|
||||
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);
|
||||
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);
|
||||
|
||||
check("exige al menos un hallazgo",
|
||||
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
|
||||
check("urgencia fuera del semaforo se rechaza",
|
||||
|
||||
@@ -29,7 +29,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", 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"] }],
|
||||
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" }],
|
||||
@@ -74,9 +74,14 @@ check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ,
|
||||
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.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].calculo.montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].calculo.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 }));
|
||||
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"));
|
||||
|
||||
|
||||
@@ -126,7 +126,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.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -389,6 +389,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.calculo.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.calculo.factores.length) {
|
||||
const desglose = dim.calculo.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.calculo.montoAnualMXN !== null && !dim.calculo.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 });
|
||||
|
||||
@@ -187,9 +187,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
|
||||
|
||||
@@ -84,12 +84,41 @@ export const diagnosticoSchema = z
|
||||
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),
|
||||
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()
|
||||
.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."),
|
||||
})
|
||||
.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)),
|
||||
|
||||
@@ -171,6 +171,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.calculo.montoAnualMXN === null) return;
|
||||
const confirmados = d.calculo.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. ` +
|
||||
`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