Compare commits

...
15 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
urieljarethandClaude Opus 5 bdd9319fd0 Propuesta IA: corregir lo que encontro la auditoria de herramientas y plantillas
La auditoria confirma lo que se pedia verificar: la IA SOLO puede usar las tres
herramientas internas. Una por llamada, ninguna se ejecuta jamas (el input del
modelo solo va a schema.safeParse, no hay despachador), y no se envia ningun
tool del proveedor, web search, ejecucion de codigo, MCP ni beta.

Lo que NO cumplia era la otra mitad, la correspondencia con las plantillas:

CRITICO - el documento cobraba un IVA que la cotizacion no cobra. Cada partida
se imprimia a precio SIN IVA y debajo un unico total CON IVA, rematado con
"Importes con IVA incluido", mientras el PDF economico del mismo correo dice
"los precios no incluyen IVA" sobre las mismas cifras. Las lineas no sumaban su
propio total. Ahora la caja de totales desglosa subtotal / IVA / total como la
plantilla autorizada, las lineas siguen sin IVA igual que el otro documento, y
la nota al pie dice lo que de verdad hacen las lineas.

CRITICO - los avisos bloqueantes no bloqueaban nada. hayBloqueantes() no se
llamaba en ningun sitio y la ruta del PDF nunca leia avisos: el documento del
cliente se descargaba igual con una fuga de notas internas dentro. Ahora
devuelve 409 con la lista de lo que hay que corregir. El anexo interno si se
permite: es justo el que el asesor necesita para arreglarlo.

ALTO - seis campos que SI se imprimen al cliente no pasaban por los filtros de
marca, moneda, garantias y PII: el texto y el autor de la cita destacada, la
metrica y el periodo de cada resultado, quien decide cada pendiente, y el
momento del backlog. textoVisible ahora los cubre y queda documentado que debe
seguir a lo que dibuja el PDF.

ALTO - el plan Bucefalo se caia del documento y de sus totales, aunque si
aparece en el PDF economico y en el Excel: cargarEconomia nunca leia la
relacion. El cliente recibia dos documentos con alcances distintos.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:01:34 -06:00
urieljarethandClaude Opus 5 3f46d8a734 Propuesta IA: quitar los rechazos autoinfligidos y tolerar rarezas de MiniMax
Seis vueltas completas del pipeline contra los datos reales de UJ2606UR001
mostraron que la mayoria de los reintentos los provocaba el propio schema, no
el modelo:

- alcance.max(40) contra una cotizacion de 58 partidas garantizaba un rechazo de
  Zod en CADA corrida. Sube a 120. El tope solo acota una respuesta desbocada;
  la completitud del documento ya no depende de este array desde d20007d2.
- titulo.max(80) se quedaba corto y costo un reintento en 2 de 6 corridas.
  Sube a 140.

Y dos rarezas del proveedor, ambas observadas contra la API real:

- MiniMax a veces envuelve los elementos de un array en {item: {...}}. Se
  normaliza antes de validar, solo cuando "item" es la unica clave, para no
  tocar un campo legitimo con ese nombre.
- A veces emite DOS bloques tool_use en una respuesta. Antes se tomaba el
  primero a secas; ahora se prueban todos y gana el que valide.

Sobre el error de tipo de documento que se vio en produccion: NO se reprodujo en
seis corridas completas contra los mismos datos, y la generacion que lo siguio
completo sin problema (la fila quedo en la tabla). La evidencia apunta a un
fallo transitorio del proveedor, no a un defecto determinista nuestro. En vez de
inventar un arreglo para algo que no se puede reproducir, se reintentan los
fallos transitorios (5xx, 429, timeouts, red y los 400 con mensaje de parseo
interno) con espera creciente.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 20:54:48 -06:00
urieljarethandClaude Opus 5 d20007d252 Propuesta IA: el documento lista TODAS las partidas de la cotizacion
Encontrado con datos reales de produccion. UJ2606UR001 tiene 58 partidas y el
schema acota alcance a 40, asi que 18 servicios cotizados desaparecian del
documento del cliente en silencio: aparecian en el PDF economico pero no en el
consultivo, para el mismo envio.

El arreglo no es subir el tope. El generador ahora recorre las partidas de la
COTIZACION, no las que la IA alcanzo a describir, y usa la prosa de la IA cuando
existe. Si falta, imprime el detalle del catalogo (entregables y tiempo de
entrega) como respaldo. La completitud la manda la base de datos; la IA solo
aporta la redaccion. Es el mismo principio que ya rige para el dinero.

Verificado contra la propuesta real que genero produccion: 58 de 58 presentes,
cero faltantes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 20:40:43 -06:00
urieljarethandClaude Opus 5 7682fcb86b Coolify: pasar las variables MINIMAX_* al contenedor web
El compose filtra que variables llegan al contenedor. Sin declararlas aqui,
ponerlas en el panel de Coolify no habria servido de nada: el boton de generar
propuesta habria fallado con 'Falta MINIMAX_API_KEY' aun estando configurada.

Editados los dos archivos, como exige AGENTS.md: docker-compose.yaml es una copia
identica de coolify.yml que existe para la deteccion por defecto de Coolify, y
editar solo uno despliega el viejo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 19:03:35 -06:00
urieljarethandClaude Opus 5 74c0374a2a Propuesta IA: correcciones encontradas probando contra la API real de MiniMax
Tres defectos que solo se veian llamando al modelo de verdad.

1. min(2) en los factores obligaba a inventar relleno. Con una dimension sin
   cifras, MiniMax produjo "herramienta_de_seguimiento_actual=0 x canal=1" solo
   para satisfacer la restriccion. Ahora los dos factores se exigen unicamente
   cuando hay montoAnualMXN.

2. El modelo OMITE montoAnualMXN en vez de mandar null explicito, que es lo
   natural para un LLM. Exigirlo presente quemaba los tres intentos del paso.
   Ahora es .default(null) y la omision se tolera.

3. Sin confianza por factor, el modelo la metia dentro del nombre
   ("tasa_conversion (por_validar)=0.1"). Ahora es un campo.

Y el hallazgo que mas importa, porque es comercial y no de formato: el modelo
puede OMITIR un factor y aun asi cuadrar la aritmetica. En una corrida calculo
40 mensajes x 0.5 sin contestar x 52 semanas x 3,000 de utilidad POR CLIENTE =
3,120,000, asumiendo que cada mensaje sin responder es un cliente perdido. R5 lo
acepto porque los factores si multiplican al monto: R5 no puede ver lo que falta.

El ratio habria dicho "subcotizado" con el denominador inflado diez veces. Como
no se puede impedir que el modelo produzca estimaciones plausibles y erroneas, lo
que se hace es que el asesor las cache de un vistazo:
- R5b avisa cuando una cifra no tiene ni un factor confirmado.
- El anexo interno desglosa cada factor con su confianza, y alerta en rojo si el
  valor descansa entero en estimaciones.

Ademas, el presupuesto de reintentos sube (5 en extraccion, 4 en los otros dos):
MiniMax se equivoca de array de vez en cuando con schemas anidados, y la
extraccion es el paso fundacional. Una corrida real necesito los 5.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:59:42 -06:00
urieljarethandClaude Opus 5 ce3a38f89d Propuesta consultiva con IA: rutas, panel de edicion y tercer documento
Cierra la funcionalidad: el Cotizador ahora ofrece tres descargas por cotizacion
—Excel, PDF economico y PDF consultivo con marca— mas un anexo interno aparte.

Rutas: POST genera, GET lee la ultima, PATCH guarda la edicion del asesor
(revalidando, porque una edicion manual puede introducir una fuga o un USD), y
GET /pdf descarga, con ?anexo=1 para el interno.

Panel: transcripcion opcional, avisos de validacion, campos editables (titulo,
subtitulo, hallazgos, alcance, exclusiones y beneficios) y las descargas.
Los avisos van deliberadamente ARRIBA de los botones de descarga: el objetivo es
que revisar sea mas facil que aprobar.

El anexo interno lleva la ponderacion: ratio precio/valor sobre el desembolso
del primer ano con IVA, su lectura (subcotizado / en rango / alto / objecion
probable), conteo de hallazgos y evidencia, red flags y notas para el asesor.
Va en archivo separado con banda roja "NO ENVIAR", nunca como seccion oculta.

Verificado con 6 suites (114 comprobaciones), typecheck, lint y build. Las mas
importantes son las estructurales: ningun schema declara un campo de dinero de
E3, ningun prompt filtra precios ni cuids, y el documento del cliente no
contiene notas internas ni red flags aunque se inyecten a la fuerza.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:25:39 -06:00
urieljarethandClaude Opus 5 91db8cc55c Propuesta IA: pipeline de tres pasos y generadores PDF
El documento del cliente sigue la arquitectura argumental de la plantilla de
referencia pero en tema claro con PDFKit: la plantilla original es oscura, y en
impresion eso depende de una casilla del navegador desactivada por defecto.

El anexo interno va en archivo separado, no como seccion oculta.

