From 5bad27397715dfed6d5ea335c7a66015187b0fad Mon Sep 17 00:00:00 2001 From: urieljareth Date: Tue, 28 Jul 2026 18:12:54 -0600 Subject: [PATCH] Propuesta IA: schemas Zod de los tres pasos, sin campos de dinero de E3 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/verificar-propuesta-schemas.ts | 81 +++++++++++ src/lib/propuesta/schemas.ts | 194 +++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 scripts/verificar-propuesta-schemas.ts create mode 100644 src/lib/propuesta/schemas.ts diff --git a/scripts/verificar-propuesta-schemas.ts b/scripts/verificar-propuesta-schemas.ts new file mode 100644 index 0000000..c3ce8cc --- /dev/null +++ b/scripts/verificar-propuesta-schemas.ts @@ -0,0 +1,81 @@ +import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema } from "@/lib/propuesta/schemas"; + +let fallas = 0; +function check(n: string, ok: boolean, d = "") { + console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`); + if (!ok) fallas++; +} + +// ── P1 por construccion: ningun schema declara un campo de dinero de E3 ── +const todos = JSON.stringify([ + aJsonSchema(hechosSchema), + aJsonSchema(diagnosticoSchema), + aJsonSchema(redaccionSchema), +]); +for (const prohibido of ["precio", "total", "subtotal", "iva", "montoMinimo", "tarifaHora", "descuento"]) { + check(`ningun schema declara la propiedad "${prohibido}"`, !todos.includes(`"${prohibido}"`)); +} +// montoAnualMXN si debe existir: es el costo del problema del cliente, no un precio nuestro. +check("montoAnualMXN si existe (costo del problema del cliente)", todos.includes("montoAnualMXN")); + +// ── Formato de las claves ── +const redaccionBase = { + hero: { titulo: "Titulo de prueba", subtitulo: "x".repeat(25) }, + citaDestacadaId: "C01", + alcance: [{ refPartida: "P01", descripcionResultado: "x".repeat(25) }], + beneficios: [], + exclusiones: [{ texto: "x".repeat(20), razon: "porque si" }], + backlogEvolucion: [], + notasInternas: [], +}; +check("una redaccion bien formada valida", redaccionSchema.safeParse(redaccionBase).success); +check("refPartida en minuscula se rechaza", + !redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "p01", descripcionResultado: "x".repeat(25) }] }).success); +check("refPartida sin ceros se rechaza", + !redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "P1", descripcionResultado: "x".repeat(25) }] }).success); +check("citaDestacadaId con otro prefijo se rechaza", + !redaccionSchema.safeParse({ ...redaccionBase, citaDestacadaId: "H01" }).success); +check("exige al menos una exclusion", + !redaccionSchema.safeParse({ ...redaccionBase, exclusiones: [] }).success); +check("rechaza propiedades desconocidas (strict)", + !redaccionSchema.safeParse({ ...redaccionBase, precioTotal: 99999 }).success); + +// ── El calculo del valor debe venir desglosado ── +const diagBase = { + hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Titulo del hallazgo", descripcion: "x".repeat(25), confianza: "confirmado", citas: [], hechos: [] }], + valorProblema: { + dimensiones: [{ + tipo: "tiempo", descripcion: "x".repeat(12), + calculo: { factores: [{ nombre: "a", valor: 8 }, { nombre: "b", valor: 52 }], montoAnualMXN: 416 }, + confianza: "estimado", hechos: [], + }], + notaMetodologia: "n", + }, + resultados: [], +}; +check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success, + JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? "")); +check("el calculo exige al menos 2 factores", + !diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }], montoAnualMXN: 1 } }] } }).success); +check("montoAnualMXN acepta null (sin cifras)", + diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: null } }] } }).success); +check("exige al menos un hallazgo", + !diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success); +check("urgencia fuera del semaforo se rechaza", + !diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [{ ...diagBase.hallazgos[0], urgencia: "morado" }] }).success); + +// ── Hechos ── +check("una cita corta se rechaza", + !hechosSchema.safeParse({ citas: [{ id: "C01", textoLiteral: "hola", quienLoDijo: "x" }], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success); +check("hechos vacios son validos (material pobre)", + hechosSchema.safeParse({ citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success); + +// ── Conversion a JSON Schema ── +const js = aJsonSchema(redaccionSchema); +check("el JSON Schema no lleva $schema", !("$schema" in js)); +check("conserva additionalProperties", JSON.stringify(js).includes("additionalProperties")); +check("conserva las descripciones para el modelo", JSON.stringify(js).includes("description")); +check("el JSON Schema es serializable", typeof JSON.stringify(js) === "string" && JSON.stringify(js).length > 200); + +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); diff --git a/src/lib/propuesta/schemas.ts b/src/lib/propuesta/schemas.ts new file mode 100644 index 0000000..236691e --- /dev/null +++ b/src/lib/propuesta/schemas.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; + +/** + * Contrato con el modelo. MiniMax no soporta structured outputs, asi que estos + * schemas viajan como `input_schema` de una herramienta y Zod valida la respuesta. + * + * REGLA ESTRUCTURAL: ningun schema de aqui contiene un campo de dinero DE E3. + * El modelo devuelve `refPartida` y prosa; los precios, totales e IVA los inyecta + * el codigo al renderizar. `montoAnualMXN` si existe, pero es lo que el problema + * le cuesta AL CLIENTE cada ano — no un precio nuestro. + */ + +const REF_PARTIDA = /^P\d{2}$/; +const ID_CITA = /^C\d{2}$/; +const ID_HECHO = /^H\d{2}$/; +const ID_HALLAZGO = /^D\d{2}$/; + +export const CONFIANZA = ["confirmado", "estimado", "por_validar"] as const; +export const URGENCIA = ["rojo", "ambar", "azul", "verde"] as const; +export const DIMENSION_COSTO = ["dinero", "tiempo", "oportunidad", "error"] as const; + +// ───────────────────── Paso 1: extraccion de hechos ───────────────────── +export const hechosSchema = z + .object({ + citas: z + .array( + z.object({ + id: z.string().regex(ID_CITA, "El id de cita debe ser C01, C02..."), + textoLiteral: z + .string() + .min(10) + .describe("Cita TEXTUAL del cliente, palabra por palabra. Nunca parafraseada ni corregida."), + quienLoDijo: z + .string() + .describe("Rol del interlocutor comercial ('el socio', 'quien atiende el WhatsApp'). No nombres de empleados."), + }) + ) + .max(30), + hechos: z + .array( + z.object({ + id: z.string().regex(ID_HECHO, "El id de hecho debe ser H01, H02..."), + enunciado: z.string().min(10), + confianza: z.enum(CONFIANZA), + citas: z.array(z.string().regex(ID_CITA)).describe("IDs de las citas que sostienen este hecho."), + }) + ) + .max(40), + materialesPendientes: z + .array(z.object({ texto: z.string().min(5), bloqueaEntrega: z.boolean() })) + .max(15), + decisionesPendientes: z + .array(z.object({ texto: z.string().min(5), quienDecide: z.string() })) + .max(15), + mencionesFueraDeAlcance: z.array(z.string().min(5)).max(15), + redFlags: z + .array(z.object({ senal: z.string().min(5), severidad: z.enum(["baja", "media", "alta"]) })) + .max(10), + }) + .strict(); + +// ─────────────── Paso 2: diagnostico y valoracion del problema ─────────────── +export const diagnosticoSchema = z + .object({ + hallazgos: z + .array( + z.object({ + id: z.string().regex(ID_HALLAZGO, "El id de hallazgo debe ser D01, D02..."), + 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), + descripcion: z.string().min(20), + confianza: z.enum(CONFIANZA), + citas: z.array(z.string().regex(ID_CITA)), + hechos: z.array(z.string().regex(ID_HECHO)), + }) + ) + .min(1) + .max(12), + valorProblema: z.object({ + dimensiones: z + .array( + z.object({ + tipo: z.enum(DIMENSION_COSTO), + descripcion: z.string().min(10), + calculo: z.object({ + factores: z.array(z.object({ nombre: z.string(), valor: z.number() })).min(2), + montoAnualMXN: z + .number() + .nullable() + .describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. null si no hay cifras."), + }), + confianza: z.enum(CONFIANZA), + hechos: z.array(z.string().regex(ID_HECHO)), + }) + ) + .max(4), + notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."), + }), + resultados: z + .array( + z.object({ + enunciado: z.string().min(15), + metrica: z.string(), + lineaBase: z.string().nullable(), + periodoMedicion: z.string(), + }) + ) + .max(5), + }) + .strict(); + +// ───────────────────────── Paso 3: redaccion final ───────────────────────── +export const redaccionSchema = z + .object({ + hero: z.object({ + titulo: z.string().min(5).max(90), + subtitulo: z.string().min(20).max(300), + }), + citaDestacadaId: z + .string() + .regex(ID_CITA) + .describe("ID de una cita YA extraida en el paso 1. Se imprime literal; no la reescribas."), + alcance: z + .array( + z.object({ + refPartida: z.string().regex(REF_PARTIDA, "Debe ser P01, P02..."), + descripcionResultado: z + .string() + .min(20) + .describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."), + }) + ) + .max(40), + beneficios: z + .array( + z.object({ + etiqueta: z.string().min(3).max(40), + texto: z.string().min(20), + hallazgoId: z + .string() + .regex(ID_HALLAZGO) + .describe("El hallazgo que este beneficio resuelve. Si no responde a ninguno, no lo incluyas."), + }) + ) + .max(8), + exclusiones: z + .array( + z.object({ + texto: z.string().min(15).describe("Especifica de ESTE proyecto, nunca generica."), + razon: z.string().min(5), + }) + ) + .min(1) + .max(10), + backlogEvolucion: z + .array(z.object({ problema: z.string().min(10), momentoSugerido: z.string() })) + .max(8), + notasInternas: z + .array(z.string().min(5)) + .max(10) + .describe("SOLO para el asesor. Nunca se imprime en el documento del cliente."), + }) + .strict(); + +export type Hechos = z.infer; +export type Diagnostico = z.infer; +export type Redaccion = z.infer; + +/** El objeto completo que se persiste, se edita y se renderiza. */ +export interface PropuestaConsultiva { + hechos: Hechos; + diagnostico: Diagnostico; + redaccion: Redaccion; +} + +/** Schema del objeto completo, para validar lo que el asesor edita a mano. */ +export const propuestaCompletaSchema = z.object({ + hechos: hechosSchema, + diagnostico: diagnosticoSchema, + redaccion: redaccionSchema, +}); + +/** + * Zod -> JSON Schema para el `input_schema` de la herramienta. + * zod 4.3.6 trae `z.toJSONSchema` nativo: no hace falta `zod-to-json-schema`. + * Se borra `$schema` porque el endpoint no lo espera. + */ +export function aJsonSchema(schema: z.ZodType): Record { + const js = z.toJSONSchema(schema, { io: "input" }) as Record; + delete js.$schema; + return js; +}