Compare commits
3
Commits
bdd9319fd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1eaf6e5b | ||
|
|
0c127b44c4 | ||
|
|
434a427f70 |
+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); });
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Guardar la transcripcion usada al generar la propuesta.
|
||||||
|
-- ADITIVA: columna nullable, no toca ninguna fila existente.
|
||||||
|
ALTER TABLE "PropuestaIA" ADD COLUMN IF NOT EXISTS "transcripcion" TEXT;
|
||||||
@@ -230,6 +230,9 @@ model PropuestaIA {
|
|||||||
estado String @default("borrador") // borrador | aprobada
|
estado String @default("borrador") // borrador | aprobada
|
||||||
contenidoIA Json
|
contenidoIA Json
|
||||||
contenidoEditado Json?
|
contenidoEditado Json?
|
||||||
|
// Transcripcion sanitizada usada al generar. Sin esto, el PATCH revalida con
|
||||||
|
// fuentes incompletas y R3 no puede comprobar que la cita destacada sea literal.
|
||||||
|
transcripcion String?
|
||||||
// Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache.
|
// Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache.
|
||||||
traza Json?
|
traza Json?
|
||||||
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
|
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const propuesta: PropuestaConsultiva = {
|
|||||||
{ id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
|
{ id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
|
||||||
],
|
],
|
||||||
valorProblema: {
|
valorProblema: {
|
||||||
dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", calculo: { factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }],
|
dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
|
||||||
notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
|
notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
|
||||||
},
|
},
|
||||||
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
|
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
|
||||||
@@ -189,7 +189,7 @@ async function main() {
|
|||||||
const soloEstimado = await generarAnexoInternoPDF({
|
const soloEstimado = await generarAnexoInternoPDF({
|
||||||
...datos,
|
...datos,
|
||||||
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
|
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
|
||||||
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 } }] } } },
|
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } },
|
||||||
});
|
});
|
||||||
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
|
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
|
||||||
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
|
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const diagBase = {
|
|||||||
valorProblema: {
|
valorProblema: {
|
||||||
dimensiones: [{
|
dimensiones: [{
|
||||||
tipo: "tiempo", descripcion: "x".repeat(12),
|
tipo: "tiempo", descripcion: "x".repeat(12),
|
||||||
calculo: { factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416 },
|
factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416,
|
||||||
confianza: "estimado", hechos: [],
|
confianza: "estimado", hechos: [],
|
||||||
}],
|
}],
|
||||||
notaMetodologia: "n",
|
notaMetodologia: "n",
|
||||||
@@ -56,15 +56,21 @@ const diagBase = {
|
|||||||
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
|
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
|
||||||
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
|
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
|
||||||
check("con monto, exige al menos 2 factores",
|
check("con monto, exige al menos 2 factores",
|
||||||
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 } }] } }).success);
|
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 }] } }).success);
|
||||||
check("sin monto, factores vacio es lo correcto",
|
check("sin monto, factores vacio es lo correcto",
|
||||||
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [], montoAnualMXN: null } }] } }).success);
|
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [], montoAnualMXN: null }] } }).success);
|
||||||
check("omitir montoAnualMXN se tolera y queda en null",
|
// Una dimension SIN la clave montoAnualMXN: es lo que manda el modelo cuando no tiene
|
||||||
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).success);
|
// cifras, y por eso el campo lleva .default(null) en vez de solo .nullable().
|
||||||
|
const dimSinMonto = { tipo: "tiempo", descripcion: "x".repeat(12), factores: [], confianza: "por_validar", hechos: [] };
|
||||||
|
const sinMonto = { ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [dimSinMonto] } };
|
||||||
|
check("omitir montoAnualMXN se tolera", diagnosticoSchema.safeParse(sinMonto).success,
|
||||||
|
JSON.stringify(diagnosticoSchema.safeParse(sinMonto).error?.issues?.[0] ?? ""));
|
||||||
check("al omitirlo, Zod lo rellena con null",
|
check("al omitirlo, Zod lo rellena con null",
|
||||||
diagnosticoSchema.parse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).valorProblema.dimensiones[0].calculo.montoAnualMXN === null);
|
diagnosticoSchema.parse(sinMonto).valorProblema.dimensiones[0].montoAnualMXN === null);
|
||||||
|
check("la dimension quedo plana: sin nivel `calculo`",
|
||||||
|
!JSON.stringify(aJsonSchema(diagnosticoSchema)).includes('"calculo"'));
|
||||||
check("un factor sin confianza se rechaza",
|
check("un factor sin confianza se rechaza",
|
||||||
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 } }] } }).success);
|
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 }] } }).success);
|
||||||
|
|
||||||
check("exige al menos un hallazgo",
|
check("exige al menos un hallazgo",
|
||||||
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
|
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const base = (): PropuestaConsultiva => ({
|
|||||||
diagnostico: {
|
diagnostico: {
|
||||||
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes del fin de semana se pierden.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }],
|
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes del fin de semana se pierden.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }],
|
||||||
valorProblema: {
|
valorProblema: {
|
||||||
dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", calculo: { factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }],
|
dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
|
||||||
notaMetodologia: "Ocho horas por semana a costo cargado.",
|
notaMetodologia: "Ocho horas por semana a costo cargado.",
|
||||||
},
|
},
|
||||||
resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }],
|
resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }],
|
||||||
@@ -72,14 +72,14 @@ check("R3 tolera acentos, mayusculas y puntuacion", !tiene(validarPropuesta(p, e
|
|||||||
p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = [];
|
p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = [];
|
||||||
check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4"));
|
check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4"));
|
||||||
|
|
||||||
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = 999999;
|
p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = 999999;
|
||||||
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
||||||
|
|
||||||
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].calculo.factores = [];
|
p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].factores = [];
|
||||||
check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
||||||
|
|
||||||
p = base();
|
p = base();
|
||||||
p.diagnostico.valorProblema.dimensiones[0].calculo.factores = p.diagnostico.valorProblema.dimensiones[0].calculo.factores.map((f) => ({ ...f, confianza: "estimado" as const }));
|
p.diagnostico.valorProblema.dimensiones[0].factores = p.diagnostico.valorProblema.dimensiones[0].factores.map((f) => ({ ...f, confianza: "estimado" as const }));
|
||||||
check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b"));
|
check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b"));
|
||||||
check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
|
check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
||||||
const { cot, notasPartidas } = ctx;
|
const { cot, notasPartidas } = ctx;
|
||||||
|
|
||||||
|
// La doble propuesta todavia no esta soportada en el documento consultivo: su
|
||||||
|
// estructura asume UNA lista de alcance y UNA caja de totales, asi que sumaria las
|
||||||
|
// dos opciones —que son alternativas excluyentes— y mostraria un total inflado.
|
||||||
|
// Mejor no generar nada que generar un documento con un total que no existe.
|
||||||
|
if (cot.esDoble) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Esta cotizacion es de doble propuesta y el documento consultivo todavia no las soporta: sumaria las dos opciones como si fueran una sola. Genera la propuesta desde una cotizacion de opcion unica.",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const economia = await cargarEconomia(id);
|
const economia = await cargarEconomia(id);
|
||||||
if (!economia.partidas.length) {
|
if (!economia.partidas.length) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -64,6 +78,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
traza: traza as unknown as object,
|
traza: traza as unknown as object,
|
||||||
avisos: avisos as unknown as object,
|
avisos: avisos as unknown as object,
|
||||||
modelo: traza.modelo,
|
modelo: traza.modelo,
|
||||||
|
transcripcion: transcripcion || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,9 +128,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
// La edicion del asesor se revalida: pudo introducir una fuga, un USD o una
|
// La edicion del asesor se revalida: pudo introducir una fuga, un USD o una
|
||||||
// promesa de resultado sin darse cuenta.
|
// promesa de resultado sin darse cuenta.
|
||||||
|
//
|
||||||
|
// Se incluye la transcripcion GUARDADA con la propuesta. Antes solo vivia en
|
||||||
|
// memoria durante la peticion de generacion, asi que al editar R3 se quedaba sin
|
||||||
|
// fuente contra la cual comprobar la cita literal y el aviso desaparecia solo.
|
||||||
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
|
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
|
||||||
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
|
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
|
||||||
textoCliente: [ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
|
textoCliente: [fila.transcripcion || "", ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
|
||||||
textoInterno: ctx?.cot.observacionesInternas || "",
|
textoInterno: ctx?.cot.observacionesInternas || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
|
|||||||
|
|
||||||
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
|
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
|
||||||
const advertencias = avisos.filter((a) => a.severidad === "advertencia");
|
const advertencias = avisos.filter((a) => a.severidad === "advertencia");
|
||||||
|
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
|
||||||
|
|
||||||
if (cargando) {
|
if (cargando) {
|
||||||
return (
|
return (
|
||||||
@@ -371,6 +372,167 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Los grupos de abajo tambien se imprimen al cliente, asi que tambien
|
||||||
|
pueden disparar un aviso bloqueante. Si no fueran editables, un
|
||||||
|
bloqueante ahi no tendria mas salida que regenerar la propuesta. */}
|
||||||
|
|
||||||
|
{citaDestacada && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">
|
||||||
|
Cita destacada
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted mb-2">
|
||||||
|
Se imprime literal. Editala solo para quitar un dato sensible, nunca para
|
||||||
|
mejorarle la redaccion al cliente.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={citaDestacada.textoLiteral}
|
||||||
|
onChange={(e) =>
|
||||||
|
editar((p) => {
|
||||||
|
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
|
||||||
|
if (c) c.textoLiteral = e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
className={`${INPUT} mb-2`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={citaDestacada.quienLoDijo}
|
||||||
|
onChange={(e) =>
|
||||||
|
editar((p) => {
|
||||||
|
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
|
||||||
|
if (c) c.quienLoDijo = e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Quien lo dijo (usa el rol, no el nombre)"
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propuesta.diagnostico.valorProblema.dimensiones.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Lo que cuesta no resolverlo
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.diagnostico.valorProblema.dimensiones.map((dim, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-start">
|
||||||
|
<span className="text-xs text-muted mt-2.5 shrink-0 w-20">{dim.tipo}</span>
|
||||||
|
<textarea
|
||||||
|
value={dim.descripcion}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.dimensiones[i].descripcion = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={propuesta.diagnostico.valorProblema.notaMetodologia}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.notaMetodologia = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Nota de metodologia"
|
||||||
|
className={`${INPUT} mt-2`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propuesta.diagnostico.resultados.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Resultados a lograr
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.diagnostico.resultados.map((r, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<textarea
|
||||||
|
value={r.enunciado}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].enunciado = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={r.metrica}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].metrica = e.target.value; })}
|
||||||
|
placeholder="Metrica"
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={r.periodoMedicion}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].periodoMedicion = e.target.value; })}
|
||||||
|
placeholder="Periodo"
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(propuesta.hechos.materialesPendientes.length > 0 ||
|
||||||
|
propuesta.hechos.decisionesPendientes.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Que necesitamos de ustedes
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.hechos.materialesPendientes.map((m, i) => (
|
||||||
|
<input
|
||||||
|
key={`mat-${i}`}
|
||||||
|
value={m.texto}
|
||||||
|
onChange={(e) => editar((p) => { p.hechos.materialesPendientes[i].texto = e.target.value; })}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{propuesta.hechos.decisionesPendientes.map((dd, i) => (
|
||||||
|
<div key={`dec-${i}`} className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={dd.texto}
|
||||||
|
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].texto = e.target.value; })}
|
||||||
|
rows={1}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={dd.quienDecide}
|
||||||
|
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].quienDecide = e.target.value; })}
|
||||||
|
placeholder="Quien decide"
|
||||||
|
className={`${INPUT} max-w-[11rem] shrink-0`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propuesta.redaccion.backlogEvolucion.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Registrado para mas adelante
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.redaccion.backlogEvolucion.map((b, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={b.problema}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].problema = e.target.value; })}
|
||||||
|
rows={1}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={b.momentoSugerido}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].momentoSugerido = e.target.value; })}
|
||||||
|
placeholder="Cuando"
|
||||||
|
className={`${INPUT} max-w-[11rem] shrink-0`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
||||||
@@ -383,13 +545,26 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
|
|||||||
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
|
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
|
||||||
href={`/api/propuesta-ia/${cotizacionId}/pdf`}
|
invitar a un clic que va a fallar. La descarga del anexo interno
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
sigue disponible: es la que ayuda a entender que corregir. */}
|
||||||
>
|
{bloqueantes.length > 0 ? (
|
||||||
<Download className="w-4 h-4" />
|
<span
|
||||||
Propuesta consultiva (PDF)
|
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm text-muted bg-gray-50 cursor-not-allowed"
|
||||||
</a>
|
title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Propuesta consultiva (PDF) — bloqueada
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`/api/propuesta-ia/${cotizacionId}/pdf`}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Propuesta consultiva (PDF)
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
|
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
|
|||||||
if (dims.length) {
|
if (dims.length) {
|
||||||
titulo("Lo que cuesta no resolverlo");
|
titulo("Lo que cuesta no resolverlo");
|
||||||
for (const dim of dims) {
|
for (const dim of dims) {
|
||||||
const monto = dim.calculo.montoAnualMXN;
|
const monto = dim.montoAnualMXN;
|
||||||
const linea = `${dim.descripcion} — ${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`;
|
const linea = `${dim.descripcion} — ${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`;
|
||||||
const h = txtH(linea, W - 14, 9);
|
const h = txtH(linea, W - 14, 9);
|
||||||
y = need(h + 14, y);
|
y = need(h + 14, y);
|
||||||
@@ -434,7 +434,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
|
|||||||
y += 24;
|
y += 24;
|
||||||
|
|
||||||
const valorAnual =
|
const valorAnual =
|
||||||
d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.calculo.montoAnualMXN ?? 0), 0) || null;
|
d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.montoAnualMXN ?? 0), 0) || null;
|
||||||
const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual);
|
const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual);
|
||||||
|
|
||||||
const seccion = (t: string) => {
|
const seccion = (t: string) => {
|
||||||
@@ -468,13 +468,13 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
|
|||||||
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
|
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
|
||||||
seccion("De donde sale el valor anual");
|
seccion("De donde sale el valor anual");
|
||||||
for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) {
|
for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) {
|
||||||
const monto = dim.calculo.montoAnualMXN;
|
const monto = dim.montoAnualMXN;
|
||||||
const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`;
|
const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`;
|
||||||
y = need(14, y);
|
y = need(14, y);
|
||||||
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
|
||||||
y += 13;
|
y += 13;
|
||||||
if (dim.calculo.factores.length) {
|
if (dim.factores.length) {
|
||||||
const desglose = dim.calculo.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
|
const desglose = dim.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
|
||||||
const h = txtH(desglose, W - 10, 8);
|
const h = txtH(desglose, W - 10, 8);
|
||||||
y = need(h + 8, y);
|
y = need(h + 8, y);
|
||||||
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 });
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 });
|
||||||
@@ -482,7 +482,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
|
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
|
||||||
(dim) => dim.calculo.montoAnualMXN !== null && !dim.calculo.factores.some((f) => f.confianza === "confirmado")
|
(dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado")
|
||||||
);
|
);
|
||||||
if (sinConfirmar) {
|
if (sinConfirmar) {
|
||||||
const alerta =
|
const alerta =
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ Reglas de este paso:
|
|||||||
ser "por_validar".
|
ser "por_validar".
|
||||||
- En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre
|
- En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre
|
||||||
propio, salvo que sea el interlocutor comercial.
|
propio, salvo que sea el interlocutor comercial.
|
||||||
|
- Si de alguna de estas listas no hay NADA que decir, devuelvela VACIA. Un array vacio
|
||||||
|
es la respuesta correcta; no metas "N/A", "ninguno" ni un guion para rellenar.
|
||||||
- En redFlags anota lo que deberia preocuparle al asesor: que pidan descuento antes de
|
- En redFlags anota lo que deberia preocuparle al asesor: que pidan descuento antes de
|
||||||
entender el alcance, que no este presente quien decide, que no haya ninguna cifra del
|
entender el alcance, que no este presente quien decide, que no haya ninguna cifra del
|
||||||
problema, o un "hagan todo y luego vemos".`;
|
problema, o un "hagan todo y luego vemos".`;
|
||||||
|
|||||||
@@ -47,14 +47,14 @@ export const hechosSchema = z
|
|||||||
)
|
)
|
||||||
.max(40),
|
.max(40),
|
||||||
materialesPendientes: z
|
materialesPendientes: z
|
||||||
.array(z.object({ texto: z.string().min(5), bloqueaEntrega: z.boolean() }))
|
.array(z.object({ texto: z.string().min(3), bloqueaEntrega: z.boolean() }))
|
||||||
.max(15),
|
.max(15),
|
||||||
decisionesPendientes: z
|
decisionesPendientes: z
|
||||||
.array(z.object({ texto: z.string().min(5), quienDecide: z.string() }))
|
.array(z.object({ texto: z.string().min(3), quienDecide: z.string() }))
|
||||||
.max(15),
|
.max(15),
|
||||||
mencionesFueraDeAlcance: z.array(z.string().min(5)).max(15),
|
mencionesFueraDeAlcance: z.array(z.string().min(3)).max(15),
|
||||||
redFlags: z
|
redFlags: z
|
||||||
.array(z.object({ senal: z.string().min(5), severidad: z.enum(["baja", "media", "alta"]) }))
|
.array(z.object({ senal: z.string().min(3), severidad: z.enum(["baja", "media", "alta"]) }))
|
||||||
.max(10),
|
.max(10),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -83,48 +83,48 @@ export const diagnosticoSchema = z
|
|||||||
valorProblema: z.object({
|
valorProblema: z.object({
|
||||||
dimensiones: z
|
dimensiones: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z
|
||||||
tipo: z.enum(DIMENSION_COSTO),
|
.object({
|
||||||
descripcion: z.string().min(10),
|
tipo: z.enum(DIMENSION_COSTO),
|
||||||
calculo: z
|
descripcion: z.string().min(10),
|
||||||
.object({
|
// Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
|
||||||
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
|
// nivel extra no aportaba nada semantico y era donde el modelo se perdia:
|
||||||
// modelo a inventar relleno. Verificado contra la API real: produjo
|
// devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Observado
|
||||||
// "herramienta_de_seguimiento_actual=0 x canal_principal=1", que no es
|
// en 3 de 6 corridas contra datos reales.
|
||||||
// un calculo. La exigencia se aplica solo si hay monto (superRefine).
|
//
|
||||||
factores: z
|
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
|
||||||
.array(
|
// modelo a inventar relleno. La exigencia se aplica solo si hay monto.
|
||||||
z.object({
|
factores: z
|
||||||
nombre: z.string(),
|
.array(
|
||||||
valor: z.number(),
|
z.object({
|
||||||
// Sin este campo el modelo metia la incertidumbre dentro del
|
nombre: z.string(),
|
||||||
// nombre ("tasa_conversion (por_validar)=0.1"). Mejor que sea
|
valor: z.number(),
|
||||||
// un dato y no una convencion de nomenclatura.
|
// Sin este campo el modelo metia la incertidumbre dentro del nombre
|
||||||
confianza: z.enum(CONFIANZA),
|
// ("tasa_conversion (por_validar)=0.1"). Mejor dato que convencion.
|
||||||
})
|
confianza: z.enum(CONFIANZA),
|
||||||
)
|
})
|
||||||
.max(6),
|
)
|
||||||
// .default(null) y no solo .nullable(): verificado contra la API real
|
.max(6),
|
||||||
// que el modelo OMITE el campo en vez de mandar null explicito, que es
|
// .default(null) y no solo .nullable(): verificado contra la API real que
|
||||||
// lo natural para un LLM. Exigirlo presente quemaba los tres intentos.
|
// el modelo OMITE el campo en vez de mandar null, que es lo natural para
|
||||||
montoAnualMXN: z
|
// un LLM. Exigirlo presente quemaba los tres intentos.
|
||||||
.number()
|
montoAnualMXN: z
|
||||||
.nullable()
|
.number()
|
||||||
.default(null)
|
.nullable()
|
||||||
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
|
.default(null)
|
||||||
})
|
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
|
||||||
.superRefine((c, ctx) => {
|
confianza: z.enum(CONFIANZA),
|
||||||
if (c.montoAnualMXN !== null && c.factores.length < 2) {
|
hechos: z.array(z.string().regex(ID_HECHO)),
|
||||||
ctx.addIssue({
|
})
|
||||||
code: "custom",
|
.superRefine((d, ctx) => {
|
||||||
path: ["factores"],
|
if (d.montoAnualMXN !== null && d.factores.length < 2) {
|
||||||
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
|
ctx.addIssue({
|
||||||
});
|
code: "custom",
|
||||||
}
|
path: ["factores"],
|
||||||
}),
|
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
|
||||||
confianza: z.enum(CONFIANZA),
|
});
|
||||||
hechos: z.array(z.string().regex(ID_HECHO)),
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.max(4),
|
.max(4),
|
||||||
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
|
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
|
||||||
@@ -147,7 +147,7 @@ export const redaccionSchema = z
|
|||||||
.object({
|
.object({
|
||||||
hero: z.object({
|
hero: z.object({
|
||||||
titulo: z.string().min(5).max(90),
|
titulo: z.string().min(5).max(90),
|
||||||
subtitulo: z.string().min(20).max(300),
|
subtitulo: z.string().min(20).max(400),
|
||||||
}),
|
}),
|
||||||
citaDestacadaId: z
|
citaDestacadaId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -172,7 +172,8 @@ export const redaccionSchema = z
|
|||||||
beneficios: z
|
beneficios: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
etiqueta: z.string().min(3).max(40),
|
// 70 y no 40: se pasaba en 2 de 3 vueltas contra datos reales.
|
||||||
|
etiqueta: z.string().min(3).max(70),
|
||||||
texto: z.string().min(20),
|
texto: z.string().min(20),
|
||||||
hallazgoId: z
|
hallazgoId: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -181,10 +181,10 @@ export function validarPropuesta(
|
|||||||
|
|
||||||
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
|
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
|
||||||
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
||||||
const m = d.calculo.montoAnualMXN;
|
const m = d.montoAnualMXN;
|
||||||
if (m === null) return;
|
if (m === null) return;
|
||||||
const producto = d.calculo.factores.reduce((a, f) => a * f.valor, 1);
|
const producto = d.factores.reduce((a, f) => a * f.valor, 1);
|
||||||
const suma = d.calculo.factores.reduce((a, f) => a + f.valor, 0);
|
const suma = d.factores.reduce((a, f) => a + f.valor, 0);
|
||||||
const base = Math.max(Math.abs(m), 1);
|
const base = Math.max(Math.abs(m), 1);
|
||||||
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
|
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
|
||||||
if (!cuadra) {
|
if (!cuadra) {
|
||||||
@@ -205,15 +205,15 @@ export function validarPropuesta(
|
|||||||
// conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la
|
// conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la
|
||||||
// aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10.
|
// aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10.
|
||||||
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
||||||
if (d.calculo.montoAnualMXN === null) return;
|
if (d.montoAnualMXN === null) return;
|
||||||
const confirmados = d.calculo.factores.filter((f) => f.confianza === "confirmado").length;
|
const confirmados = d.factores.filter((f) => f.confianza === "confirmado").length;
|
||||||
if (confirmados === 0) {
|
if (confirmados === 0) {
|
||||||
avisos.push({
|
avisos.push({
|
||||||
regla: "R5b",
|
regla: "R5b",
|
||||||
severidad: "advertencia",
|
severidad: "advertencia",
|
||||||
ruta: `valorProblema.dimensiones[${i}]`,
|
ruta: `valorProblema.dimensiones[${i}]`,
|
||||||
mensaje:
|
mensaje:
|
||||||
`La cifra de ${d.calculo.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` +
|
`La cifra de ${d.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` +
|
||||||
`Revisa el desglose antes de fiarte del ratio precio/valor.`,
|
`Revisa el desglose antes de fiarte del ratio precio/valor.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user