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]>
156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
import Anthropic from "@anthropic-ai/sdk";
|
|
import { z } from "zod";
|
|
import { aJsonSchema } from "./schemas";
|
|
|
|
/**
|
|
* Cliente del proveedor de IA (MiniMax-M3 via su endpoint compatible con Anthropic)
|
|
* y runner que fuerza el contrato con tool-calling.
|
|
*
|
|
* MiniMax NO soporta structured outputs (`output_config` / `json_schema`), asi que
|
|
* el `input_schema` de la herramienta ES el contrato, y Zod valida siempre del lado
|
|
* del codigo. `tool_choice` no se envia: no esta documentado en MiniMax, y fijarlo
|
|
* solo en el reintento le daria al reintento un prefijo distinto — se pagaria el
|
|
* contexto completo justo cuando es mas grande.
|
|
*/
|
|
|
|
export interface UsoTokens {
|
|
entrada: number;
|
|
salida: number;
|
|
cacheLectura: number;
|
|
cacheEscritura: number;
|
|
}
|
|
|
|
export function crearCliente(): Anthropic {
|
|
const apiKey = process.env.MINIMAX_API_KEY;
|
|
if (!apiKey) {
|
|
throw new Error(
|
|
"Falta MINIMAX_API_KEY. Definela en .env. No uses ANTHROPIC_API_KEY: el SDK la " +
|
|
"leeria por su cuenta y mandaria la clave de MiniMax a api.anthropic.com."
|
|
);
|
|
}
|
|
// apiKey y baseURL explicitos. Si se dejan al SDK, toma las ANTHROPIC_* del shell
|
|
// y la clave termina en el proveedor equivocado.
|
|
return new Anthropic({
|
|
apiKey,
|
|
baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/anthropic",
|
|
});
|
|
}
|
|
|
|
export function modelo(): string {
|
|
return process.env.MINIMAX_MODEL || "MiniMax-M3";
|
|
}
|
|
|
|
export interface BloqueSystem {
|
|
texto: string;
|
|
cachear: boolean;
|
|
}
|
|
|
|
interface OpcionesLlamada {
|
|
system: BloqueSystem[];
|
|
mensajeUsuario: string;
|
|
herramienta: { nombre: string; descripcion: string; schema: z.ZodType };
|
|
maxIntentos: number;
|
|
maxTokens: number;
|
|
}
|
|
|
|
export async function llamarConHerramienta<T>(
|
|
opts: OpcionesLlamada
|
|
): Promise<{ datos: T; uso: UsoTokens; intentos: number }> {
|
|
const cliente = crearCliente();
|
|
|
|
const tools = [
|
|
{
|
|
name: opts.herramienta.nombre,
|
|
description: opts.herramienta.descripcion,
|
|
input_schema: aJsonSchema(opts.herramienta.schema) as Anthropic.Tool["input_schema"],
|
|
},
|
|
];
|
|
|
|
const system = opts.system.map((b) => ({
|
|
type: "text" as const,
|
|
text: b.texto,
|
|
...(b.cachear ? { cache_control: { type: "ephemeral" as const } } : {}),
|
|
}));
|
|
|
|
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: opts.mensajeUsuario }];
|
|
const uso: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
|
|
let ultimoError = "";
|
|
|
|
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
|
|
const res = await cliente.messages.create({
|
|
model: modelo(),
|
|
max_tokens: opts.maxTokens,
|
|
system,
|
|
tools,
|
|
messages: mensajes,
|
|
});
|
|
|
|
const u = res.usage as {
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
cache_read_input_tokens?: number;
|
|
cache_creation_input_tokens?: number;
|
|
};
|
|
uso.entrada += u.input_tokens ?? 0;
|
|
uso.salida += u.output_tokens ?? 0;
|
|
uso.cacheLectura += u.cache_read_input_tokens ?? 0;
|
|
uso.cacheEscritura += u.cache_creation_input_tokens ?? 0;
|
|
|
|
const bloquesTool = res.content.filter(
|
|
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
|
|
);
|
|
const correcto = bloquesTool.find((b) => b.name === opts.herramienta.nombre);
|
|
|
|
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(
|
|
{ role: "assistant", content: res.content },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
...bloquesTool.map((b) => ({
|
|
type: "tool_result" as const,
|
|
tool_use_id: b.id,
|
|
is_error: true,
|
|
content:
|
|
b.id === correcto.id
|
|
? ultimoError
|
|
: `Herramienta inesperada. Usa ${opts.herramienta.nombre}.`,
|
|
})),
|
|
{
|
|
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}\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.`,
|
|
},
|
|
],
|
|
}
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// No llamo a ninguna herramienta: aqui si va un turno de usuario plano.
|
|
ultimoError = "El modelo respondio con prosa en vez de llamar a la herramienta.";
|
|
mensajes.push(
|
|
{ role: "assistant", content: res.content },
|
|
{
|
|
role: "user",
|
|
content: `Debes responder llamando a la herramienta ${opts.herramienta.nombre}. No escribas prosa suelta.`,
|
|
}
|
|
);
|
|
}
|
|
|
|
throw new Error(
|
|
`El paso "${opts.herramienta.nombre}" agoto ${opts.maxIntentos} intentos.\nUltimo error:\n${ultimoError}`
|
|
);
|
|
}
|