Propuesta IA: schemas Zod de los tres pasos, sin campos de dinero de E3
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4e103beb27
commit
5bad273977
@@ -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);
|
||||||
@@ -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<typeof hechosSchema>;
|
||||||
|
export type Diagnostico = z.infer<typeof diagnosticoSchema>;
|
||||||
|
export type Redaccion = z.infer<typeof redaccionSchema>;
|
||||||
|
|
||||||
|
/** 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<string, unknown> {
|
||||||
|
const js = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
|
||||||
|
delete js.$schema;
|
||||||
|
return js;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user