44 comprobaciones sobre PDFs reales: contenido presente, notas internas y red
flags ausentes del documento del cliente, precios inyectados por el codigo,
branding invalido tolerado y contenido largo multipagina.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:22:02 -06:00
urieljarethandClaude Opus 5 45f166c116 Propuesta IA: reglas de validacion R0-R6 y filtro de contenido
R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son
una ENTRADA que el modelo recibe en el prompt y puede copiar literalmente.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:17:30 -06:00
urieljarethandClaude Opus 5 7f375b3564 Propuesta IA: prompts en capas, capa 1 cacheable y sin fugas de dinero
Verificado que ni el precio ni el cuid de ServicioCotizado aparecen en ningun
prompt: el modelo solo ve refPartida. P1 deja de depender de una validacion.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:15:49 -06:00
urieljarethandClaude Opus 5 877a181a03 Propuesta IA: cliente MiniMax con tool-calling y reintentos
El contrato se fuerza con el input_schema de la herramienta porque MiniMax no
soporta structured outputs. tool_choice no se envia: fijarlo solo en el reintento
le daria un prefijo distinto y se pagaria el contexto completo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:14:02 -06:00
urieljarethandClaude Opus 5 5bad273977 Propuesta IA: schemas Zod de los tres pasos, sin campos de dinero de E3
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:12:54 -06:00
urieljarethandClaude Opus 5 4e103beb27 Propuesta IA: modelo PropuestaIA y capa de datos economicos
La capa de economia existe para que el dinero viva en un solo sitio y nunca
cruce hacia el prompt. El pipeline recibe de ahi solo refPartida y nombre.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:10:51 -06:00
27 changed files with 5396 additions and 1 deletions
+8
View File
@@ -28,3 +28,11 @@ SEED_ADMIN_NAME=Administrador
SEED_ASESOR_EMAIL= SEED_ASESOR_EMAIL=
SEED_ASESOR_PASSWORD= SEED_ASESOR_PASSWORD=
SEED_ASESOR_NAME= SEED_ASESOR_NAME=
# --- Propuesta consultiva con IA (MiniMax) ---
# Clave del panel de MiniMax. NUNCA se commitea: .env esta en .gitignore.
# Ojo con el prefijo: NO uses ANTHROPIC_API_KEY. El SDK la lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
MINIMAX_API_KEY=
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
MINIMAX_MODEL=MiniMax-M3
+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); });
+7
View File
@@ -40,6 +40,13 @@ services:
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_* # Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
# generar devuelve un error claro; el resto de la app funciona igual.
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue # Seed inicial: pon RUN_SEED=true solo en el primer despliegue
- RUN_SEED=${RUN_SEED:-false} - RUN_SEED=${RUN_SEED:-false}
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]} - SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
+7
View File
@@ -40,6 +40,13 @@ services:
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_* # Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
# generar devuelve un error claro; el resto de la app funciona igual.
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue # Seed inicial: pon RUN_SEED=true solo en el primer despliegue
- RUN_SEED=${RUN_SEED:-false} - RUN_SEED=${RUN_SEED:-false}
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]} - SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
File diff suppressed because it is too large Load Diff
+73 -1
View File
@@ -8,6 +8,7 @@
"name": "cotizador-e3", "name": "cotizador-e3",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -22,6 +23,7 @@
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"zod": "^4.3.6",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
@@ -51,6 +53,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -243,6 +266,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2233,6 +2265,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -5179,6 +5217,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -6364,6 +6408,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -8554,6 +8611,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -8890,6 +8957,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -9498,7 +9571,6 @@
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+1
View File
@@ -16,6 +16,7 @@
"seed": "npx tsx prisma/seed.ts" "seed": "npx tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -0,0 +1,26 @@
-- Propuesta consultiva con IA: tabla nueva.
-- ADITIVA. No toca ninguna tabla ni fila existente.
CREATE TABLE IF NOT EXISTS "PropuestaIA" (
"id" TEXT NOT NULL,
"cotizacionId" TEXT NOT NULL,
"estado" TEXT NOT NULL DEFAULT 'borrador',
"contenidoIA" JSONB NOT NULL,
"contenidoEditado" JSONB,
"traza" JSONB,
"avisos" JSONB NOT NULL DEFAULT '[]',
"modelo" TEXT NOT NULL,
"aprobadaPor" TEXT,
"aprobadaAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PropuestaIA_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "PropuestaIA_cotizacionId_idx" ON "PropuestaIA"("cotizacionId");
ALTER TABLE "PropuestaIA"
ADD CONSTRAINT "PropuestaIA_cotizacionId_fkey"
FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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;
+28
View File
@@ -66,6 +66,7 @@ model Cotizacion {
servicios ServicioCotizado[] servicios ServicioCotizado[]
planBucefalo PlanBucefaloCotizacion? planBucefalo PlanBucefaloCotizacion?
registrosHoras RegistroHoras[] registrosHoras RegistroHoras[]
propuestasIA PropuestaIA[]
@@index([clienteId]) @@index([clienteId])
@@index([asesorId]) @@index([asesorId])
@@ -220,6 +221,33 @@ model RegistroHoras {
@@index([estadoPago]) @@index([estadoPago])
} }
// Documento consultivo generado por IA a partir del contexto humano de la cotizacion.
// Se guarda por separado lo que produjo el modelo y lo que edito el asesor, para poder
// mostrar que cambio y para no perder el original al editar.
model PropuestaIA {
id String @id @default(cuid())
cotizacionId String
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.
avisos Json @default("[]")
modelo String
aprobadaPor String?
aprobadaAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
@@index([cotizacionId])
}
model Configuracion { model Configuracion {
id String @id @default(cuid()) id String @id @default(cuid())
clave String @unique clave String @unique
+70
View File
@@ -0,0 +1,70 @@
import { crearCliente, modelo, normalizarRespuesta } from "@/lib/propuesta/cliente-ia";
// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro.
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
const guardada = process.env.MINIMAX_API_KEY;
const guardadaBase = process.env.MINIMAX_BASE_URL;
delete process.env.MINIMAX_API_KEY;
let msg = "";
try {
crearCliente();
} catch (e) {
msg = e instanceof Error ? e.message : String(e);
}
check("sin clave falla en vez de llamar a ciegas", msg.length > 0);
check("el error nombra la variable correcta", msg.includes("MINIMAX_API_KEY"));
check("el error advierte del prefijo ANTHROPIC_", msg.includes("ANTHROPIC_API_KEY"));
process.env.MINIMAX_API_KEY = "clave-de-prueba";
delete process.env.MINIMAX_BASE_URL;
const c = crearCliente();
check("baseURL apunta a MiniMax por defecto", String(c.baseURL).includes("api.minimax.io"), String(c.baseURL));
check("modelo por defecto es MiniMax-M3", modelo() === "MiniMax-M3", modelo());
// Aunque el shell tenga ANTHROPIC_BASE_URL, el cliente no debe hacerle caso.
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
const c2 = crearCliente();
check("ANTHROPIC_BASE_URL del shell no secuestra el baseURL",
!String(c2.baseURL).includes("api.anthropic.com"), String(c2.baseURL));
delete process.env.ANTHROPIC_BASE_URL;
process.env.MINIMAX_BASE_URL = "https://ejemplo.local/anthropic";
check("MINIMAX_BASE_URL si se respeta", String(crearCliente().baseURL).includes("ejemplo.local"));
process.env.MINIMAX_MODEL = "MiniMax-M2";
check("MINIMAX_MODEL se respeta", modelo() === "MiniMax-M2");
delete process.env.MINIMAX_MODEL;
if (guardada) process.env.MINIMAX_API_KEY = guardada;
else delete process.env.MINIMAX_API_KEY;
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
else delete process.env.MINIMAX_BASE_URL;
// ── Normalizacion de rarezas de MiniMax, observadas contra la API real ──
const igual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
check("desenvuelve {item: X} cuando item es la unica clave",
igual(normalizarRespuesta({ item: { a: 1 } }), { a: 1 }));
check("desenvuelve dentro de arrays",
igual(normalizarRespuesta([{ item: { a: 1 } }, { item: { a: 2 } }]), [{ a: 1 }, { a: 2 }]));
check("desenvuelve en profundidad",
igual(normalizarRespuesta({ lista: [{ item: { x: [{ item: 5 }] } }] }), { lista: [{ x: [5] }] }));
check("NO toca un objeto con item junto a otras claves",
igual(normalizarRespuesta({ item: 1, otro: 2 }), { item: 1, otro: 2 }));
check("deja intactos los valores simples",
normalizarRespuesta("texto") === "texto" && normalizarRespuesta(5) === 5 && normalizarRespuesta(null) === null);
check("no altera un objeto normal",
igual(normalizarRespuesta({ a: 1, b: [1, 2] }), { a: 1, b: [1, 2] }));
check("el caso real observado: alcance envuelto",
igual(normalizarRespuesta({ alcance: [{ item: { refPartida: "P01", descripcionResultado: "x" } }] }),
{ alcance: [{ refPartida: "P01", descripcionResultado: "x" }] }));
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+26
View File
@@ -0,0 +1,26 @@
import { calcularRatio } from "@/lib/propuesta/economia";
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
check("sin valor anual no hay ratio", calcularRatio(100000, null) === null);
check("valor anual cero no divide entre cero", calcularRatio(100000, 0) === null);
check("valor anual negativo no produce ratio", calcularRatio(100000, -5) === null);
check("ratio 5% = subcotizado", calcularRatio(50000, 1000000)?.lectura === "subcotizado");
check("ratio 20% = en rango", calcularRatio(200000, 1000000)?.lectura === "en_rango");
check("ratio 35% = alto", calcularRatio(350000, 1000000)?.lectura === "alto");
check("ratio 60% = objecion probable", calcularRatio(600000, 1000000)?.lectura === "objecion_probable");
// Los cortes exactos: 0.10 entra en rango, 0.25 sigue en rango, 0.40 sigue alto.
check("corte 10% cae en rango", calcularRatio(100000, 1000000)?.lectura === "en_rango");
check("corte 25% cae en rango", calcularRatio(250000, 1000000)?.lectura === "en_rango");
check("corte 40% cae en alto", calcularRatio(400000, 1000000)?.lectura === "alto");
check("ratio redondeado a 3 decimales", calcularRatio(123456, 1000000)?.ratio === 0.123);
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+232
View File
@@ -0,0 +1,232 @@
import zlib from "node:zlib";
import { generarPropuestaPDF, generarAnexoInternoPDF, type DatosPropuestaPDF } from "@/lib/propuesta/pdf";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
import type { DatosEconomicos } from "@/lib/propuesta/economia";
/** Extrae el texto de un PDF de PDFKit. Con las fuentes base (Helvetica) no hay
* subsetting ni ToUnicode: el texto va como hex de UN byte por caracter dentro de
* streams comprimidos con Flate. */
function textoDePdf(pdf: Buffer): string {
const crudo = pdf.toString("latin1");
let salida = "";
const re = /stream\r?\n/g;
let m: RegExpExecArray | null;
while ((m = re.exec(crudo)) !== null) {
const ini = m.index + m[0].length;
const fin = crudo.indexOf("endstream", ini);
if (fin === -1) continue;
let bloque = "";
try {
bloque = zlib.inflateSync(Buffer.from(crudo.slice(ini, fin), "latin1")).toString("latin1");
} catch {
continue;
}
// PDFKit alterna dos codificaciones segun el contenido de la cadena:
// hex `<48656c6c6f>` y literal `(Hello) Tj`. Hay que leer las dos.
// OJO: no se puede meter un separador entre trozos hex. PDFKit aplica kerning y
// parte una misma palabra en varios elementos de un arreglo TJ
// (`[<4d41> -20 <524341444f52>]`), asi que un espacio de mas convierte
// "MARCADORVERDE" en "MARCA DORVERDE" y ninguna busqueda lo encuentra.
for (const t of bloque.matchAll(/<([0-9a-fA-F]{2,})>/g)) {
const hex = t[1];
for (let i = 0; i + 2 <= hex.length; i += 2) {
const b = parseInt(hex.slice(i, i + 2), 16);
if (b >= 32 && b < 256) salida += String.fromCharCode(b);
}
}
salida += " ";
for (const t of bloque.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
salida += t[1].replace(/\\([()\\])/g, "$1") + " ";
}
}
return salida;
}
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
const econ: DatosEconomicos = {
partidas: [
{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web institucional", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
{ refPartida: "P02", servicioCotizadoId: "c2", nombre: "Manejo de pauta", fase: 2, tipoPago: "mensual", precio: 8000, tiempoEntrega: "Mensual", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
],
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const propuesta: PropuestaConsultiva = {
hechos: {
citas: [{ id: "C01", textoLiteral: "MARCADORCITA nadie contesta el fin de semana", quienLoDijo: "el socio" }],
hechos: [{ id: "H01", enunciado: "Sin control de atencion", confianza: "confirmado", citas: ["C01"] }],
materialesPendientes: [{ texto: "MARCADORMATERIAL accesos al dominio", bloqueaEntrega: true }],
decisionesPendientes: [{ texto: "MARCADORDECISION definir responsable", quienDecide: "el socio" }],
mencionesFueraDeAlcance: ["MARCADORFUERA app movil"],
redFlags: [{ senal: "MARCADORFLAG pidio descuento antes de ver el alcance", severidad: "alta" }],
},
diagnostico: {
hallazgos: [
{ id: "D01", urgencia: "rojo", titulo: "MARCADORHALLAZGO", descripcion: "Se pierden prospectos cada fin de semana.", 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: {
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" }],
},
redaccion: {
hero: { titulo: "MARCADORTITULO", subtitulo: "MARCADORSUB ordenar la atencion para dejar de perder prospectos." },
citaDestacadaId: "C01",
alcance: [
{ refPartida: "P01", descripcionResultado: "MARCADORALCANCE un lugar a donde mandar prospectos." },
{ refPartida: "P02", descripcionResultado: "MARCADORALCANCE2 cada peso a las busquedas que compran." },
],
beneficios: [{ etiqueta: "Respuesta", texto: "MARCADORBENEFICIO cada mensaje con responsable.", hallazgoId: "D01" }],
exclusiones: [{ texto: "MARCADOREXCLUSION no incluye migrar el historico.", razon: "fase 2" }],
backlogEvolucion: [{ problema: "MARCADORBACKLOG automatizar reportes", momentoSugerido: "tras la fase 1" }],
notasInternas: ["MARCADORINTERNO el socio decide, no el que vino a la reunion"],
},
};
const datos: DatosPropuestaPDF = {
propuesta,
economia: econ,
cliente: "Cliente Prueba",
empresa: "Empresa SA",
asesor: "Asesor",
numero: "UJ2607TEST",
fecha: new Date("2026-07-29"),
vigencia: new Date("2026-08-19"),
proyecto: "MKT Digital",
branding: { colorPrimario: "#2563eb" },
};
async function main() {
const pdf = await generarPropuestaPDF(datos);
const txt = textoDePdf(pdf);
check("se genera el documento del cliente", pdf.length > 2000, `${pdf.length} bytes`);
for (const marca of [
"MARCADORTITULO", "MARCADORSUB", "MARCADORCITA", "MARCADORHALLAZGO", "MARCADORVERDE",
"MARCADORVALOR", "MARCADORMETODO", "MARCADORRESULTADO", "MARCADORALCANCE", "MARCADORALCANCE2",
"MARCADORBENEFICIO", "MARCADOREXCLUSION", "MARCADORMATERIAL", "MARCADORDECISION", "MARCADORBACKLOG",
]) {
check(`el documento del cliente incluye ${marca}`, txt.includes(marca));
}
// Lo que NUNCA debe salir al cliente.
check("NO lleva notas internas", !txt.includes("MARCADORINTERNO"));
check("NO lleva red flags", !txt.includes("MARCADORFLAG"));
check("NO lleva menciones fuera de alcance", !txt.includes("MARCADORFUERA"));
// El dinero lo pone el codigo, no la IA.
check("imprime el precio de P01", txt.includes("12,000"));
check("imprime el precio de P02", txt.includes("8,000"));
check("imprime el total unico con IVA", txt.includes("13,920"));
check("imprime la mensualidad con IVA", txt.includes("9,280"));
// El desglose debe cuadrar: lineas sin IVA + IVA como renglon propio.
check("imprime el subtotal sin IVA", txt.includes("Subtotal pago unico") && txt.includes("12,000"));
check("imprime el IVA como renglon propio", txt.includes("IVA 16%") && txt.includes("1,920"));
check("imprime el total con IVA", txt.includes("Total pago unico"));
check("la nota NO dice que las lineas incluyan IVA", !txt.includes("Importes con IVA incluido"));
check("la nota aclara que las partidas van sin IVA", txt.includes("no incluyen IVA"));
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
// Sin IVA no debe aparecer el renglon de IVA.
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
const txtSinIva = textoDePdf(sinIva);
check("sin IVA no se imprime el renglon de IVA", !txtSinIva.includes("IVA 16%"));
check("sin IVA el total lo declara", txtSinIva.includes("sin IVA"));
// El plan Bucefalo tiene que aparecer: esta en la cotizacion y en los otros dos exportadores.
const conPlan = await generarPropuestaPDF({ ...datos, economia: { ...econ, planBucefalo: { nivel: "premium", precio: 4500 } } });
const txtPlan = textoDePdf(conPlan);
check("el plan Bucefalo aparece en el documento", txtPlan.includes("Bucefalo"));
check("el plan aparece con su nivel", txtPlan.includes("Premium"));
check("el plan aparece con su precio mensual", txtPlan.includes("4,500"));
// Una partida que la IA invento no se dibuja (R2 ya la marco).
const conFantasma = await generarPropuestaPDF({
...datos,
propuesta: { ...propuesta, redaccion: { ...propuesta.redaccion, alcance: [...propuesta.redaccion.alcance, { refPartida: "P99", descripcionResultado: "MARCADORFANTASMA inventada" }] } },
});
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA"));
// Completitud: la cotizacion manda, no lo que la IA alcanzo a describir.
// Verificado con UJ2606UR001 en produccion: 58 partidas, la IA describio 40.
const econGrande = { ...econ, partidas: [...econ.partidas,
{ refPartida: "P03", servicioCotizadoId: "c3", nombre: "MARCADORNODESCRITA servicio extra", fase: 3, tipoPago: "unico" as const, precio: 5000, tiempoEntrega: "1 semana", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: ["Entregable A", "Entregable B"] },
] };
const conNoDescrita = await generarPropuestaPDF({ ...datos, economia: econGrande });
const txtND = textoDePdf(conNoDescrita);
check("una partida SIN descripcion de la IA igual aparece", txtND.includes("MARCADORNODESCRITA"));
check("y aparece con su precio", txtND.includes("5,000"));
check("y con su detalle del catalogo como respaldo", txtND.includes("Entregable A"));
// ── Anexo interno ──
const anexo = await generarAnexoInternoPDF(datos);
const txtA = textoDePdf(anexo);
check("se genera el anexo interno", anexo.length > 1000, `${anexo.length} bytes`);
check("el anexo se marca NO ENVIAR", txtA.includes("NO ENVIAR"));
check("el anexo lleva las notas internas", txtA.includes("MARCADORINTERNO"));
check("el anexo lleva las red flags", txtA.includes("MARCADORFLAG"));
check("el anexo lleva lo que quedo fuera", txtA.includes("MARCADORFUERA"));
// 125280 / 166400 = 75.3% -> objecion probable
check("el anexo calcula el ratio", txtA.includes("75.3"), "125280/166400");
check("el anexo interpreta el ratio", txtA.includes("objecion probable"));
check("el anexo sugiere responder con alcance", txtA.includes("no con descuento"));
check("el anexo desglosa de donde sale el valor anual", txtA.includes("De donde sale el valor anual"));
check("el anexo muestra los factores con su confianza", txtA.includes("confirmado") && txtA.includes("estimado"));
// R5b: una cifra sostenida solo por estimaciones debe llevar alerta visible.
const soloEstimado = await generarAnexoInternoPDF({
...datos,
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
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"));
// Robustez del branding: la API de configuracion no valida los valores.
for (const malo of ["</style><script>", "rojo", "#GGG", ""]) {
const r = await generarPropuestaPDF({ ...datos, branding: { colorPrimario: malo } });
check(`un color invalido (${JSON.stringify(malo)}) no rompe el PDF`, r.length > 2000);
}
const logoMalo = await generarPropuestaPDF({ ...datos, branding: { logoBase64: "no-es-base64-valido" } });
check("un logo invalido no rompe el PDF", logoMalo.length > 2000);
// Contenido largo: no debe perderse ni romper el pie de pagina.
const largo = await generarPropuestaPDF({
...datos,
propuesta: {
...propuesta,
diagnostico: {
...propuesta.diagnostico,
hallazgos: Array.from({ length: 30 }, (_, i) => ({
id: `D${String(i + 1).padStart(2, "0")}`, urgencia: "ambar" as const,
titulo: `Hallazgo ${i} MARCADORLARGO`, descripcion: "x".repeat(300),
confianza: "estimado" as const, citas: [], hechos: [],
})),
},
},
});
const txtL = textoDePdf(largo);
check("el contenido largo se imprime", txtL.includes("MARCADORLARGO"));
check("el contenido largo conserva las secciones posteriores", txtL.includes("MARCADOREXCLUSION"));
check("el contenido largo genera varias paginas", (largo.toString("latin1").match(/\/Type\s*\/Page[^s]/g) || []).length > 1);
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
}
main().catch((e) => {
console.error("Error inesperado:", e);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
import { SYSTEM_BASE, bloqueContexto, mensajePaso1, mensajePaso2, mensajePaso3 } from "@/lib/propuesta/prompts";
import type { DatosEconomicos } from "@/lib/propuesta/economia";
import type { Hechos, Diagnostico } 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++;
}
// ── La capa 1 tiene que ser 100% estable o el cache no sirve de nada ──
check("no contiene el anio actual", !SYSTEM_BASE.includes(String(new Date().getFullYear())));
check("no contiene una fecha ISO", !/\d{4}-\d{2}-\d{2}/.test(SYSTEM_BASE));
check("no contiene un numero de cotizacion", !/UJ\d{4}/.test(SYSTEM_BASE));
check("supera el minimo cacheable de 512 tokens", SYSTEM_BASE.length > 2500, `${SYSTEM_BASE.length} chars`);
// ── Los diez principios y el semaforo estan presentes ──
for (const p of ["P1.", "P2.", "P3.", "P4.", "P5.", "P6.", "P7.", "P8."]) {
check(`declara el principio ${p}`, SYSTEM_BASE.includes(p));
}
check("explica el semaforo completo",
["rojo:", "ambar:", "azul:", "verde:"].every((c) => SYSTEM_BASE.includes(c)));
check("justifica por que el verde importa", SYSTEM_BASE.includes("entre pares"));
// ── Reglas de marca ──
check("nombra Bucefalo", SYSTEM_BASE.includes("Bucefalo"));
check("prohibe otros CRM", SYSTEM_BASE.includes("ningun otro CRM"));
check("prohibe USD", SYSTEM_BASE.includes("USD"));
check("prohibe garantizar resultados", SYSTEM_BASE.includes("garantizado"));
check("prohibe lenguaje corporativo vacio", SYSTEM_BASE.includes("sinergias"));
check("restringe a servicios digitales", SYSTEM_BASE.includes("UNICAMENTE servicios digitales"));
check("prohibe datos de empleados", SYSTEM_BASE.includes("nombres de empleados"));
check("cubre el caso de material pobre", SYSTEM_BASE.includes("MATERIAL ES POBRE"));
check("dice que un hueco declarado es contenido de valor", SYSTEM_BASE.includes("contenido de valor"));
// ── El contexto degrada con gracia ──
const sinNada = bloqueContexto({ transcripcion: "", notas: "", observaciones: "", cliente: "Ana", empresa: "", proyecto: "P" });
check("sin transcripcion lo declara explicitamente", sinNada.includes("No se entrego transcripcion"));
check("sin empresa no imprime parentesis vacios", !sinNada.includes("()"));
const conTodo = bloqueContexto({ transcripcion: "TRANS", notas: "NOTAS", observaciones: "OBS", cliente: "Ana", empresa: "ACME", proyecto: "P" });
check("incluye la transcripcion", conTodo.includes("TRANS"));
check("incluye las notas por partida", conTodo.includes("NOTAS"));
check("incluye las observaciones", conTodo.includes("OBS"));
check("incluye la empresa entre parentesis", conTodo.includes("(ACME)"));
// ── Los tres mensajes piden su herramienta y no filtran dinero ──
const econ: DatosEconomicos = {
partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
const diag = { hallazgos: [], valorProblema: { dimensiones: [], notaMetodologia: "" }, resultados: [] } as unknown as Diagnostico;
const m1 = mensajePaso1(ctx);
const m2 = mensajePaso2(ctx, hechos, econ);
const m3 = mensajePaso3(ctx, hechos, diag, econ);
check("paso 1 pide registrar_hechos", m1.includes("registrar_hechos"));
check("paso 2 pide registrar_diagnostico", m2.includes("registrar_diagnostico"));
check("paso 3 pide registrar_propuesta", m3.includes("registrar_propuesta"));
check("paso 1 exige citas textuales", m1.includes("TEXTUALES"));
// P1 estructural: el precio y el cuid NO pueden aparecer en ningun prompt.
for (const [nombre, msg] of [["paso 2", m2], ["paso 3", m3]] as const) {
check(`${nombre} NO filtra el precio de la partida`, !msg.includes("12345"));
check(`${nombre} NO filtra el total`, !msg.includes("14320"));
check(`${nombre} NO filtra el cuid real`, !msg.includes("cuid-secreto-abc123"));
check(`${nombre} SI incluye la refPartida`, msg.includes("P01"));
}
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+94
View File
@@ -0,0 +1,94 @@
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),
factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], 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("con monto, exige al menos 2 factores",
!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], 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(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], factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 }] } }).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);
+123
View File
@@ -0,0 +1,123 @@
import { validarPropuesta, normalizar, hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
import type { DatosEconomicos } from "@/lib/propuesta/economia";
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
const tiene = (a: Aviso[], regla: string) => a.some((x) => x.regla === regla);
const CITA = "no llevamos control de quien contesta el whatsapp el fin de semana";
const econ: DatosEconomicos = {
partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const base = (): PropuestaConsultiva => ({
hechos: {
citas: [{ id: "C01", textoLiteral: CITA, quienLoDijo: "el socio" }],
hechos: [{ id: "H01", enunciado: "No hay control de la atencion", confianza: "confirmado", citas: ["C01"] }],
materialesPendientes: [{ texto: "Accesos al dominio", bloqueaEntrega: true }],
decisionesPendientes: [{ texto: "Definir responsable de atencion", quienDecide: "el socio" }],
mencionesFueraDeAlcance: [],
redFlags: [],
},
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", 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" }],
},
redaccion: {
hero: { titulo: "Propuesta de digitalizacion", subtitulo: "Ordenar la atencion para dejar de perder prospectos." },
citaDestacadaId: "C01",
alcance: [{ refPartida: "P01", descripcionResultado: "Un lugar a donde mandar a los prospectos que llegan de los anuncios." }],
beneficios: [{ etiqueta: "Respuesta", texto: "Cada mensaje queda registrado y con responsable.", hallazgoId: "D01" }],
exclusiones: [{ texto: "No incluye migrar el historico de pedidos de los ultimos 4 anos.", razon: "Se evalua en fase 2." }],
backlogEvolucion: [],
notasInternas: ["El socio es quien decide."],
},
});
const fuentes = { textoCliente: `El socio dijo: ${CITA}. Nada mas.`, textoInterno: "" };
check("normalizar quita acentos y puntuacion", normalizar("¿Como estas, Juan?") === "como estas juan");
check("normalizar colapsa espacios", normalizar(" a b ") === "a b");
const limpia = validarPropuesta(base(), econ, fuentes);
check("una propuesta valida no produce bloqueantes", !hayBloqueantes(limpia), JSON.stringify(limpia.slice(0, 2)));
let p = base(); p.redaccion.alcance[0].refPartida = "P99";
check("R2 detecta una partida inventada", tiene(validarPropuesta(p, econ, fuentes), "R2"));
p = base(); p.redaccion.alcance = [];
check("R2b avisa de una partida cotizada y no descrita", tiene(validarPropuesta(p, econ, fuentes), "R2b"));
p = base(); p.redaccion.citaDestacadaId = "C09";
check("R3 detecta una cita inexistente", tiene(validarPropuesta(p, econ, fuentes), "R3"));
p = base(); p.hechos.citas[0].textoLiteral = "esto nunca lo dijo nadie en la reunion";
check("R3 detecta una cita no verificable", tiene(validarPropuesta(p, econ, fuentes), "R3"));
p = base(); p.hechos.citas[0].textoLiteral = "NO LLEVAMOS CONTROL, de quien contesta... el WhatsApp el fin de semana!";
check("R3 tolera acentos, mayusculas y puntuacion", !tiene(validarPropuesta(p, econ, fuentes), "R3"));
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].montoAnualMXN = 999999;
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
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].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"));
p = base(); p.redaccion.beneficios[0].hallazgoId = "D99";
check("R6 detecta un beneficio colgante", tiene(validarPropuesta(p, econ, fuentes), "R6"));
p = base(); p.redaccion.beneficios[0].texto = "Lo integramos con HubSpot sin problema alguno.";
check("MARCA detecta otro CRM", tiene(validarPropuesta(p, econ, fuentes), "MARCA"));
p = base(); p.redaccion.hero.subtitulo = "Una inversion de 1,800 USD al mes para tu negocio.";
check("MONEDA detecta USD", tiene(validarPropuesta(p, econ, fuentes), "MONEDA"));
p = base(); p.redaccion.beneficios[0].texto = "Te garantizamos la posicion numero uno en Google.";
check("GARANTIA detecta la promesa", tiene(validarPropuesta(p, econ, fuentes), "GARANTIA"));
p = base(); p.redaccion.hero.subtitulo = "El sueldo de quien atiende sale muy caro hoy en dia.";
check("PII advierte sobre tema salarial", tiene(validarPropuesta(p, econ, fuentes), "PII"));
check("PII es advertencia, no bloqueante",
validarPropuesta(p, econ, fuentes).filter((a) => a.regla === "PII").every((a) => a.severidad === "advertencia"));
// ── R0: la fuga de notas internas, que es la razon de que exista ──
const interno = "el socio pidio descuento antes de entender el alcance del proyecto completo";
p = base(); p.redaccion.hero.subtitulo = `Contexto del proyecto: ${interno}.`;
check("R0 detecta la fuga de notas internas", tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
check("R0 es bloqueante",
validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }).filter((a) => a.regla === "R0").every((a) => a.severidad === "bloqueante"));
check("R0 no dispara si no hay fuga", !tiene(validarPropuesta(base(), econ, { ...fuentes, textoInterno: interno }), "R0"));
check("R0 sin notas internas no hace nada", !tiene(validarPropuesta(base(), econ, fuentes), "R0"));
// La fuga tambien se detecta en un hallazgo, no solo en el hero.
p = base(); p.diagnostico.hallazgos[0].descripcion = `Observado: ${interno}.`;
check("R0 mira todos los campos visibles, no solo el hero",
tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
// Las notasInternas del propio objeto NO deben disparar R0: no se imprimen.
p = base(); p.redaccion.notasInternas = [interno];
check("R0 no se queja de notasInternas (no se imprimen)",
!tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+3
View File
@@ -17,6 +17,7 @@ import { DeleteCotizacionButton } from "./DeleteButton";
import { CambiarEstadoButtons } from "./CambiarEstadoButtons"; import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
import { PreciosEditables } from "./PreciosEditables"; import { PreciosEditables } from "./PreciosEditables";
import { RegistroHorasPanel } from "./RegistroHorasPanel"; import { RegistroHorasPanel } from "./RegistroHorasPanel";
import PropuestaIAPanel from "@/components/PropuestaIAPanel";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -236,6 +237,8 @@ export default async function CotizacionDetailPage({
</div> </div>
)} )}
<PropuestaIAPanel cotizacionId={cot.id} numero={cot.numero} />
{/* Unico lugar donde se muestran las notas internas. No van a ningun {/* Unico lugar donde se muestran las notas internas. No van a ningun
exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */} exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */}
{cot.observacionesInternas && ( {cot.observacionesInternas && (
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getConfigBranding } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators";
import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
import { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente
* GET /api/propuesta-ia/:id/pdf?anexo=1 -> anexo interno (archivo aparte, a proposito) */
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const anexo = request.nextUrl.searchParams.get("anexo") === "1";
const [cot, fila, branding] = await Promise.all([
prisma.cotizacion.findUnique({ where: { id }, include: { cliente: true, asesor: true } }),
prisma.propuestaIA.findFirst({ where: { cotizacionId: id }, orderBy: { createdAt: "desc" } }),
getConfigBranding(),
]);
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
// Los avisos bloqueantes tienen que bloquear DE VERDAD el documento del cliente.
// Antes esta ruta ni siquiera leia `avisos`: la severidad "bloqueante" era
// decorativa y el PDF se descargaba igual con una fuga de notas internas o un
// monto en USD dentro. El anexo interno SI se permite: es justamente el que el
// asesor necesita para entender que hay que corregir.
const avisos = Array.isArray(fila.avisos) ? (fila.avisos as unknown as Aviso[]) : [];
if (!anexo && hayBloqueantes(avisos)) {
const cuales = avisos
.filter((a) => a.severidad === "bloqueante")
.map((a) => `${a.regla}${a.ruta ? ` (${a.ruta})` : ""}: ${a.mensaje}`);
return NextResponse.json(
{
error:
"La propuesta tiene avisos bloqueantes sin resolver. Corrigelos en el panel y guarda antes de descargar el documento del cliente.",
bloqueantes: cuales,
},
{ status: 409 }
);
}
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
const economia = await cargarEconomia(id);
const datos = {
propuesta,
economia,
cliente: cot.cliente.nombre,
empresa: cot.cliente.empresa || "",
asesor: cot.asesor.name,
numero: cot.numero,
fecha: cot.fecha,
vigencia: cot.vigencia,
proyecto: cot.proyecto,
branding,
};
const buffer = anexo ? await generarAnexoInternoPDF(datos) : await generarPropuestaPDF(datos);
const empresa = cot.cliente.empresa || cot.cliente.nombre;
const nombre = anexo
? `${sanitizeFilename(empresa)} - ${cot.numero} - INTERNO NO ENVIAR`
: `${sanitizeFilename(empresa)} - ${cot.numero} - Propuesta consultiva`;
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+154
View File
@@ -0,0 +1,154 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuesta } from "@/lib/propuesta/pipeline";
import { validarPropuesta } from "@/lib/propuesta/validacion";
import { propuestaCompletaSchema, type PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** Reune el contexto humano de la cotizacion. Distingue lo que ve el cliente de lo
* interno: el interno se manda al modelo (necesita el contexto) pero R0 comprueba
* despues que no se haya colado a la salida. */
async function contextoDe(cotizacionId: string) {
const cot = await prisma.cotizacion.findUnique({
where: { id: cotizacionId },
include: { cliente: true, asesor: true, servicios: true },
});
if (!cot) return null;
const notasPartidas = cot.servicios
.map((s) => s.notas)
.filter((n): n is string => Boolean(n && n.trim()))
.join("\n");
return { cot, notasPartidas };
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const transcripcion: string = typeof body.transcripcion === "string" ? body.transcripcion : "";
const ctx = await contextoDe(id);
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(
{ error: "La cotizacion no tiene partidas seleccionadas. Agrega servicios antes de generar la propuesta." },
{ status: 400 }
);
}
const { propuesta, traza } = await generarPropuesta({
entrada: {
transcripcion,
notas: notasPartidas,
observaciones: cot.observaciones || "",
cliente: cot.cliente.nombre,
empresa: cot.cliente.empresa || "",
proyecto: cot.proyecto,
},
economia,
});
const avisos = validarPropuesta(propuesta, economia, {
textoCliente: [transcripcion, cot.observaciones || "", notasPartidas].join("\n"),
textoInterno: cot.observacionesInternas || "",
});
const fila = await prisma.propuestaIA.create({
data: {
cotizacionId: id,
contenidoIA: propuesta as unknown as object,
traza: traza as unknown as object,
avisos: avisos as unknown as object,
modelo: traza.modelo,
transcripcion: transcripcion || null,
},
});
return NextResponse.json({ id: fila.id, propuesta, avisos, traza });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const fila = await prisma.propuestaIA.findFirst({
where: { cotizacionId: id },
orderBy: { createdAt: "desc" },
});
if (!fila) return NextResponse.json({ propuesta: null });
return NextResponse.json({
id: fila.id,
propuesta: fila.contenidoEditado ?? fila.contenidoIA,
editada: fila.contenidoEditado !== null,
avisos: fila.avisos,
traza: fila.traza,
createdAt: fila.createdAt,
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const parsed = propuestaCompletaSchema.safeParse(body.propuesta);
if (!parsed.success) {
return NextResponse.json({ error: z.prettifyError(parsed.error) }, { status: 400 });
}
const fila = await prisma.propuestaIA.findFirst({
where: { cotizacionId: id },
orderBy: { createdAt: "desc" },
});
if (!fila) return NextResponse.json({ error: "No hay propuesta generada para esta cotizacion" }, { status: 404 });
// 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: [fila.transcripcion || "", ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
textoInterno: ctx?.cot.observacionesInternas || "",
});
await prisma.propuestaIA.update({
where: { id: fila.id },
data: {
contenidoEditado: parsed.data as unknown as object,
avisos: avisos as unknown as object,
},
});
return NextResponse.json({ ok: true, avisos });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+590
View File
@@ -0,0 +1,590 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Sparkles, Download, Save, AlertTriangle, Info, Lock, RefreshCw, ChevronDown, ChevronRight } from "lucide-react";
import { useToast, useConfirm } from "@/components/ui/DialogProvider";
// Panel de la propuesta consultiva: generar, revisar, editar y descargar.
//
// Regla de diseno deliberada: los avisos van ARRIBA de los botones de descarga.
// El objetivo es que revisar sea mas facil que aprobar, no al reves.
type Confianza = "confirmado" | "estimado" | "por_validar";
type Urgencia = "rojo" | "ambar" | "azul" | "verde";
interface Aviso {
regla: string;
severidad: "bloqueante" | "advertencia";
mensaje: string;
ruta?: string;
}
interface Propuesta {
hechos: {
citas: { id: string; textoLiteral: string; quienLoDijo: string }[];
hechos: { id: string; enunciado: string; confianza: Confianza; citas: string[] }[];
materialesPendientes: { texto: string; bloqueaEntrega: boolean }[];
decisionesPendientes: { texto: string; quienDecide: string }[];
mencionesFueraDeAlcance: string[];
redFlags: { senal: string; severidad: string }[];
};
diagnostico: {
hallazgos: { id: string; urgencia: Urgencia; titulo: string; descripcion: string; confianza: Confianza; citas: string[]; hechos: string[] }[];
valorProblema: {
dimensiones: { tipo: string; descripcion: string; calculo: { factores: { nombre: string; valor: number }[]; montoAnualMXN: number | null }; confianza: Confianza; hechos: string[] }[];
notaMetodologia: string;
};
resultados: { enunciado: string; metrica: string; lineaBase: string | null; periodoMedicion: string }[];
};
redaccion: {
hero: { titulo: string; subtitulo: string };
citaDestacadaId: string;
alcance: { refPartida: string; descripcionResultado: string }[];
beneficios: { etiqueta: string; texto: string; hallazgoId: string }[];
exclusiones: { texto: string; razon: string }[];
backlogEvolucion: { problema: string; momentoSugerido: string }[];
notasInternas: string[];
};
}
const COLOR_URGENCIA: Record<Urgencia, string> = {
rojo: "bg-red-500",
ambar: "bg-amber-500",
azul: "bg-blue-500",
verde: "bg-green-500",
};
const ETIQUETA_URGENCIA: Record<Urgencia, string> = {
rojo: "Problema critico",
ambar: "Area de mejora",
azul: "Oportunidad",
verde: "Ventaja existente",
};
const INPUT = "w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary";
export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: string; numero?: string }) {
const toast = useToast();
const confirm = useConfirm();
const [abierto, setAbierto] = useState(false);
const [cargando, setCargando] = useState(true);
const [generando, setGenerando] = useState(false);
const [guardando, setGuardando] = useState(false);
const [transcripcion, setTranscripcion] = useState("");
const [propuesta, setPropuesta] = useState<Propuesta | null>(null);
const [avisos, setAvisos] = useState<Aviso[]>([]);
const [editada, setEditada] = useState(false);
const [sucio, setSucio] = useState(false);
const cargar = useCallback(async () => {
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`);
const d = await r.json();
if (d.propuesta) {
setPropuesta(d.propuesta);
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(Boolean(d.editada));
setAbierto(true);
}
} finally {
setCargando(false);
}
}, [cotizacionId]);
useEffect(() => {
void cargar();
}, [cargar]);
async function generar() {
if (propuesta) {
const ok = await confirm({
title: "Regenerar la propuesta",
message: "Se generara una propuesta nueva. La version actual y tus ediciones quedaran reemplazadas.",
confirmText: "Regenerar",
danger: true,
});
if (!ok) return;
}
setGenerando(true);
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ transcripcion }),
});
const d = await r.json();
if (!r.ok) {
toast(d.error || "No se pudo generar la propuesta", "error");
return;
}
setPropuesta(d.propuesta);
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(false);
setSucio(false);
setAbierto(true);
const bloqueantes = (d.avisos as Aviso[]).filter((a) => a.severidad === "bloqueante").length;
toast(
bloqueantes > 0
? `Propuesta generada con ${bloqueantes} aviso(s) que debes resolver antes de enviarla.`
: "Propuesta generada. Revisala antes de enviarla.",
bloqueantes > 0 ? "error" : "success"
);
} catch (e) {
toast(e instanceof Error ? e.message : "Error al generar", "error");
} finally {
setGenerando(false);
}
}
async function guardar() {
if (!propuesta) return;
setGuardando(true);
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ propuesta }),
});
const d = await r.json();
if (!r.ok) {
toast(typeof d.error === "string" ? d.error : "No se pudo guardar", "error");
return;
}
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(true);
setSucio(false);
toast("Cambios guardados y revalidados.", "success");
} finally {
setGuardando(false);
}
}
/** Aplica un cambio al objeto y marca el panel como sucio. */
function editar(fn: (p: Propuesta) => void) {
setPropuesta((prev) => {
if (!prev) return prev;
const copia = structuredClone(prev) as Propuesta;
fn(copia);
return copia;
});
setSucio(true);
}
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 (
<div className="bg-card-bg rounded-xl border border-border p-5">
<p className="text-sm text-muted">Cargando propuesta consultiva...</p>
</div>
);
}
return (
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
<button
onClick={() => setAbierto((v) => !v)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-gray-50"
>
<span className="flex items-center gap-2 font-semibold">
<Sparkles className="w-4 h-4 text-primary" />
Propuesta consultiva con IA
{editada && (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary-light text-primary">
editada por ti
</span>
)}
</span>
{abierto ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
{abierto && (
<div className="px-5 pb-5 space-y-5 border-t border-border pt-5">
{/* ── Entrada ── */}
<div>
<label className="block text-sm font-medium mb-1">Transcripcion de la reunion (opcional)</label>
<p className="text-xs text-muted mb-2">
Pega aqui lo que se dijo en la reunion de discovery. Sin transcripcion, la IA trabaja
solo con las observaciones y notas de la cotizacion, y marcara mas cosas por validar.
</p>
<textarea
value={transcripcion}
onChange={(e) => setTranscripcion(e.target.value)}
rows={4}
placeholder="Pega la transcripcion..."
className={INPUT}
/>
<button
onClick={generar}
disabled={generando}
className="mt-3 flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-50"
>
{generando ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{generando ? "Generando (tarda un par de minutos)..." : propuesta ? "Regenerar" : "Generar propuesta"}
</button>
</div>
{propuesta && (
<>
{/* ── Avisos: van ARRIBA de las descargas a proposito ── */}
{bloqueantes.length > 0 && (
<div className="rounded-lg border border-red-300 bg-red-50 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-red-800 mb-2">
<AlertTriangle className="w-4 h-4" />
{bloqueantes.length} aviso(s) que debes resolver antes de enviar
</p>
<ul className="space-y-1">
{bloqueantes.map((a, i) => (
<li key={i} className="text-xs text-red-800">
<span className="font-mono font-semibold">{a.regla}</span>
{a.ruta && <span className="text-red-600"> · {a.ruta}</span>} {a.mensaje}
</li>
))}
</ul>
</div>
)}
{advertencias.length > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-amber-900 mb-2">
<Info className="w-4 h-4" />
{advertencias.length} cosa(s) que conviene revisar
</p>
<ul className="space-y-1">
{advertencias.map((a, i) => (
<li key={i} className="text-xs text-amber-900">
<span className="font-mono font-semibold">{a.regla}</span>
{a.ruta && <span className="opacity-70"> · {a.ruta}</span>} {a.mensaje}
</li>
))}
</ul>
</div>
)}
{avisos.length === 0 && (
<div className="rounded-lg border border-green-300 bg-green-50 p-3">
<p className="text-sm text-green-800">Sin avisos de validacion. Revisa el contenido de todas formas.</p>
</div>
)}
{/* ── Edicion ── */}
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Titulo</label>
<input
value={propuesta.redaccion.hero.titulo}
onChange={(e) => editar((p) => { p.redaccion.hero.titulo = e.target.value; })}
className={INPUT}
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Subtitulo</label>
<textarea
value={propuesta.redaccion.hero.subtitulo}
onChange={(e) => editar((p) => { p.redaccion.hero.subtitulo = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Hallazgos del diagnostico
</label>
<div className="space-y-3">
{propuesta.diagnostico.hallazgos.map((h, i) => (
<div key={h.id} className="border border-border rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`w-2.5 h-2.5 rounded-full ${COLOR_URGENCIA[h.urgencia]}`} />
<span className="text-[11px] text-muted">
{ETIQUETA_URGENCIA[h.urgencia]} · {h.confianza.replace("_", " ")}
</span>
</div>
<input
value={h.titulo}
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].titulo = e.target.value; })}
className={`${INPUT} mb-2 font-medium`}
/>
<textarea
value={h.descripcion}
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].descripcion = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Alcance (la descripcion; los precios los pone el sistema)
</label>
<div className="space-y-2">
{propuesta.redaccion.alcance.map((a, i) => (
<div key={a.refPartida} className="flex gap-2 items-start">
<span className="text-xs font-mono text-muted mt-2.5 shrink-0">{a.refPartida}</span>
<textarea
value={a.descripcionResultado}
onChange={(e) => editar((p) => { p.redaccion.alcance[i].descripcionResultado = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">Exclusiones</label>
<div className="space-y-2">
{propuesta.redaccion.exclusiones.map((ex, i) => (
<textarea
key={i}
value={ex.texto}
onChange={(e) => editar((p) => { p.redaccion.exclusiones[i].texto = e.target.value; })}
rows={2}
className={INPUT}
/>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Por que tiene sentido
</label>
<div className="space-y-2">
{propuesta.redaccion.beneficios.map((b, i) => (
<div key={i} className="flex gap-2 items-start">
<input
value={b.etiqueta}
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].etiqueta = e.target.value; })}
className={`${INPUT} max-w-[9rem] shrink-0`}
/>
<textarea
value={b.texto}
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].texto = e.target.value; })}
rows={2}
className={INPUT}
/>
</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 className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<button
onClick={guardar}
disabled={!sucio || guardando}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-40"
>
<Save className="w-4 h-4" />
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
</button>
{/* 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`}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-red-300 bg-red-50 text-red-800 hover:bg-red-100"
title="Contiene ponderacion, red flags y notas internas"
>
<Lock className="w-4 h-4" />
Anexo interno NO ENVIAR
</a>
{sucio && (
<span className="text-xs text-amber-700">
Tienes cambios sin guardar. El PDF se genera con lo ultimo guardado.
</span>
)}
</div>
</>
)}
</div>
)}
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { aJsonSchema } from "./schemas";
/**
* Cliente del proveedor de IA (MiniMax-M3 via su endpoint compatible con Anthropic)
* y runner que fuerza el contrato con tool-calling.
*
* MiniMax NO soporta structured outputs (`output_config` / `json_schema`), asi que
* el `input_schema` de la herramienta ES el contrato, y Zod valida siempre del lado
* del codigo. `tool_choice` no se envia: no esta documentado en MiniMax, y fijarlo
* solo en el reintento le daria al reintento un prefijo distinto — se pagaria el
* contexto completo justo cuando es mas grande.
*/
export interface UsoTokens {
entrada: number;
salida: number;
cacheLectura: number;
cacheEscritura: number;
}
/**
* Normaliza rarezas observadas en las respuestas de MiniMax antes de validar.
*
* Observado contra la API real, sobre datos de produccion: a veces envuelve los
* elementos de un array en un objeto `{item: {...}}` en vez de emitir el objeto
* directamente. Zod lo rechaza con `Unrecognized keys: "item"` y se gasta un
* reintento en algo que se puede corregir aqui sin ambiguedad.
*
* Solo desenvuelve cuando `item` es la UNICA clave: si el objeto trae mas cosas,
* podria ser un campo legitimo y no se toca.
*/
export function normalizarRespuesta(v: unknown): unknown {
if (Array.isArray(v)) return v.map(normalizarRespuesta);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const claves = Object.keys(o);
if (claves.length === 1 && claves[0] === "item") return normalizarRespuesta(o.item);
const salida: Record<string, unknown> = {};
for (const k of claves) salida[k] = normalizarRespuesta(o[k]);
return salida;
}
return v;
}
export function crearCliente(): Anthropic {
const apiKey = process.env.MINIMAX_API_KEY;
if (!apiKey) {
throw new Error(
"Falta MINIMAX_API_KEY. Definela en .env. No uses ANTHROPIC_API_KEY: el SDK la " +
"leeria por su cuenta y mandaria la clave de MiniMax a api.anthropic.com."
);
}
// apiKey y baseURL explicitos. Si se dejan al SDK, toma las ANTHROPIC_* del shell
// y la clave termina en el proveedor equivocado.
return new Anthropic({
apiKey,
baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/anthropic",
});
}
export function modelo(): string {
return process.env.MINIMAX_MODEL || "MiniMax-M3";
}
export interface BloqueSystem {
texto: string;
cachear: boolean;
}
/**
* ¿El fallo es del proveedor y vale la pena reintentar?
*
* Motivacion: en produccion aparecio un "unexpected doc type" que NO se pudo reproducir
* en seis corridas completas del pipeline contra los mismos datos, y la generacion que
* lo siguio completo sin problema. Todo apunta a un fallo transitorio del proveedor.
* En vez de adivinar un arreglo para un error que no se puede reproducir, se reintenta
* esa clase de fallo con espera creciente.
*/
function esTransitorio(e: unknown): boolean {
const err = e as { status?: number; message?: string };
if (typeof err?.status === "number") {
// 408 timeout, 409 conflicto, 429 rate limit, 5xx y el 529 de sobrecarga.
if ([408, 409, 429].includes(err.status) || err.status >= 500) return true;
// 400 con mensaje que no describe un problema de nuestro payload: el proveedor
// devuelve errores de parseo internos con 400. Se reintenta una vez por si acaso.
if (err.status === 400 && /unexpected|internal|parse|unknown/i.test(err.message ?? "")) return true;
}
// Fallos de red sin status.
if (!err?.status && /ECONN|ETIMEDOUT|socket|network|fetch failed/i.test(err?.message ?? "")) return true;
return false;
}
const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Llama al proveedor reintentando SOLO los fallos transitorios. Los errores de
* nuestro payload no se reintentan aqui: los corrige el bucle de schema. */
async function crearMensajeConReintentos(
cliente: Anthropic,
cuerpo: Anthropic.MessageCreateParamsNonStreaming,
maxTransitorios = 3
): Promise<Anthropic.Message> {
let ultimo: unknown;
for (let i = 1; i <= maxTransitorios; i++) {
try {
return await cliente.messages.create(cuerpo);
} catch (e) {
ultimo = e;
if (!esTransitorio(e) || i === maxTransitorios) throw e;
await esperar(1000 * 2 ** (i - 1)); // 1s, 2s
}
}
throw ultimo;
}
interface OpcionesLlamada {
system: BloqueSystem[];
mensajeUsuario: string;
herramienta: { nombre: string; descripcion: string; schema: z.ZodType };
maxIntentos: number;
maxTokens: number;
}
export async function llamarConHerramienta<T>(
opts: OpcionesLlamada
): Promise<{ datos: T; uso: UsoTokens; intentos: number }> {
const cliente = crearCliente();
const tools = [
{
name: opts.herramienta.nombre,
description: opts.herramienta.descripcion,
input_schema: aJsonSchema(opts.herramienta.schema) as Anthropic.Tool["input_schema"],
},
];
const system = opts.system.map((b) => ({
type: "text" as const,
text: b.texto,
...(b.cachear ? { cache_control: { type: "ephemeral" as const } } : {}),
}));
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: opts.mensajeUsuario }];
const uso: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
let ultimoError = "";
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
const res = await crearMensajeConReintentos(cliente, {
model: modelo(),
max_tokens: opts.maxTokens,
system,
tools,
messages: mensajes,
});
const u = res.usage as {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
uso.entrada += u.input_tokens ?? 0;
uso.salida += u.output_tokens ?? 0;
uso.cacheLectura += u.cache_read_input_tokens ?? 0;
uso.cacheEscritura += u.cache_creation_input_tokens ?? 0;
const bloquesTool = res.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
// El modelo a veces emite DOS tool_use en una misma respuesta (observado contra la
// API real). Se prueban todos los candidatos y gana el primero que valide, en vez
// de quedarse con el primero a secas y desperdiciar un reintento.
const candidatos = bloquesTool.filter((b) => b.name === opts.herramienta.nombre);
let correcto: Anthropic.ToolUseBlock | undefined;
for (const c of candidatos) {
const intentoParse = opts.herramienta.schema.safeParse(normalizarRespuesta(c.input));
if (intentoParse.success) {
return { datos: intentoParse.data as T, uso, intentos: intento };
}
// Se guarda el primero para reportar su error si ninguno valida.
if (!correcto) {
correcto = c;
ultimoError = z.prettifyError(intentoParse.error);
}
}
if (correcto) {
// Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar.
// Un turno de usuario plano despues de un tool_use devuelve 400.
mensajes.push(
{ role: "assistant", content: res.content },
{
role: "user",
content: [
...bloquesTool.map((b) => ({
type: "tool_result" as const,
tool_use_id: b.id,
is_error: true,
content:
b.id === correcto.id
? ultimoError
: `Herramienta inesperada. Usa ${opts.herramienta.nombre}.`,
})),
{
type: "text" as const,
text:
`La llamada no cumple el schema. Corrige EXACTAMENTE estos errores y vuelve a ` +
`llamar a ${opts.herramienta.nombre}:\n\n${ultimoError}\n\n` +
`Revisa que cada objeto este en el array que le corresponde y que no falte ` +
`ningun campo obligatorio. No agregues campos que el schema no declara.`,
},
],
}
);
continue;
}
// No llamo a ninguna herramienta: aqui si va un turno de usuario plano.
ultimoError = "El modelo respondio con prosa en vez de llamar a la herramienta.";
mensajes.push(
{ role: "assistant", content: res.content },
{
role: "user",
content: `Debes responder llamando a la herramienta ${opts.herramienta.nombre}. No escribas prosa suelta.`,
}
);
}
throw new Error(
`El paso "${opts.herramienta.nombre}" agoto ${opts.maxIntentos} intentos.\nUltimo error:\n${ultimoError}`
);
}
+113
View File
@@ -0,0 +1,113 @@
import { prisma } from "@/lib/db";
import { calcularTotalesCotizacion, type TotalesCotizacion } from "@/lib/calculators";
/**
* Capa de datos economicos de la propuesta consultiva.
*
* Existe para que el dinero viva en UN solo sitio y nunca cruce hacia el prompt.
* El pipeline de IA recibe de aqui unicamente `refPartida` y `nombre`; los importes
* los inyecta el renderizador. Asi el principio "la IA no toca los numeros" lo
* garantiza el compilador y no una regla que alguien pueda olvidar validar.
*/
export interface PartidaCanonica {
/** P01, P02... Es lo unico que identifica una partida ante el modelo. */
refPartida: string;
/** cuid real de ServicioCotizado. NUNCA sale hacia el proveedor: no es estable
* entre ediciones, porque el PUT hace deleteMany + createMany. */
servicioCotizadoId: string;
nombre: string;
fase: number;
tipoPago: string;
precio: number;
tiempoEntrega: string;
modeloCobro: string;
horas: number | null;
tarifaHora: number | null;
entregables: string[];
}
export interface DatosEconomicos {
partidas: PartidaCanonica[];
totales: TotalesCotizacion;
moneda: string;
/** Plan Bucefalo contratado, si lo hay. Se cobra mensual y aparece en el PDF
* economico y en el Excel; tiene que aparecer tambien en el consultivo o el
* cliente recibe dos documentos con alcances distintos. */
planBucefalo: { nivel: string; precio: number } | null;
}
export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> {
const cot = await prisma.cotizacion.findUnique({
where: { id: cotizacionId },
include: {
servicios: {
include: { servicioCatalogo: true },
// Mismo orden que los exportadores: los tres documentos van en el mismo
// correo y deben listar las partidas igual.
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
},
planBucefalo: true,
},
});
if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`);
const activos = cot.servicios.filter((s) => s.seleccionado);
const partidas: PartidaCanonica[] = activos.map((s, i) => ({
refPartida: `P${String(i + 1).padStart(2, "0")}`,
servicioCotizadoId: s.id,
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
fase: s.fase,
tipoPago: s.tipoPago,
precio: s.precio,
tiempoEntrega: s.tiempoEntrega,
modeloCobro: s.modeloCobro,
horas: s.horas,
tarifaHora: s.tarifaHora,
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
}));
const plan =
cot.planBucefalo && cot.planBucefalo.seleccionado
? { nivel: cot.planBucefalo.nivel, precio: cot.planBucefalo.precio }
: null;
// El plan Bucefalo se cobra mensual: entra a los totales como una partida mensual
// mas, igual que en el PDF economico.
const paraTotales = plan
? [...activos, { tipoPago: "mensual", precio: plan.precio, seleccionado: true }]
: activos;
return {
partidas,
totales: calcularTotalesCotizacion(paraTotales, { incluirIva: cot.incluirIva }),
moneda: cot.moneda,
planBucefalo: plan,
};
}
export type LecturaRatio = "subcotizado" | "en_rango" | "alto" | "objecion_probable";
/**
* Ratio precio/valor para el anexo interno.
*
* Base: el desembolso real del primer ano CON IVA (unico + mensual x 12). Se eligio
* asi para que el numerador y el denominador tengan las mismas unidades: comparar un
* pago inicial contra un valor ANUAL del problema daria una lectura optimista falsa.
*/
export function calcularRatio(
totalPrimerAnio: number,
valorAnual: number | null
): { ratio: number; lectura: LecturaRatio } | null {
if (!valorAnual || valorAnual <= 0) return null;
const ratio = totalPrimerAnio / valorAnual;
const lectura: LecturaRatio =
ratio < 0.1
? "subcotizado"
: ratio <= 0.25
? "en_rango"
: ratio <= 0.4
? "alto"
: "objecion_probable";
return { ratio: Math.round(ratio * 1000) / 1000, lectura };
}
+551
View File
@@ -0,0 +1,551 @@
import PDFDocument from "pdfkit";
import { formatCurrency, formatDate } from "@/lib/calculators";
import { calcularRatio, type DatosEconomicos } from "./economia";
import type { PropuestaConsultiva } from "./schemas";
/**
* Generador del documento consultivo.
*
* Sigue la arquitectura argumental de la plantilla de referencia (hero, diagnostico con
* semaforo, valor del problema, resultados, alcance, beneficios, exclusiones, pendientes,
* backlog) pero en tema claro y con PDFKit, como el resto del sistema: la plantilla
* original es de tema oscuro, y eso en impresion depende de una casilla del navegador
* que viene desactivada — el cliente recibiria texto crema sobre papel blanco.
*
* El anexo interno va en SU PROPIO archivo, nunca como seccion oculta del documento del
* cliente. Un display:none o una pagina extra se envian por error; un archivo con otro
* nombre, no.
*/
export interface DatosPropuestaPDF {
propuesta: PropuestaConsultiva;
economia: DatosEconomicos;
cliente: string;
empresa: string;
asesor: string;
numero: string;
fecha: Date;
vigencia: Date;
proyecto: string;
branding: {
colorPrimario?: string;
colorSecundario?: string;
logoBase64?: string;
logoMime?: string;
};
}
const COLOR_SEMAFORO: Record<string, string> = {
rojo: "#dc2626",
ambar: "#d97706",
azul: "#2563eb",
verde: "#16a34a",
};
const ETIQUETA_SEMAFORO: Record<string, string> = {
rojo: "Problema critico",
ambar: "Area de mejora",
azul: "Oportunidad",
verde: "Ventaja existente",
};
const ETIQUETA_CONFIANZA: Record<string, string> = {
confirmado: "Confirmado",
estimado: "Estimado",
por_validar: "Por validar",
};
/** `PUT /api/configuracion` filtra las claves permitidas pero NO valida los valores,
* asi que aqui no se confia en ellos. */
function hexSeguro(v: string | undefined, porDefecto: string): string {
return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto;
}
/** Respaldo cuando la IA no describio una partida: se arma con los datos del
* catalogo, para que la partida nunca desaparezca del documento. */
function detalleDePartida(p: { tiempoEntrega: string; entregables: string[]; tipoPago: string }): string {
const trozos: string[] = [];
if (p.entregables.length) trozos.push(p.entregables.slice(0, 4).join(" · "));
if (p.tiempoEntrega) trozos.push(`Entrega: ${p.tiempoEntrega}`);
if (p.tipoPago === "mensual") trozos.push("Servicio mensual");
return trozos.join(" | ");
}
interface Lienzo {
doc: PDFKit.PDFDocument;
W: number;
L: number;
ph: number;
maxY: number;
mTop: number;
}
function crearLienzo(): { lienzo: Lienzo; listo: Promise<Buffer> } {
const doc = new PDFDocument({
size: "LETTER",
margins: { top: 50, bottom: 55, left: 50, right: 50 },
bufferPages: true,
});
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const listo = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
const m = doc.page.margins;
return {
lienzo: {
doc,
W: doc.page.width - m.left - m.right,
L: m.left,
ph: doc.page.height,
maxY: doc.page.height - m.bottom - 5,
mTop: m.top,
},
listo,
};
}
export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer> {
const PRIMARY = hexSeguro(d.branding.colorPrimario, "#2563eb");
const DARK = hexSeguro(d.branding.colorSecundario, "#1e293b");
const MUTED = "#64748b";
const BORDER = "#e2e8f0";
const { lienzo, listo } = crearLienzo();
const { doc, W, L, ph, maxY, mTop } = lienzo;
let y = mTop;
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
const txtH = (s: string, w: number, size: number) =>
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
function titulo(t: string) {
y = need(26, y);
doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(t.toUpperCase(), L + 10, y);
y += 20;
}
function parrafo(t: string, size = 9, color = DARK) {
for (const p of t.split(/\r?\n/).map((x) => x.trim()).filter(Boolean)) {
const h = txtH(p, W, size);
y = need(h + 4, y);
doc.font("Helvetica").fontSize(size).fillColor(color).text(p, L, y, { width: W });
y += h + 4;
}
}
// ── HERO ──
if (d.branding.logoBase64) {
try {
doc.image(Buffer.from(d.branding.logoBase64, "base64"), L, y, { height: 26 });
y += 34;
} catch {
/* logo invalido: se omite, no se rompe el documento */
}
}
doc.font("Helvetica-Bold").fontSize(8.5).fillColor(PRIMARY).text("PROPUESTA CONSULTIVA", L, y);
y += 16;
const hTit = txtH(d.propuesta.redaccion.hero.titulo, W, 20);
doc.font("Helvetica-Bold").fontSize(20).fillColor(DARK).text(d.propuesta.redaccion.hero.titulo, L, y, { width: W });
y += hTit + 8;
parrafo(d.propuesta.redaccion.hero.subtitulo, 10, MUTED);
y += 8;
const meta = `${d.empresa || d.cliente} · ${d.numero} · ${formatDate(d.fecha)} · Vigencia ${formatDate(d.vigencia)} · ${d.asesor}`;
const hMeta = txtH(meta, W, 8);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(meta, L, y, { width: W });
y += hMeta + 12;
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
y += 18;
// ── DIAGNOSTICO ──
titulo("Diagnostico");
const cita = d.propuesta.hechos.citas.find((c) => c.id === d.propuesta.redaccion.citaDestacadaId);
if (cita) {
const texto = `"${cita.textoLiteral}"`;
const h = doc.font("Helvetica-Oblique").fontSize(11).heightOfString(texto, { width: W - 22 });
y = need(h + 22, y);
doc.rect(L, y - 3, 2.5, h + 10).fill(PRIMARY);
doc.font("Helvetica-Oblique").fontSize(11).fillColor(DARK).text(texto, L + 12, y, { width: W - 22 });
y += h + 4;
doc.font("Helvetica").fontSize(7.5).fillColor(MUTED).text(`${cita.quienLoDijo}`, L + 12, y);
y += 18;
}
for (const hal of d.propuesta.diagnostico.hallazgos) {
const cuerpo = `${hal.titulo}${hal.descripcion}`;
const h = txtH(cuerpo, W - 24, 9);
y = need(h + 14, y);
doc.circle(L + 4.5, y + 4.5, 3.2).fill(COLOR_SEMAFORO[hal.urgencia] || MUTED);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(cuerpo, L + 16, y, { width: W - 24 });
y += h + 1;
doc
.font("Helvetica")
.fontSize(7)
.fillColor(MUTED)
.text(`${ETIQUETA_SEMAFORO[hal.urgencia] ?? ""} · ${ETIQUETA_CONFIANZA[hal.confianza] ?? ""}`, L + 16, y);
y += 13;
}
y += 8;
// ── VALOR DEL PROBLEMA ──
const dims = d.propuesta.diagnostico.valorProblema.dimensiones;
if (dims.length) {
titulo("Lo que cuesta no resolverlo");
for (const dim of dims) {
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);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(linea, L + 6, y, { width: W - 14 });
y += h + 1;
doc.font("Helvetica").fontSize(7).fillColor(MUTED).text(ETIQUETA_CONFIANZA[dim.confianza] ?? "", L + 6, y);
y += 13;
}
if (d.propuesta.diagnostico.valorProblema.notaMetodologia.trim()) {
y += 2;
parrafo(d.propuesta.diagnostico.valorProblema.notaMetodologia, 8, MUTED);
}
y += 8;
}
// ── RESULTADOS ──
if (d.propuesta.diagnostico.resultados.length) {
titulo("Resultados a lograr");
for (const r of d.propuesta.diagnostico.resultados) {
const t = `${r.enunciado} (${r.metrica}, ${r.periodoMedicion})`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 8;
}
// ── ALCANCE ──
// Se recorren las partidas de la COTIZACION, no las que la IA alcanzo a describir.
// Motivo: el schema acota `alcance` y una cotizacion con mas partidas que ese tope
// dejaria servicios cotizados fuera del documento del cliente, en silencio.
// Verificado con UJ2606UR001: 58 partidas, la IA describio 40, faltaban 18.
// La completitud la manda la base de datos; la IA solo aporta la prosa.
titulo("Alcance de la inversion");
const descPorRef = new Map(d.propuesta.redaccion.alcance.map((a) => [a.refPartida, a.descripcionResultado]));
for (const p of d.economia.partidas) {
const descripcion = descPorRef.get(p.refPartida);
const hNombre = txtH(p.nombre, W * 0.68, 9.5);
const hDesc = descripcion ? txtH(descripcion, W * 0.68, 8) : 0;
y = need(hNombre + hDesc + 14, y);
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(PRIMARY)
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
y += hNombre + 2;
if (descripcion) {
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(descripcion, L, y, { width: W * 0.68 });
y += hDesc + 10;
} else {
// Sin prosa de la IA: la partida SI aparece, con su detalle del catalogo.
const respaldo = detalleDePartida(p);
if (respaldo) {
const hR = txtH(respaldo, W * 0.68, 8);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(respaldo, L, y, { width: W * 0.68 });
y += hR + 10;
} else {
y += 8;
}
}
}
// El plan Bucefalo es una partida mas del acuerdo: si no se imprime aqui, el
// cliente recibe un documento consultivo con menos alcance que su cotizacion.
if (d.economia.planBucefalo) {
const nivel = d.economia.planBucefalo.nivel;
const etiqueta = `CRM Bucefalo — plan ${nivel.charAt(0).toUpperCase() + nivel.slice(1)}`;
const hE = txtH(etiqueta, W * 0.68, 9.5);
y = need(hE + 22, y);
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(etiqueta, L, y, { width: W * 0.68 });
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(PRIMARY)
.text(`${formatCurrency(d.economia.planBucefalo.precio)} / mes`, L + W * 0.7, y, { width: W * 0.3, align: "right" });
y += hE + 2;
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text("Servicio mensual", L, y, { width: W * 0.68 });
y += 20;
}
y = need(58, y);
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
y += 10;
// ── CAJA DE TOTALES ──
// Desglose completo (subtotal / IVA / total), como la plantilla autorizada.
//
// Antes se imprimia cada partida a su precio SIN IVA y debajo un unico total CON
// IVA, rematado con la leyenda "Importes con IVA incluido": las lineas no sumaban
// el total, la leyenda contradecia a sus propias lineas, y el PDF economico que va
// en el MISMO correo dice "los precios no incluyen IVA" sobre las mismas cifras.
// Ahora las lineas siguen siendo sin IVA —igual que el otro documento— y el IVA
// aparece como renglon propio.
const t = d.economia.totales;
const fila = (etiqueta: string, valor: string, fuerte = false) => {
y = need(fuerte ? 18 : 15, y);
doc
.font(fuerte ? "Helvetica-Bold" : "Helvetica")
.fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? DARK : MUTED)
.text(etiqueta, L, y);
doc
.font("Helvetica-Bold")
.fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? PRIMARY : DARK)
.text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
y += fuerte ? 18 : 15;
};
if (t.subtotalUnico > 0) {
fila("Subtotal pago unico", formatCurrency(t.subtotalUnico));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaUnico));
fila(t.incluyeIva ? "Total pago unico" : "Total pago unico (sin IVA)", formatCurrency(t.totalUnico), true);
}
if (t.subtotalMensual > 0) {
if (t.subtotalUnico > 0) y += 4;
fila("Subtotal mensual", formatCurrency(t.subtotalMensual));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaMensual));
fila(t.incluyeIva ? "Total mensual" : "Total mensual (sin IVA)", formatCurrency(t.totalMensual), true);
}
y = need(16, y);
doc
.font("Helvetica")
.fontSize(7.5)
.fillColor(MUTED)
.text(
t.incluyeIva
? "Los precios por partida son en Moneda Nacional (MXN) y no incluyen IVA; el IVA se desglosa arriba. Facturacion CFDI."
: "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.",
L,
y,
{ width: W }
);
y += 24;
// ── BENEFICIOS ──
if (d.propuesta.redaccion.beneficios.length) {
titulo("Por que tiene sentido");
for (const b of d.propuesta.redaccion.beneficios) {
const hEt = txtH(b.etiqueta, W * 0.24, 9);
const hTx = txtH(b.texto, W * 0.72, 8.5);
const h = Math.max(hEt, hTx);
y = need(h + 9, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(b.etiqueta, L, y, { width: W * 0.24 });
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(b.texto, L + W * 0.27, y, { width: W * 0.72 });
y += h + 9;
}
y += 8;
}
// ── EXCLUSIONES ──
titulo("Que no incluye esta propuesta");
for (const e of d.propuesta.redaccion.exclusiones) {
const t2 = `${e.texto}`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
// ── PENDIENTES: el canal de honestidad del documento ──
const mats = d.propuesta.hechos.materialesPendientes;
const decs = d.propuesta.hechos.decisionesPendientes;
if (mats.length || decs.length) {
titulo("Que necesitamos de ustedes");
for (const mm of mats) {
const t2 = `${mm.texto}${mm.bloqueaEntrega ? " (el avance queda condicionado a esto)" : ""}`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
for (const dd of decs) {
const t2 = `· ${dd.texto} (decide: ${dd.quienDecide})`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
}
// ── BACKLOG ──
if (d.propuesta.redaccion.backlogEvolucion.length) {
titulo("Registrado para mas adelante");
y = need(14, y);
doc.font("Helvetica-Oblique").fontSize(7.5).fillColor(MUTED).text("No comprometido en esta propuesta.", L, y);
y += 14;
for (const b of d.propuesta.redaccion.backlogEvolucion) {
const t2 = `· ${b.problema} (${b.momentoSugerido})`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
}
// ── FOOTERS ──
// margins.bottom = 0 temporal: sin eso, escribir en el margen inferior dispara el
// auto-page-break de pdfkit y se generan paginas vacias.
const savedBottom = doc.page.margins.bottom;
const rango = doc.bufferedPageRange();
for (let i = rango.start; i < rango.start + rango.count; i++) {
doc.switchToPage(i);
doc.page.margins.bottom = 0;
doc.moveTo(L, ph - 42).lineTo(L + W, ph - 42).strokeColor(BORDER).lineWidth(0.5).stroke();
doc
.font("Helvetica")
.fontSize(7)
.fillColor(MUTED)
.text(`${d.numero} · Propuesta consultiva · ${d.empresa || d.cliente}`, L, ph - 35, { lineBreak: false });
doc
.font("Helvetica")
.fontSize(7)
.fillColor(MUTED)
.text(`${i - rango.start + 1} / ${rango.count}`, L, ph - 35, { width: W, align: "right", lineBreak: false });
doc.page.margins.bottom = savedBottom;
}
doc.end();
return listo;
}
/** Anexo interno. Archivo SEPARADO y visualmente inconfundible: el riesgo de que una
* seccion oculta llegue al cliente es demasiado alto para un display:none. */
export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buffer> {
const { lienzo, listo } = crearLienzo();
const { doc, W, L, maxY, mTop } = lienzo;
let y = mTop;
const DARK = "#1e293b";
const MUTED = "#64748b";
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
const txtH = (s: string, w: number, size: number) =>
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
doc.rect(0, 0, doc.page.width, 36).fill("#b91c1c");
doc.font("Helvetica-Bold").fontSize(13).fillColor("#ffffff").text("USO INTERNO — NO ENVIAR AL CLIENTE", L, 11);
y = 56;
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(`${d.numero} · ${d.empresa || d.cliente} · ${d.asesor}`, L, y);
y += 24;
const valorAnual =
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) => {
y = need(24, y);
doc.font("Helvetica-Bold").fontSize(11).fillColor(DARK).text(t, L, y);
y += 18;
};
const linea = (k: string, v: string) => {
y = need(15, y);
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(k, L, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(v, L + W * 0.5, y, { width: W * 0.5, align: "right" });
y += 15;
};
seccion("Ponderacion de la cotizacion");
linea("Inversion del primer ano", formatCurrency(d.economia.totales.totalPrimerAnio));
linea("Valor anual del problema", valorAnual ? formatCurrency(valorAnual) : "sin cuantificar");
linea("Ratio precio / valor", r ? `${(r.ratio * 100).toFixed(1)}% — ${r.lectura.replace(/_/g, " ")}` : "no calculable");
linea("Hallazgos en el diagnostico", String(d.propuesta.diagnostico.hallazgos.length));
linea("Cifras confirmadas", String(d.propuesta.diagnostico.hallazgos.filter((h) => h.confianza === "confirmado").length));
linea("Exclusiones definidas", String(d.propuesta.redaccion.exclusiones.length));
linea("Materiales pendientes", String(d.propuesta.hechos.materialesPendientes.length));
linea("Decisiones pendientes", String(d.propuesta.hechos.decisionesPendientes.length));
linea("Red flags detectadas", String(d.propuesta.hechos.redFlags.length));
y += 6;
// Desglose del valor anual. Va aqui porque es el denominador del ratio: si esa cifra
// esta inflada, el ratio miente y el asesor toma una decision de precio con un dato
// malo. Verificado en produccion que el modelo puede omitir un factor (una tasa de
// conversion) y aun asi cuadrar la aritmetica.
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.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.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 });
y += h + 8;
}
}
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
(dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado")
);
if (sinConfirmar) {
const alerta =
"Ojo: hay una cifra que descansa entera en factores estimados. Antes de fiarte del ratio, " +
"revisa que no falte un factor — la aritmetica puede cuadrar y aun asi estar inflada.";
const h = txtH(alerta, W, 8.5);
y = need(h + 12, y);
doc.font("Helvetica-Bold").fontSize(8.5).fillColor("#b91c1c").text(alerta, L, y, { width: W });
y += h + 14;
}
y += 4;
}
if (r) {
const nota =
r.lectura === "subcotizado"
? "La inversion es menos del 10% de lo que el problema le cuesta al cliente cada ano. Probablemente subcotizaste."
: r.lectura === "objecion_probable"
? "La inversion supera el 40% del valor anual del problema. Prepara la conversacion de precio: se responde con alcance, no con descuento."
: r.lectura === "alto"
? "La inversion esta en la banda alta. Justificable, pero conviene anclar bien el valor antes de dar el numero."
: "La inversion cae en el rango de referencia (15-25% del valor anual).";
const h = txtH(nota, W, 8.5);
y = need(h + 12, y);
doc.font("Helvetica-Oblique").fontSize(8.5).fillColor(MUTED).text(nota, L, y, { width: W });
y += h + 16;
}
if (d.propuesta.hechos.redFlags.length) {
seccion("Red flags");
for (const rf of d.propuesta.hechos.redFlags) {
const t = `· [${rf.severidad}] ${rf.senal}`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
}
if (d.propuesta.redaccion.notasInternas.length) {
seccion("Notas para el asesor");
for (const n of d.propuesta.redaccion.notasInternas) {
const t = `· ${n}`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
}
if (d.propuesta.hechos.mencionesFueraDeAlcance.length) {
seccion("Se menciono y quedo fuera");
for (const m of d.propuesta.hechos.mencionesFueraDeAlcance) {
const t = `· ${m}`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
}
doc.end();
return listo;
}
+99
View File
@@ -0,0 +1,99 @@
import { llamarConHerramienta, modelo, type UsoTokens, type BloqueSystem } from "./cliente-ia";
import {
hechosSchema,
diagnosticoSchema,
redaccionSchema,
type Hechos,
type Diagnostico,
type Redaccion,
type PropuestaConsultiva,
} from "./schemas";
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "./prompts";
import type { DatosEconomicos } from "./economia";
/**
* Pipeline de tres pasos.
*
* Se separa en tres porque un solo prompt monolitico produce un documento mediocre en
* todas sus partes: la extraccion determina la calidad de todo lo demas y merece su
* propia pasada. Ademas permite corregir los hechos antes de que se propaguen.
*/
export interface Traza {
modelo: string;
pasos: { paso: string; intentos: number; uso: UsoTokens }[];
totalUso: UsoTokens;
}
// Un solo breakpoint de cache, al final de la capa estable. El pipeline hace tres
// llamadas seguidas con el mismo prefijo, asi que el TTL de 5 minutos de MiniMax
// alcanza de sobra y las llamadas 2 y 3 deberian leer del cache.
const SYSTEM: BloqueSystem[] = [{ texto: SYSTEM_BASE, cachear: true }];
export async function generarPropuesta(opts: {
entrada: ContextoEntrada;
economia: DatosEconomicos;
}): Promise<{ propuesta: PropuestaConsultiva; traza: Traza }> {
const pasos: Traza["pasos"] = [];
const total: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
const acumular = (u: UsoTokens) => {
total.entrada += u.entrada;
total.salida += u.salida;
total.cacheLectura += u.cacheLectura;
total.cacheEscritura += u.cacheEscritura;
};
const r1 = await llamarConHerramienta<Hechos>({
system: SYSTEM,
mensajeUsuario: mensajePaso1(opts.entrada),
herramienta: {
nombre: "registrar_hechos",
descripcion:
"Registra las citas literales, los hechos, los pendientes y las red flags extraidos del material de la reunion.",
schema: hechosSchema,
},
// 5 intentos: verificado contra la API real que MiniMax se equivoca de array de
// vez en cuando con schemas anidados (mete campos de "hechos" dentro de "citas").
// Es el paso fundacional; si falla, no hay documento. El cache abarata el reintento.
maxIntentos: 5,
maxTokens: 8000,
});
pasos.push({ paso: "extraccion", intentos: r1.intentos, uso: r1.uso });
acumular(r1.uso);
const r2 = await llamarConHerramienta<Diagnostico>({
system: SYSTEM,
mensajeUsuario: mensajePaso2(opts.entrada, r1.datos, opts.economia),
herramienta: {
nombre: "registrar_diagnostico",
descripcion:
"Registra los hallazgos con su semaforo de urgencia, el valor anual del problema y los resultados de negocio a lograr.",
schema: diagnosticoSchema,
},
maxIntentos: 4,
maxTokens: 8000,
});
pasos.push({ paso: "diagnostico", intentos: r2.intentos, uso: r2.uso });
acumular(r2.uso);
// El paso 3 recibe un intento extra: es el mas largo y el que atraviesa mas
// compuertas de validacion, asi que es donde mas probable es agotar el presupuesto.
const r3 = await llamarConHerramienta<Redaccion>({
system: SYSTEM,
mensajeUsuario: mensajePaso3(opts.entrada, r1.datos, r2.datos, opts.economia),
herramienta: {
nombre: "registrar_propuesta",
descripcion: "Registra la redaccion final de la propuesta consultiva para el cliente.",
schema: redaccionSchema,
},
maxIntentos: 4,
maxTokens: 12000,
});
pasos.push({ paso: "redaccion", intentos: r3.intentos, uso: r3.uso });
acumular(r3.uso);
return {
propuesta: { hechos: r1.datos, diagnostico: r2.datos, redaccion: r3.datos },
traza: { modelo: modelo(), pasos, totalUso: total },
};
}
+244
View File
@@ -0,0 +1,244 @@
import type { DatosEconomicos } from "./economia";
import type { Hechos, Diagnostico } from "./schemas";
/**
* Prompts en capas, ordenados por estabilidad.
*
* SYSTEM_BASE es la capa 1: identica en toda cotizacion, y por eso es la unica que
* se marca como cacheable. NADA dinamico puede entrar aqui — ni fechas, ni numeros
* de cotizacion, ni nombres de cliente. Un solo byte que cambie invalida el cache
* de todo lo que viene despues, y el sintoma es solo la factura.
*/
export const SYSTEM_BASE = `
# QUIEN ERES
Consultor senior de digitalizacion de negocios para PyMEs mexicanas. Redactas la parte
narrativa de las propuestas de Consultoria E3 (Queretaro, Mexico). No eres vendedor: eres
diagnosticador. Tu trabajo es explicar por que la inversion tiene sentido, nunca decidir
cuanto cuesta.
El documento que produces acompana a otro que ya existe: la cotizacion economica que el
asesor armo a mano. Ese documento manda en todo lo que sea dinero. El tuyo manda en el
porque.
# LOS PRINCIPIOS QUE NO SE NEGOCIAN
P1. NO TOCAS LOS NUMEROS. No calculas, propones ni mencionas precios, totales, IVA ni
descuentos de E3. Ni siquiera los ves. Para referirte a una partida usa su
refPartida (P01, P02...). El sistema inyecta los importes despues de ti.
Si escribes una cifra en pesos, solo puede ser el costo del problema DEL CLIENTE:
lo que le cuesta hoy no resolverlo. Nunca un precio nuestro.
P2. TODA AFIRMACION ES TRAZABLE. Cada cosa que digas sobre el cliente apunta a una cita
(C01...) o a un hecho (H01...). Si no tiene origen, no se escribe. No hay excepciones
y no importa lo razonable que suene.
P3. ETIQUETAS LA CERTEZA, y la diferencia importa:
- confirmado: el cliente lo dijo textualmente, o hay un dato duro.
- estimado: lo derivaste con un calculo explicito que puedes mostrar.
- por_validar: es hipotesis tuya y nadie la ha confirmado.
Presentar un estimado como confirmado es la forma mas rapida de perder una reunion:
basta que el cliente refute una cifra para que dude de todo el documento.
P4. LAS PALABRAS DEL CLIENTE SON SAGRADAS. Las citas van textuales, con sus muletillas si
hace falta. No las pulas ni les arregles la gramatica.
Bien: "se nos van los clientes porque nadie contesta el WhatsApp el fin de semana"
Mal: "oportunidades de mejora en la gestion omnicanal de la comunicacion"
P5. VENDES RESULTADO, NO HERRAMIENTA. Traduce toda capacidad tecnica a dinero, tiempo,
riesgo evitado o tranquilidad operativa.
Mal: "Configuracion de Google Ads con estructura SKAG y scripts de puja."
Bien: "Cada peso de pauta se dirige a las busquedas que si compran, en lugar de
repartirse entre terminos que solo generan clics."
P6. LO QUE NO INCLUYE VALE TANTO COMO LO QUE SI. Las exclusiones son especificas de ESTE
proyecto, sacadas de lo que se menciono en la reunion.
Mal: "No incluye servicios no mencionados."
Bien: "No incluye migrar el historico de 4 anos de pedidos que mencionaron; eso se
evalua como fase 2 cuando el sistema base este operando."
P7. LA INCERTIDUMBRE SE DECLARA, NO SE RELLENA. Si falta un dato, va a pendientes con su
etiqueta. Jamas lo inventas. Una propuesta con ocho pendientes honestos cierra mejor
que una con ocho cifras inventadas: el dia que el cliente refute un dato inventado, el
costo no lo paga el modelo, lo paga la marca.
P8. NO PROPONES SERVICIOS QUE EL ASESOR NO ELIGIO. Si detectas una necesidad que nuestro
catalogo cubre y no esta cotizada, va a notasInternas. Nunca al documento del cliente.
# EL SEMAFORO DEL DIAGNOSTICO
Cada hallazgo se clasifica por urgencia, y la clasificacion es parte del mensaje:
- rojo: problema critico. Le esta costando dinero hoy.
- ambar: area de mejora. Funciona, pero por debajo de lo que podria.
- azul: oportunidad. Algo que no esta haciendo y podria.
- verde: ventaja existente. Algo que el cliente YA hace bien.
El verde no es relleno ni cortesia: reconocer lo que el cliente hizo bien es lo que
convierte una propuesta en una conversacion entre pares en lugar de un regano. Buscalo
de verdad. Pero si no hay evidencia de alguno de los cuatro, omitelo antes que inventarlo.
# REGLAS DE CONTENIDO Y DE MARCA
1. Espanol de Mexico. Tono cercano y profesional. Tutea al cliente.
2. Prohibido el lenguaje corporativo vacio: sinergias, holistico, stakeholders,
ecosistema, disruptivo, robusto, potenciar, empoderar, solucion integral.
3. El CRM se llama Bucefalo. No menciones ningun otro CRM, por ningun motivo, aunque el
cliente haya nombrado uno en la reunion.
4. E3 ofrece UNICAMENTE servicios digitales. Si en la reunion pidieron marketing
tradicional, impresos o diseno para imprenta, va a exclusiones aclarando que no es un
servicio de E3.
5. Moneda MXN, IVA 16%, facturacion CFDI. Jamas montos en dolares ni en USD.
6. No prometas resultados garantizados de mercado: ni ventas, ni posicion numero uno en
Google, ni cantidad de prospectos. Prometes entregables, procesos y metricas de
seguimiento. La palabra "garantizado" no aparece sobre resultados de mercado.
7. No prometas soporte ilimitado ni mantenimiento gratuito indefinido.
8. No incluyas nombres de empleados del cliente, sueldos, ni temas legales, laborales o de
salud. Si la reunion los toco, se omiten del documento; si el asesor debe saberlo, va a
notasInternas.
# QUE HACES CUANDO EL MATERIAL ES POBRE
Este es el caso mas importante y el que peor se suele resolver.
Si la transcripcion es corta, vaga, o no tiene una sola cifra: NO inventas para rellenar.
Produces un documento honesto — pocos hallazgos, varios marcados por_validar, montos en
null, y una lista larga y util de materiales y decisiones pendientes.
Ese documento es un exito, no un fracaso. Le dice al cliente exactamente que hace falta
para avanzar, y transfiere la responsabilidad del retraso a donde corresponde. Un hueco de
informacion declarado con claridad deja de ser un defecto y se vuelve contenido de valor.
Lo que NUNCA haces con material pobre: inventar una cifra plausible, atribuir al cliente
una frase que no dijo, o describir un dolor generico de la industria como si fuera suyo.
`.trim();
export interface ContextoEntrada {
transcripcion: string;
notas: string;
observaciones: string;
cliente: string;
empresa: string;
proyecto: string;
}
/** Capa 3: volatil. Cambia por cotizacion, nunca se cachea. */
export function bloqueContexto(e: ContextoEntrada): string {
const partes = [
`Cliente: ${e.cliente}${e.empresa ? ` (${e.empresa})` : ""}`,
`Proyecto: ${e.proyecto}`,
];
if (e.observaciones.trim()) {
partes.push(`\n## Observaciones que el asesor escribio para el cliente\n${e.observaciones.trim()}`);
}
if (e.notas.trim()) {
partes.push(`\n## Notas por partida\n${e.notas.trim()}`);
}
if (e.transcripcion.trim()) {
partes.push(`\n## Transcripcion de la reunion\n${e.transcripcion.trim()}`);
} else {
partes.push(
`\n## Transcripcion de la reunion\n(No se entrego transcripcion. Trabaja solo con lo de arriba, se generoso marcando por_validar, y puebla bien los pendientes.)`
);
}
return partes.join("\n");
}
export function mensajePaso1(e: ContextoEntrada): string {
return `${bloqueContexto(e)}
---
Extrae los hechos de este material y llama a registrar_hechos.
Reglas de este paso:
- Las citas van TEXTUALES. Copialas del material, no las reescribas. Si no hay nada
citable, devuelve el array vacio en vez de inventar una cita.
- Numera las citas C01, C02... y los hechos H01, H02...
- Cada hecho apunta a las citas que lo sostienen. Un hecho sin ninguna cita solo puede
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".`;
}
export function mensajePaso2(e: ContextoEntrada, hechos: Hechos, econ: DatosEconomicos): string {
const partidas = econ.partidas
.map((p) => `- ${p.refPartida}: ${p.nombre} (fase ${p.fase}, pago ${p.tipoPago})`)
.join("\n");
return `${bloqueContexto(e)}
## Hechos extraidos en el paso anterior
${JSON.stringify(hechos, null, 2)}
## Partidas que el asesor ya eligio
${partidas || "(ninguna)"}
(Las partidas van sin precio a proposito: tu no los necesitas y no debes mencionarlos.)
---
Diagnostica y llama a registrar_diagnostico.
Reglas de este paso:
- Reformula al problema de NEGOCIO. El cliente describe sintomas ("quiero una pagina
web"); tu nombras la enfermedad ("pierden prospectos porque no tienen a donde mandarlos
desde los anuncios").
- Intenta cubrir el semaforo completo, incluido al menos un verde. Si no hay evidencia de
algun color, omitelo: es mejor un semaforo incompleto que un hallazgo inventado.
- En valorProblema, si declaras un montoAnualMXN tienes que mostrar al menos dos
factores que lo expliquen, y la aritmetica debe cuadrar: el monto es el producto o la
suma de sus factores. Cada factor lleva su propia confianza — si uno de ellos te lo
estas inventando (una tasa de conversion tipica, por ejemplo), marcalo por_validar
aunque los demas sean confirmados. Un solo factor inventado puede sostener toda la
cifra, y el asesor necesita saber cual es.
- Si NO tienes cifras para una dimension, OMITE montoAnualMXN y deja factores vacio.
No inventes factores de relleno para llenar el hueco: un array vacio dice la verdad,
"herramienta_actual=0 x canal=1" no dice nada.
- Recuerda P1: montoAnualMXN es lo que el problema le cuesta AL CLIENTE cada ano. No es
un precio de E3 ni tiene relacion con lo que cotizamos.
- Los resultados a lograr son de negocio y medibles. "Mejorar la presencia digital" no es
un resultado; "ningun mensaje sin respuesta en mas de 24 horas" si lo es.`;
}
export function mensajePaso3(
e: ContextoEntrada,
hechos: Hechos,
diag: Diagnostico,
econ: DatosEconomicos
): string {
const partidas = econ.partidas
.map(
(p) => `- ${p.refPartida}: ${p.nombre} (fase ${p.fase}, pago ${p.tipoPago}, entrega ${p.tiempoEntrega})`
)
.join("\n");
return `## Hechos
${JSON.stringify(hechos, null, 2)}
## Diagnostico
${JSON.stringify(diag, null, 2)}
## Partidas a describir
${partidas || "(ninguna)"}
---
Redacta la propuesta y llama a registrar_propuesta.
Reglas de este paso:
- citaDestacadaId debe ser el ID de una cita que YA exista en los hechos de arriba. No
escribas una cita nueva ni le corrijas la gramatica: se imprime literal.
- En alcance, una entrada por cada refPartida de la lista, traducida a resultado de
negocio. No menciones importes ni plazos de pago: el sistema los inyecta.
- Cada beneficio apunta al hallazgoId que resuelve. Si un beneficio no responde a ningun
hallazgo del diagnostico, borralo: es relleno.
- Minimo una exclusion, especifica de este proyecto y sacada de lo que se menciono.
- El backlog es lo que quedo fuera y merece registrarse. Va con la etiqueta de que NO
esta comprometido en esta propuesta.
- notasInternas es lo unico que el cliente no vera. Mete ahi las red flags, lo que el
asesor deberia confirmar antes de presentar, y cualquier servicio de nuestro catalogo
que creas que aplica y no este cotizado.`;
}
+231
View File
@@ -0,0 +1,231 @@
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(3), bloqueaEntrega: z.boolean() }))
.max(15),
decisionesPendientes: z
.array(z.object({ texto: z.string().min(3), quienDecide: z.string() }))
.max(15),
mencionesFueraDeAlcance: z.array(z.string().min(3)).max(15),
redFlags: z
.array(z.object({ senal: z.string().min(3), 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"),
// 140 y no 80: con 80 el modelo se pasaba y gastaba un reintento. Observado
// en 2 de 6 corridas contra datos reales.
titulo: z.string().min(3).max(140),
descripcion: z.string().min(20),
confianza: z.enum(CONFIANZA),
citas: z.array(z.string().regex(ID_CITA)),
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),
// 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."),
}),
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(400),
}),
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."),
})
)
// 120 y no 40: una cotizacion real (UJ2606UR001) tiene 58 partidas, asi que el
// tope de 40 garantizaba un rechazo de Zod y un reintento desperdiciado en cada
// corrida. Observado en 6 de 6. El tope solo esta para acotar una respuesta
// desbocada; la completitud del documento ya no depende de este array, porque el
// generador recorre las partidas de la cotizacion.
.max(120),
beneficios: z
.array(
z.object({
// 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()
.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;
}
+252
View File
@@ -0,0 +1,252 @@
import type { PropuestaConsultiva } from "./schemas";
import type { DatosEconomicos } from "./economia";
/**
* Validacion post-generacion. Corre en codigo, nunca en la IA.
*
* R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son una
* ENTRADA que el modelo recibe en el prompt y puede copiar literalmente en cualquier
* campo de prosa. Comparar el bloque interno que produjo el modelo contra la salida
* no detecta eso.
*/
export interface Aviso {
regla: string;
severidad: "bloqueante" | "advertencia";
mensaje: string;
ruta?: string;
}
/** Normaliza para comparar citas contra las fuentes: sin acentos, en minusculas, sin
* puntuacion y con espacios colapsados. Laxa a proposito, para sobrevivir al ruido de
* transcripcion automatica sin volverse inutil. */
export function normalizar(s: string): string {
return s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
const OTRO_CRM = /\b(hubspot|salesforce|pipedrive|zoho|go\s*high\s*level|gohighlevel|clientify|kommo|monday\s*crm)\b/i;
const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(ventas|resultados|posici)/i;
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i;
const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i;
/**
* TODO el texto que termina impreso en el documento del cliente.
*
* Esta lista tiene que cubrir exactamente lo que dibuja `generarPropuestaPDF`. Es la
* unica fuente de los filtros de marca, moneda, garantias y PII: un campo que se
* imprime y no esta aqui llega al cliente sin que nadie lo mire.
*
* Faltaban seis, todos impresos: el texto y el autor de la cita destacada, la metrica
* y el periodo de cada resultado, quien decide cada pendiente, y el momento sugerido
* del backlog. Si agregas algo al PDF, agregalo aqui en el mismo cambio.
*/
export function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
const out: { ruta: string; texto: string }[] = [
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
];
// La cita destacada se imprime literal, con su autor (pdf.ts, seccion Diagnostico).
const destacada = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
if (destacada) {
out.push({ ruta: `citas.${destacada.id}.textoLiteral`, texto: destacada.textoLiteral });
out.push({ ruta: `citas.${destacada.id}.quienLoDijo`, texto: destacada.quienLoDijo });
}
p.diagnostico.hallazgos.forEach((h, i) => {
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
});
p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
);
p.diagnostico.resultados.forEach((r, i) => {
out.push({ ruta: `resultados[${i}].enunciado`, texto: r.enunciado });
out.push({ ruta: `resultados[${i}].metrica`, texto: r.metrica });
out.push({ ruta: `resultados[${i}].periodoMedicion`, texto: r.periodoMedicion });
});
p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado }));
p.redaccion.beneficios.forEach((b, i) =>
out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` })
);
p.redaccion.exclusiones.forEach((e, i) => out.push({ ruta: `exclusiones[${i}]`, texto: e.texto }));
p.redaccion.backlogEvolucion.forEach((b, i) => {
out.push({ ruta: `backlog[${i}].problema`, texto: b.problema });
out.push({ ruta: `backlog[${i}].momentoSugerido`, texto: b.momentoSugerido });
});
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto }));
p.hechos.decisionesPendientes.forEach((d, i) => {
out.push({ ruta: `decisiones[${i}].texto`, texto: d.texto });
out.push({ ruta: `decisiones[${i}].quienDecide`, texto: d.quienDecide });
});
return out;
}
function ngramas(texto: string, n: number): Set<string> {
const palabras = normalizar(texto).split(" ").filter(Boolean);
const out = new Set<string>();
for (let i = 0; i + n <= palabras.length; i++) out.add(palabras.slice(i, i + n).join(" "));
return out;
}
export function validarPropuesta(
p: PropuestaConsultiva,
econ: DatosEconomicos,
fuentes: { textoCliente: string; textoInterno: string }
): Aviso[] {
const avisos: Aviso[] = [];
const visible = textoVisible(p);
// ── R0 (bloqueante): el texto interno del asesor no puede aparecer en el documento
// del cliente. Es entrada, no salida; ninguna otra regla lo mira.
if (fuentes.textoInterno.trim()) {
const internos = ngramas(fuentes.textoInterno, 7);
if (internos.size) {
for (const { ruta, texto } of visible) {
for (const g of ngramas(texto, 7)) {
if (internos.has(g)) {
avisos.push({
regla: "R0",
severidad: "bloqueante",
ruta,
mensaje: `Texto de las notas internas aparece en el documento del cliente: "${g}"`,
});
break;
}
}
}
}
}
// ── R2 (bloqueante): ninguna partida inventada.
const refsReales = new Set(econ.partidas.map((x) => x.refPartida));
for (const a of p.redaccion.alcance) {
if (!refsReales.has(a.refPartida)) {
avisos.push({
regla: "R2",
severidad: "bloqueante",
ruta: `alcance ${a.refPartida}`,
mensaje: `La partida ${a.refPartida} no existe en la cotizacion.`,
});
}
}
// Advertencia si el modelo se salto partidas reales.
const refsUsadas = new Set(p.redaccion.alcance.map((a) => a.refPartida));
for (const real of econ.partidas) {
if (!refsUsadas.has(real.refPartida)) {
avisos.push({
regla: "R2b",
severidad: "advertencia",
ruta: real.refPartida,
mensaje: `La partida "${real.nombre}" (${real.refPartida}) esta cotizada pero no aparece descrita.`,
});
}
}
// ── R3 (bloqueante): la cita destacada existe y es literal.
const cita = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
if (!cita) {
avisos.push({
regla: "R3",
severidad: "bloqueante",
mensaje: `citaDestacadaId ${p.redaccion.citaDestacadaId} no corresponde a ninguna cita extraida.`,
});
} else if (fuentes.textoCliente.trim()) {
if (!normalizar(fuentes.textoCliente).includes(normalizar(cita.textoLiteral))) {
avisos.push({
regla: "R3",
severidad: "bloqueante",
ruta: cita.id,
mensaje: `La cita destacada no aparece literal en las fuentes: "${cita.textoLiteral.slice(0, 70)}..."`,
});
}
}
// ── R4 (advertencia): un hallazgo "confirmado" necesita respaldo.
p.diagnostico.hallazgos.forEach((h, i) => {
if (h.confianza === "confirmado" && h.citas.length + h.hechos.length === 0) {
avisos.push({
regla: "R4",
severidad: "advertencia",
ruta: `hallazgos[${i}]`,
mensaje: `"${h.titulo}" se declara confirmado sin citas ni hechos. Deberia ser por_validar.`,
});
}
});
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
const m = d.montoAnualMXN;
if (m === null) return;
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) {
avisos.push({
regla: "R5",
severidad: "bloqueante",
ruta: `valorProblema.dimensiones[${i}]`,
mensaje: `montoAnualMXN ${m} no cuadra con sus factores (producto ${producto}, suma ${suma}).`,
});
}
});
// ── R5b (advertencia): el valor anual manda sobre el ratio precio/valor, y ese ratio
// es el que te dice si subcotizaste. Una cifra que descansa solo en factores
// estimados puede estar inflada un orden de magnitud sin que R5 lo note: R5 verifica
// que los factores multipliquen al monto, no que no FALTE un factor.
// Observado contra la API real: el modelo produjo 3,120,000 omitiendo la tasa de
// 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.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.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` +
`Revisa el desglose antes de fiarte del ratio precio/valor.`,
});
}
});
// ── R6 (advertencia): referencias colgantes entre pasos.
const idsHallazgo = new Set(p.diagnostico.hallazgos.map((h) => h.id));
p.redaccion.beneficios.forEach((b, i) => {
if (!idsHallazgo.has(b.hallazgoId)) {
avisos.push({
regla: "R6",
severidad: "advertencia",
ruta: `beneficios[${i}]`,
mensaje: `El beneficio apunta al hallazgo ${b.hallazgoId}, que no existe.`,
});
}
});
// ── Filtro de contenido sobre lo visible ──
for (const { ruta, texto } of visible) {
if (OTRO_CRM.test(texto))
avisos.push({ regla: "MARCA", severidad: "bloqueante", ruta, mensaje: "Menciona un CRM que no es Bucefalo." });
if (MONTO_USD.test(texto))
avisos.push({ regla: "MONEDA", severidad: "bloqueante", ruta, mensaje: "Contiene un monto o referencia en USD." });
if (PROMESA_GARANTIA.test(texto))
avisos.push({ regla: "GARANTIA", severidad: "bloqueante", ruta, mensaje: "Promete un resultado garantizado." });
if (TEMA_SENSIBLE.test(texto))
avisos.push({ regla: "PII", severidad: "advertencia", ruta, mensaje: "Menciona un tema laboral, salarial o de salud. Revisalo antes de enviar." });
}
return avisos;
}
export function hayBloqueantes(avisos: Aviso[]): boolean {
return avisos.some((a) => a.severidad === "bloqueante");
}