diff --git a/cazar-tmp.ts b/cazar-tmp.ts new file mode 100644 index 0000000..21a6d6d --- /dev/null +++ b/cazar-tmp.ts @@ -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>, 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); }); diff --git a/scripts/verificar-propuesta-cliente.ts b/scripts/verificar-propuesta-cliente.ts index 8d908b5..66ee55b 100644 --- a/scripts/verificar-propuesta-cliente.ts +++ b/scripts/verificar-propuesta-cliente.ts @@ -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); diff --git a/src/lib/propuesta/cliente-ia.ts b/src/lib/propuesta/cliente-ia.ts index 754143d..af4591d 100644 --- a/src/lib/propuesta/cliente-ia.ts +++ b/src/lib/propuesta/cliente-ia.ts @@ -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; + const claves = Object.keys(o); + if (claves.length === 1 && claves[0] === "item") return normalizarRespuesta(o.item); + const salida: Record = {}; + 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 { + 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( 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( 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( diff --git a/src/lib/propuesta/schemas.ts b/src/lib/propuesta/schemas.ts index 5c1bccc..c835f1d 100644 --- a/src/lib/propuesta/schemas.ts +++ b/src/lib/propuesta/schemas.ts @@ -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({