Compare commits

...
3 Commits
Author SHA1 Message Date
urieljarethandClaude Opus 5 3a1eaf6e5b Propuesta IA: aflojar las restricciones que peleaban con una respuesta honesta
Medicion de 3 vueltas mas contra datos reales. El aplanado del commit anterior
NO redujo los reintentos: subieron de 3 a 10. Se mantiene porque quitar ese
nivel es correcto de todos modos, pero no cumplio su proposito.

Lo que si salio de esa medicion son dos restricciones mias que provocaban el
rechazo:

- min(5) en los textos de materiales, decisiones, menciones y red flags. Con una
  transcripcion pobre no hay nada que listar, asi que el modelo mete "N/A" o un
  guion para rellenar y falla. Ocurrio 4 veces SEGUIDAS en una vuelta. Baja a
  min(3), y el prompt ahora dice explicitamente que devolver el array vacio es la
  respuesta correcta y que no rellene.
- beneficios.etiqueta.max(40) se pasaba en 2 de 3 vueltas. Sube a 70.

Es el mismo patron que el tope de 40 partidas: el schema peleandose con la
realidad, no el modelo fallando.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:25:20 -06:00
urieljarethandClaude Opus 5 0c127b44c4 Propuesta IA: cerrar los hallazgos medios de la auditoria
El mas urgente lo cause yo al hacer que el bloqueo bloqueara de verdad: si un
aviso bloqueante caia en un campo que el panel no dejaba editar, el asesor
quedaba sin salida salvo regenerar. Ocho campos eran editables y bastantes mas
se imprimen al cliente. Ahora se pueden editar tambien la cita destacada y su
autor, las dimensiones del valor y su nota de metodologia, los resultados con su
metrica y periodo, los materiales, las decisiones con quien decide, y el backlog.
El boton de descarga del documento del cliente aparece deshabilitado cuando hay
bloqueantes, en vez de invitar a un clic que devuelve 409.

Doble propuesta: se rechaza generar. El documento asume UNA lista de alcance y
UNA caja de totales, asi que sumaria las dos opciones —que son alternativas
excluyentes— y mostraria un total que no existe. Soportarlas es rediseniar el
documento; mientras tanto es mejor no producir uno incorrecto. Hoy no hay
cotizaciones dobles en produccion, asi que no bloquea a nadie.

La transcripcion ahora se guarda con la propuesta (columna nueva, migracion
aditiva). Solo vivia en memoria durante la generacion, asi que al editar y
revalidar, R3 se quedaba sin fuente contra la cual comprobar que la cita
destacada siguiera siendo literal, y el aviso desaparecia solo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:15:03 -06:00
urieljarethandClaude Opus 5 434a427f70 Propuesta IA: aplanar el nivel calculo y subir el tope del subtitulo
Segunda tanda de rechazos observados contra datos reales (3 vueltas mas):

- Cada dimension del valor del problema tenia sus factores dentro de un objeto
  `calculo`. Ese nivel no aportaba nada semantico y era justo donde el modelo se
  perdia: devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Ahora
  `factores` y `montoAnualMXN` cuelgan de la dimension. Un nivel menos de
  anidamiento en el punto exacto donde fallaba.
- subtitulo.max(300) se quedaba corto. Sube a 400.

