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.
|
// 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;
|
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
|
||||||
else delete process.env.MINIMAX_BASE_URL;
|
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`);
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
process.exit(fallas === 0 ? 0 : 1);
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
|
|||||||
@@ -20,6 +20,30 @@ export interface UsoTokens {
|
|||||||
cacheEscritura: number;
|
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 {
|
export function crearCliente(): Anthropic {
|
||||||
const apiKey = process.env.MINIMAX_API_KEY;
|
const apiKey = process.env.MINIMAX_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -45,6 +69,51 @@ export interface BloqueSystem {
|
|||||||
cachear: boolean;
|
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 {
|
interface OpcionesLlamada {
|
||||||
system: BloqueSystem[];
|
system: BloqueSystem[];
|
||||||
mensajeUsuario: string;
|
mensajeUsuario: string;
|
||||||
@@ -77,7 +146,7 @@ export async function llamarConHerramienta<T>(
|
|||||||
let ultimoError = "";
|
let ultimoError = "";
|
||||||
|
|
||||||
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
|
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
|
||||||
const res = await cliente.messages.create({
|
const res = await crearMensajeConReintentos(cliente, {
|
||||||
model: modelo(),
|
model: modelo(),
|
||||||
max_tokens: opts.maxTokens,
|
max_tokens: opts.maxTokens,
|
||||||
system,
|
system,
|
||||||
@@ -99,15 +168,25 @@ export async function llamarConHerramienta<T>(
|
|||||||
const bloquesTool = res.content.filter(
|
const bloquesTool = res.content.filter(
|
||||||
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
|
(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) {
|
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.
|
// 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.
|
// Un turno de usuario plano despues de un tool_use devuelve 400.
|
||||||
mensajes.push(
|
mensajes.push(
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export const diagnosticoSchema = z
|
|||||||
urgencia: z
|
urgencia: z
|
||||||
.enum(URGENCIA)
|
.enum(URGENCIA)
|
||||||
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
|
.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),
|
descripcion: z.string().min(20),
|
||||||
confianza: z.enum(CONFIANZA),
|
confianza: z.enum(CONFIANZA),
|
||||||
citas: z.array(z.string().regex(ID_CITA)),
|
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."),
|
.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
|
beneficios: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user