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:
co-authored by
Claude Opus 5
parent
d20007d252
commit
3f46d8a734
+113
@@ -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); });
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user