La primera tanda de arreglos ya habia bajado los rechazos de 7 a 3 en tres
vueltas, y de 3 vueltas con reintento en la redaccion a solo 1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:07:39 -06:00
12 changed files with 403 additions and 81 deletions
+113
View File
@@ -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;
+3
View File
@@ -230,6 +230,9 @@ model PropuestaIA {
estado String @default("borrador") // borrador | aprobada
contenidoIA 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 Json?
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
+2 -2
View File
@@ -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"] },
],
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.",
},
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
@@ -189,7 +189,7 @@ async function main() {
const soloEstimado = await generarAnexoInternoPDF({
...datos,
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 NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
+13 -7
View File
@@ -46,7 +46,7 @@ const diagBase = {
valorProblema: {
dimensiones: [{
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: [],
}],
notaMetodologia: "n",
@@ -56,15 +56,21 @@ const diagBase = {
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
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",
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [], montoAnualMXN: null } }] } }).success);
check("omitir montoAnualMXN se tolera y queda en null",
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [] } }] } }).success);
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [], montoAnualMXN: null }] } }).success);
// Una dimension SIN la clave montoAnualMXN: es lo que manda el modelo cuando no tiene
// 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",
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",
!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",
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
+4 -4
View File
@@ -30,7 +30,7 @@ const base = (): PropuestaConsultiva => ({
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"] }],
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.",
},
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 = [];
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"));
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"));
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 no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
+20 -1
View File
@@ -32,6 +32,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
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);
if (!economia.partidas.length) {
return NextResponse.json(
@@ -64,6 +78,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
traza: traza as unknown as object,
avisos: avisos as unknown as object,
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
// 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 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 || "",
});
+182 -7
View File
@@ -172,6 +172,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
const advertencias = avisos.filter((a) => a.severidad === "advertencia");
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
if (cargando) {
return (
@@ -371,6 +372,167 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
))}
</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 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"}
</button>
<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>
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
invitar a un clic que va a fallar. La descarga del anexo interno
sigue disponible: es la que ayuda a entender que corregir. */}
{bloqueantes.length > 0 ? (
<span
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"
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
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
+6 -6
View File
@@ -187,7 +187,7 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
if (dims.length) {
titulo("Lo que cuesta no resolverlo");
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 h = txtH(linea, W - 14, 9);
y = need(h + 14, y);
@@ -434,7 +434,7 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
y += 24;
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 seccion = (t: string) => {
@@ -468,13 +468,13 @@ export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buff
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
seccion("De donde sale el valor anual");
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"}`;
y = need(14, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
y += 13;
if (dim.calculo.factores.length) {
const desglose = dim.calculo.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
if (dim.factores.length) {
const desglose = dim.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
const h = txtH(desglose, W - 10, 8);
y = need(h + 8, y);
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(
(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) {
const alerta =
+2
View File
@@ -158,6 +158,8 @@ Reglas de este paso:
ser "por_validar".
- En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre
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
entender el alcance, que no este presente quien decide, que no haya ninguna cifra del
problema, o un "hagan todo y luego vemos".`;
+49 -48
View File
@@ -47,14 +47,14 @@ export const hechosSchema = z
)
.max(40),
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),
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),
mencionesFueraDeAlcance: z.array(z.string().min(5)).max(15),
mencionesFueraDeAlcance: z.array(z.string().min(3)).max(15),
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),
})
.strict();
@@ -83,48 +83,48 @@ export const diagnosticoSchema = z
valorProblema: z.object({
dimensiones: z
.array(
z.object({
tipo: z.enum(DIMENSION_COSTO),
descripcion: z.string().min(10),
calculo: z
.object({
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
// modelo a inventar relleno. Verificado contra la API real: produjo
// "herramienta_de_seguimiento_actual=0 x canal_principal=1", que no es
// un calculo. La exigencia se aplica solo si hay monto (superRefine).
factores: z
.array(
z.object({
nombre: z.string(),
valor: z.number(),
// Sin este campo el modelo metia la incertidumbre dentro del
// nombre ("tasa_conversion (por_validar)=0.1"). Mejor que sea
// un dato y no una convencion de nomenclatura.
confianza: z.enum(CONFIANZA),
})
)
.max(6),
// .default(null) y no solo .nullable(): verificado contra la API real
// que el modelo OMITE el campo en vez de mandar null explicito, que es
// lo natural para un LLM. Exigirlo presente quemaba los tres intentos.
montoAnualMXN: z
.number()
.nullable()
.default(null)
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
})
.superRefine((c, ctx) => {
if (c.montoAnualMXN !== null && c.factores.length < 2) {
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)),
})
z
.object({
tipo: z.enum(DIMENSION_COSTO),
descripcion: z.string().min(10),
// Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
// nivel extra no aportaba nada semantico y era donde el modelo se perdia:
// devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Observado
// en 3 de 6 corridas contra datos reales.
//
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
// modelo a inventar relleno. La exigencia se aplica solo si hay monto.
factores: z
.array(
z.object({
nombre: z.string(),
valor: z.number(),
// Sin este campo el modelo metia la incertidumbre dentro del nombre
// ("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 que
// el modelo OMITE el campo en vez de mandar null, que es lo natural para
// un LLM. Exigirlo presente quemaba los tres intentos.
montoAnualMXN: z
.number()
.nullable()
.default(null)
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
confianza: z.enum(CONFIANZA),
hechos: z.array(z.string().regex(ID_HECHO)),
})
.superRefine((d, ctx) => {
if (d.montoAnualMXN !== null && d.factores.length < 2) {
ctx.addIssue({
code: "custom",
path: ["factores"],
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
});
}
})
)
.max(4),
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
@@ -147,7 +147,7 @@ export const redaccionSchema = z
.object({
hero: z.object({
titulo: z.string().min(5).max(90),
subtitulo: z.string().min(20).max(300),
subtitulo: z.string().min(20).max(400),
}),
citaDestacadaId: z
.string()
@@ -172,7 +172,8 @@ export const redaccionSchema = z
beneficios: z
.array(
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),
hallazgoId: z
.string()
+6 -6
View File
@@ -181,10 +181,10 @@ export function validarPropuesta(
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
const m = d.calculo.montoAnualMXN;
const m = d.montoAnualMXN;
if (m === null) return;
const producto = d.calculo.factores.reduce((a, f) => a * f.valor, 1);
const suma = d.calculo.factores.reduce((a, f) => a + f.valor, 0);
const producto = d.factores.reduce((a, f) => a * f.valor, 1);
const suma = d.factores.reduce((a, f) => a + f.valor, 0);
const base = Math.max(Math.abs(m), 1);
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
if (!cuadra) {
@@ -205,15 +205,15 @@ export function validarPropuesta(
// 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.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
if (d.calculo.montoAnualMXN === null) return;
const confirmados = d.calculo.factores.filter((f) => f.confianza === "confirmado").length;
if (d.montoAnualMXN === null) return;
const confirmados = d.factores.filter((f) => f.confianza === "confirmado").length;
if (confirmados === 0) {
avisos.push({
regla: "R5b",
severidad: "advertencia",
ruta: `valorProblema.dimensiones[${i}]`,
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.`,
});
}