Propuesta IA: quitar los rechazos autoinfligidos y tolerar rarezas de MiniMax

Seis vueltas completas del pipeline contra los datos reales de UJ2606UR001
mostraron que la mayoria de los reintentos los provocaba el propio schema, no
el modelo:

- alcance.max(40) contra una cotizacion de 58 partidas garantizaba un rechazo de
  Zod en CADA corrida. Sube a 120. El tope solo acota una respuesta desbocada;
  la completitud del documento ya no depende de este array desde d20007d2.
- titulo.max(80) se quedaba corto y costo un reintento en 2 de 6 corridas.
  Sube a 140.

Y dos rarezas del proveedor, ambas observadas contra la API real:

- MiniMax a veces envuelve los elementos de un array en {item: {...}}. Se
  normaliza antes de validar, solo cuando "item" es la unica clave, para no
  tocar un campo legitimo con ese nombre.
- A veces emite DOS bloques tool_use en una respuesta. Antes se tomaba el
  primero a secas; ahora se prueban todos y gana el que valide.

Sobre el error de tipo de documento que se vio en produccion: NO se reprodujo en
seis corridas completas contra los mismos datos, y la generacion que lo siguio
completo sin problema (la fila quedo en la tabla). La evidencia apunta a un
fallo transitorio del proveedor, no a un defecto determinista nuestro. En vez de
inventar un arreglo para algo que no se puede reproducir, se reintentan los
fallos transitorios (5xx, 429, timeouts, red y los 400 con mensaje de parseo
interno) con espera creciente.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 20:54:48 -06:00
co-authored by Claude Opus 5
parent d20007d252
commit 3f46d8a734
4 changed files with 229 additions and 11 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); });
+20 -1
View File
@@ -1,4 +1,4 @@
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia";
import { crearCliente, modelo, normalizarRespuesta } from "@/lib/propuesta/cliente-ia";
// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro.
@@ -47,5 +47,24 @@ else delete process.env.MINIMAX_API_KEY;
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
else delete process.env.MINIMAX_BASE_URL;
// ── Normalizacion de rarezas de MiniMax, observadas contra la API real ──
const igual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
check("desenvuelve {item: X} cuando item es la unica clave",
igual(normalizarRespuesta({ item: { a: 1 } }), { a: 1 }));
check("desenvuelve dentro de arrays",
igual(normalizarRespuesta([{ item: { a: 1 } }, { item: { a: 2 } }]), [{ a: 1 }, { a: 2 }]));
check("desenvuelve en profundidad",
igual(normalizarRespuesta({ lista: [{ item: { x: [{ item: 5 }] } }] }), { lista: [{ x: [5] }] }));
check("NO toca un objeto con item junto a otras claves",
igual(normalizarRespuesta({ item: 1, otro: 2 }), { item: 1, otro: 2 }));
check("deja intactos los valores simples",
normalizarRespuesta("texto") === "texto" && normalizarRespuesta(5) === 5 && normalizarRespuesta(null) === null);
check("no altera un objeto normal",
igual(normalizarRespuesta({ a: 1, b: [1, 2] }), { a: 1, b: [1, 2] }));
check("el caso real observado: alcance envuelto",
igual(normalizarRespuesta({ alcance: [{ item: { refPartida: "P01", descripcionResultado: "x" } }] }),
{ alcance: [{ refPartida: "P01", descripcionResultado: "x" }] }));
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+87 -8
View File
@@ -20,6 +20,30 @@ export interface UsoTokens {
cacheEscritura: number;
}
/**
* Normaliza rarezas observadas en las respuestas de MiniMax antes de validar.
*
* Observado contra la API real, sobre datos de produccion: a veces envuelve los
* elementos de un array en un objeto `{item: {...}}` en vez de emitir el objeto
* directamente. Zod lo rechaza con `Unrecognized keys: "item"` y se gasta un
* reintento en algo que se puede corregir aqui sin ambiguedad.
*
* Solo desenvuelve cuando `item` es la UNICA clave: si el objeto trae mas cosas,
* podria ser un campo legitimo y no se toca.
*/
export function normalizarRespuesta(v: unknown): unknown {
if (Array.isArray(v)) return v.map(normalizarRespuesta);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const claves = Object.keys(o);
if (claves.length === 1 && claves[0] === "item") return normalizarRespuesta(o.item);
const salida: Record<string, unknown> = {};
for (const k of claves) salida[k] = normalizarRespuesta(o[k]);
return salida;
}
return v;
}
export function crearCliente(): Anthropic {
const apiKey = process.env.MINIMAX_API_KEY;
if (!apiKey) {
@@ -45,6 +69,51 @@ export interface BloqueSystem {
cachear: boolean;
}
/**
* ¿El fallo es del proveedor y vale la pena reintentar?
*
* Motivacion: en produccion aparecio un "unexpected doc type" que NO se pudo reproducir
* en seis corridas completas del pipeline contra los mismos datos, y la generacion que
* lo siguio completo sin problema. Todo apunta a un fallo transitorio del proveedor.
* En vez de adivinar un arreglo para un error que no se puede reproducir, se reintenta
* esa clase de fallo con espera creciente.
*/
function esTransitorio(e: unknown): boolean {
const err = e as { status?: number; message?: string };
if (typeof err?.status === "number") {
// 408 timeout, 409 conflicto, 429 rate limit, 5xx y el 529 de sobrecarga.
if ([408, 409, 429].includes(err.status) || err.status >= 500) return true;
// 400 con mensaje que no describe un problema de nuestro payload: el proveedor
// devuelve errores de parseo internos con 400. Se reintenta una vez por si acaso.
if (err.status === 400 && /unexpected|internal|parse|unknown/i.test(err.message ?? "")) return true;
}
// Fallos de red sin status.
if (!err?.status && /ECONN|ETIMEDOUT|socket|network|fetch failed/i.test(err?.message ?? "")) return true;
return false;
}
const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Llama al proveedor reintentando SOLO los fallos transitorios. Los errores de
* nuestro payload no se reintentan aqui: los corrige el bucle de schema. */
async function crearMensajeConReintentos(
cliente: Anthropic,
cuerpo: Anthropic.MessageCreateParamsNonStreaming,
maxTransitorios = 3
): Promise<Anthropic.Message> {
let ultimo: unknown;
for (let i = 1; i <= maxTransitorios; i++) {
try {
return await cliente.messages.create(cuerpo);
} catch (e) {
ultimo = e;
if (!esTransitorio(e) || i === maxTransitorios) throw e;
await esperar(1000 * 2 ** (i - 1)); // 1s, 2s
}
}
throw ultimo;
}
interface OpcionesLlamada {
system: BloqueSystem[];
mensajeUsuario: string;
@@ -77,7 +146,7 @@ export async function llamarConHerramienta<T>(
let ultimoError = "";
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
const res = await cliente.messages.create({
const res = await crearMensajeConReintentos(cliente, {
model: modelo(),
max_tokens: opts.maxTokens,
system,
@@ -99,15 +168,25 @@ export async function llamarConHerramienta<T>(
const bloquesTool = res.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
const correcto = bloquesTool.find((b) => b.name === opts.herramienta.nombre);
// El modelo a veces emite DOS tool_use en una misma respuesta (observado contra la
// API real). Se prueban todos los candidatos y gana el primero que valide, en vez
// de quedarse con el primero a secas y desperdiciar un reintento.
const candidatos = bloquesTool.filter((b) => b.name === opts.herramienta.nombre);
let correcto: Anthropic.ToolUseBlock | undefined;
for (const c of candidatos) {
const intentoParse = opts.herramienta.schema.safeParse(normalizarRespuesta(c.input));
if (intentoParse.success) {
return { datos: intentoParse.data as T, uso, intentos: intento };
}
// Se guarda el primero para reportar su error si ninguno valida.
if (!correcto) {
correcto = c;
ultimoError = z.prettifyError(intentoParse.error);
}
}
if (correcto) {
const parsed = opts.herramienta.schema.safeParse(correcto.input);
if (parsed.success) {
return { datos: parsed.data as T, uso, intentos: intento };
}
ultimoError = z.prettifyError(parsed.error);
// Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar.
// Un turno de usuario plano despues de un tool_use devuelve 400.
mensajes.push(
+9 -2
View File
@@ -69,7 +69,9 @@ export const diagnosticoSchema = z
urgencia: z
.enum(URGENCIA)
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
titulo: z.string().min(3).max(80),
// 140 y no 80: con 80 el modelo se pasaba y gastaba un reintento. Observado
// en 2 de 6 corridas contra datos reales.
titulo: z.string().min(3).max(140),
descripcion: z.string().min(20),
confianza: z.enum(CONFIANZA),
citas: z.array(z.string().regex(ID_CITA)),
@@ -161,7 +163,12 @@ export const redaccionSchema = z
.describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."),
})
)
.max(40),
// 120 y no 40: una cotizacion real (UJ2606UR001) tiene 58 partidas, asi que el
// tope de 40 garantizaba un rechazo de Zod y un reintento desperdiciado en cada
// corrida. Observado en 6 de 6. El tope solo esta para acotar una respuesta
// desbocada; la completitud del documento ya no depende de este array, porque el
// generador recorre las partidas de la cotizacion.
.max(120),
beneficios: z
.array(
z.object({