Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce3a38f89d | ||
|
|
91db8cc55c | ||
|
|
45f166c116 | ||
|
|
7f375b3564 | ||
|
|
877a181a03 | ||
|
|
5bad273977 | ||
|
|
4e103beb27 |
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Generated
+73
-1
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,30 @@ 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?
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { crearCliente, modelo } 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;
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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", calculo: { factores: [{ nombre: "h", valor: 8 }, { nombre: "s", valor: 52 }, { nombre: "c", valor: 400 }], 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"));
|
||||||
|
check("declara que los importes llevan IVA", txt.includes("IVA incluido"));
|
||||||
|
|
||||||
|
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 la nota cambia.
|
||||||
|
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
|
||||||
|
check("sin IVA la nota legal cambia", textoDePdf(sinIva).includes("sin IVA"));
|
||||||
|
|
||||||
|
// 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"));
|
||||||
|
|
||||||
|
// ── 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"));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
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);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema } from "@/lib/propuesta/schemas";
|
||||||
|
|
||||||
|
let fallas = 0;
|
||||||
|
function check(n: string, ok: boolean, d = "") {
|
||||||
|
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
|
||||||
|
if (!ok) fallas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P1 por construccion: ningun schema declara un campo de dinero de E3 ──
|
||||||
|
const todos = JSON.stringify([
|
||||||
|
aJsonSchema(hechosSchema),
|
||||||
|
aJsonSchema(diagnosticoSchema),
|
||||||
|
aJsonSchema(redaccionSchema),
|
||||||
|
]);
|
||||||
|
for (const prohibido of ["precio", "total", "subtotal", "iva", "montoMinimo", "tarifaHora", "descuento"]) {
|
||||||
|
check(`ningun schema declara la propiedad "${prohibido}"`, !todos.includes(`"${prohibido}"`));
|
||||||
|
}
|
||||||
|
// montoAnualMXN si debe existir: es el costo del problema del cliente, no un precio nuestro.
|
||||||
|
check("montoAnualMXN si existe (costo del problema del cliente)", todos.includes("montoAnualMXN"));
|
||||||
|
|
||||||
|
// ── Formato de las claves ──
|
||||||
|
const redaccionBase = {
|
||||||
|
hero: { titulo: "Titulo de prueba", subtitulo: "x".repeat(25) },
|
||||||
|
citaDestacadaId: "C01",
|
||||||
|
alcance: [{ refPartida: "P01", descripcionResultado: "x".repeat(25) }],
|
||||||
|
beneficios: [],
|
||||||
|
exclusiones: [{ texto: "x".repeat(20), razon: "porque si" }],
|
||||||
|
backlogEvolucion: [],
|
||||||
|
notasInternas: [],
|
||||||
|
};
|
||||||
|
check("una redaccion bien formada valida", redaccionSchema.safeParse(redaccionBase).success);
|
||||||
|
check("refPartida en minuscula se rechaza",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "p01", descripcionResultado: "x".repeat(25) }] }).success);
|
||||||
|
check("refPartida sin ceros se rechaza",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "P1", descripcionResultado: "x".repeat(25) }] }).success);
|
||||||
|
check("citaDestacadaId con otro prefijo se rechaza",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, citaDestacadaId: "H01" }).success);
|
||||||
|
check("exige al menos una exclusion",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, exclusiones: [] }).success);
|
||||||
|
check("rechaza propiedades desconocidas (strict)",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, precioTotal: 99999 }).success);
|
||||||
|
|
||||||
|
// ── El calculo del valor debe venir desglosado ──
|
||||||
|
const diagBase = {
|
||||||
|
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Titulo del hallazgo", descripcion: "x".repeat(25), confianza: "confirmado", citas: [], hechos: [] }],
|
||||||
|
valorProblema: {
|
||||||
|
dimensiones: [{
|
||||||
|
tipo: "tiempo", descripcion: "x".repeat(12),
|
||||||
|
calculo: { factores: [{ nombre: "a", valor: 8 }, { nombre: "b", valor: 52 }], montoAnualMXN: 416 },
|
||||||
|
confianza: "estimado", hechos: [],
|
||||||
|
}],
|
||||||
|
notaMetodologia: "n",
|
||||||
|
},
|
||||||
|
resultados: [],
|
||||||
|
};
|
||||||
|
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
|
||||||
|
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
|
||||||
|
check("el calculo exige al menos 2 factores",
|
||||||
|
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }], montoAnualMXN: 1 } }] } }).success);
|
||||||
|
check("montoAnualMXN acepta null (sin cifras)",
|
||||||
|
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], calculo: { factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: null } }] } }).success);
|
||||||
|
check("exige al menos un hallazgo",
|
||||||
|
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
|
||||||
|
check("urgencia fuera del semaforo se rechaza",
|
||||||
|
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [{ ...diagBase.hallazgos[0], urgencia: "morado" }] }).success);
|
||||||
|
|
||||||
|
// ── Hechos ──
|
||||||
|
check("una cita corta se rechaza",
|
||||||
|
!hechosSchema.safeParse({ citas: [{ id: "C01", textoLiteral: "hola", quienLoDijo: "x" }], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success);
|
||||||
|
check("hechos vacios son validos (material pobre)",
|
||||||
|
hechosSchema.safeParse({ citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success);
|
||||||
|
|
||||||
|
// ── Conversion a JSON Schema ──
|
||||||
|
const js = aJsonSchema(redaccionSchema);
|
||||||
|
check("el JSON Schema no lleva $schema", !("$schema" in js));
|
||||||
|
check("conserva additionalProperties", JSON.stringify(js).includes("additionalProperties"));
|
||||||
|
check("conserva las descripciones para el modelo", JSON.stringify(js).includes("description"));
|
||||||
|
check("el JSON Schema es serializable", typeof JSON.stringify(js) === "string" && JSON.stringify(js).length > 200);
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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", calculo: { factores: [{ nombre: "horas por semana", valor: 8 }, { nombre: "semanas", valor: 52 }, { nombre: "costo por hora", valor: 400 }], 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].calculo.montoAnualMXN = 999999;
|
||||||
|
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
||||||
|
|
||||||
|
p = base(); p.diagnostico.valorProblema.dimensiones[0].calculo.montoAnualMXN = null;
|
||||||
|
check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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,57 @@
|
|||||||
|
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 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 });
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
|
||||||
|
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
|
||||||
|
textoCliente: [ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"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");
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cliente.messages.create({
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
const correcto = bloquesTool.find((b) => b.name === opts.herramienta.nombre);
|
||||||
|
|
||||||
|
if (correcto) {
|
||||||
|
const parsed = opts.herramienta.schema.safeParse(correcto.input);
|
||||||
|
if (parsed.success) {
|
||||||
|
return { datos: parsed.data as T, uso, intentos: intento };
|
||||||
|
}
|
||||||
|
ultimoError = z.prettifyError(parsed.error);
|
||||||
|
|
||||||
|
// 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}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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[]) : [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
partidas,
|
||||||
|
totales: calcularTotalesCotizacion(activos, { incluirIva: cot.incluirIva }),
|
||||||
|
moneda: cot.moneda,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.calculo.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: la IA pone la prosa, el codigo pone el dinero ──
|
||||||
|
titulo("Alcance de la inversion");
|
||||||
|
const porRef = new Map(d.economia.partidas.map((p) => [p.refPartida, p]));
|
||||||
|
for (const a of d.propuesta.redaccion.alcance) {
|
||||||
|
const p = porRef.get(a.refPartida);
|
||||||
|
if (!p) continue; // R2 ya lo marco como bloqueante; aqui simplemente no se dibuja
|
||||||
|
const hNombre = txtH(p.nombre, W * 0.68, 9.5);
|
||||||
|
const hDesc = txtH(a.descripcionResultado, W * 0.68, 8);
|
||||||
|
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;
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(a.descripcionResultado, L, y, { width: W * 0.68 });
|
||||||
|
y += hDesc + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
y = need(58, y);
|
||||||
|
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
|
y += 10;
|
||||||
|
const t = d.economia.totales;
|
||||||
|
const filaTotal = (etiqueta: string, valor: string) => {
|
||||||
|
y = need(16, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(etiqueta, L, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(10).fillColor(PRIMARY).text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
|
||||||
|
y += 16;
|
||||||
|
};
|
||||||
|
if (t.subtotalUnico > 0) filaTotal("Pago unico", formatCurrency(t.totalUnico));
|
||||||
|
if (t.subtotalMensual > 0) filaTotal("Mensualidad", formatCurrency(t.totalMensual));
|
||||||
|
y = need(14, y);
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7.5)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(
|
||||||
|
t.incluyeIva
|
||||||
|
? "Importes con IVA incluido. Moneda nacional (MXN). Facturacion CFDI."
|
||||||
|
: "Importes sin IVA. Moneda nacional (MXN). Facturacion CFDI.",
|
||||||
|
L,
|
||||||
|
y,
|
||||||
|
{ width: W }
|
||||||
|
);
|
||||||
|
y += 22;
|
||||||
|
|
||||||
|
// ── 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.calculo.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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
maxIntentos: 3,
|
||||||
|
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: 3,
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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.
|
||||||
|
- 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, cada dimension lleva su calculo desglosado en factores, y la
|
||||||
|
aritmetica tiene que cuadrar: montoAnualMXN debe ser el producto o la suma de sus
|
||||||
|
factores. Si no tienes cifras, deja montoAnualMXN en null y marca por_validar.
|
||||||
|
- 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.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrato con el modelo. MiniMax no soporta structured outputs, asi que estos
|
||||||
|
* schemas viajan como `input_schema` de una herramienta y Zod valida la respuesta.
|
||||||
|
*
|
||||||
|
* REGLA ESTRUCTURAL: ningun schema de aqui contiene un campo de dinero DE E3.
|
||||||
|
* El modelo devuelve `refPartida` y prosa; los precios, totales e IVA los inyecta
|
||||||
|
* el codigo al renderizar. `montoAnualMXN` si existe, pero es lo que el problema
|
||||||
|
* le cuesta AL CLIENTE cada ano — no un precio nuestro.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REF_PARTIDA = /^P\d{2}$/;
|
||||||
|
const ID_CITA = /^C\d{2}$/;
|
||||||
|
const ID_HECHO = /^H\d{2}$/;
|
||||||
|
const ID_HALLAZGO = /^D\d{2}$/;
|
||||||
|
|
||||||
|
export const CONFIANZA = ["confirmado", "estimado", "por_validar"] as const;
|
||||||
|
export const URGENCIA = ["rojo", "ambar", "azul", "verde"] as const;
|
||||||
|
export const DIMENSION_COSTO = ["dinero", "tiempo", "oportunidad", "error"] as const;
|
||||||
|
|
||||||
|
// ───────────────────── Paso 1: extraccion de hechos ─────────────────────
|
||||||
|
export const hechosSchema = z
|
||||||
|
.object({
|
||||||
|
citas: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().regex(ID_CITA, "El id de cita debe ser C01, C02..."),
|
||||||
|
textoLiteral: z
|
||||||
|
.string()
|
||||||
|
.min(10)
|
||||||
|
.describe("Cita TEXTUAL del cliente, palabra por palabra. Nunca parafraseada ni corregida."),
|
||||||
|
quienLoDijo: z
|
||||||
|
.string()
|
||||||
|
.describe("Rol del interlocutor comercial ('el socio', 'quien atiende el WhatsApp'). No nombres de empleados."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(30),
|
||||||
|
hechos: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().regex(ID_HECHO, "El id de hecho debe ser H01, H02..."),
|
||||||
|
enunciado: z.string().min(10),
|
||||||
|
confianza: z.enum(CONFIANZA),
|
||||||
|
citas: z.array(z.string().regex(ID_CITA)).describe("IDs de las citas que sostienen este hecho."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(40),
|
||||||
|
materialesPendientes: z
|
||||||
|
.array(z.object({ texto: z.string().min(5), bloqueaEntrega: z.boolean() }))
|
||||||
|
.max(15),
|
||||||
|
decisionesPendientes: z
|
||||||
|
.array(z.object({ texto: z.string().min(5), quienDecide: z.string() }))
|
||||||
|
.max(15),
|
||||||
|
mencionesFueraDeAlcance: z.array(z.string().min(5)).max(15),
|
||||||
|
redFlags: z
|
||||||
|
.array(z.object({ senal: z.string().min(5), severidad: z.enum(["baja", "media", "alta"]) }))
|
||||||
|
.max(10),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// ─────────────── Paso 2: diagnostico y valoracion del problema ───────────────
|
||||||
|
export const diagnosticoSchema = z
|
||||||
|
.object({
|
||||||
|
hallazgos: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().regex(ID_HALLAZGO, "El id de hallazgo debe ser D01, D02..."),
|
||||||
|
urgencia: z
|
||||||
|
.enum(URGENCIA)
|
||||||
|
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
|
||||||
|
titulo: z.string().min(3).max(80),
|
||||||
|
descripcion: z.string().min(20),
|
||||||
|
confianza: z.enum(CONFIANZA),
|
||||||
|
citas: z.array(z.string().regex(ID_CITA)),
|
||||||
|
hechos: z.array(z.string().regex(ID_HECHO)),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(12),
|
||||||
|
valorProblema: z.object({
|
||||||
|
dimensiones: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
tipo: z.enum(DIMENSION_COSTO),
|
||||||
|
descripcion: z.string().min(10),
|
||||||
|
calculo: z.object({
|
||||||
|
factores: z.array(z.object({ nombre: z.string(), valor: z.number() })).min(2),
|
||||||
|
montoAnualMXN: z
|
||||||
|
.number()
|
||||||
|
.nullable()
|
||||||
|
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. null si no hay cifras."),
|
||||||
|
}),
|
||||||
|
confianza: z.enum(CONFIANZA),
|
||||||
|
hechos: z.array(z.string().regex(ID_HECHO)),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(4),
|
||||||
|
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
|
||||||
|
}),
|
||||||
|
resultados: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
enunciado: z.string().min(15),
|
||||||
|
metrica: z.string(),
|
||||||
|
lineaBase: z.string().nullable(),
|
||||||
|
periodoMedicion: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(5),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// ───────────────────────── Paso 3: redaccion final ─────────────────────────
|
||||||
|
export const redaccionSchema = z
|
||||||
|
.object({
|
||||||
|
hero: z.object({
|
||||||
|
titulo: z.string().min(5).max(90),
|
||||||
|
subtitulo: z.string().min(20).max(300),
|
||||||
|
}),
|
||||||
|
citaDestacadaId: z
|
||||||
|
.string()
|
||||||
|
.regex(ID_CITA)
|
||||||
|
.describe("ID de una cita YA extraida en el paso 1. Se imprime literal; no la reescribas."),
|
||||||
|
alcance: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
refPartida: z.string().regex(REF_PARTIDA, "Debe ser P01, P02..."),
|
||||||
|
descripcionResultado: z
|
||||||
|
.string()
|
||||||
|
.min(20)
|
||||||
|
.describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(40),
|
||||||
|
beneficios: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
etiqueta: z.string().min(3).max(40),
|
||||||
|
texto: z.string().min(20),
|
||||||
|
hallazgoId: z
|
||||||
|
.string()
|
||||||
|
.regex(ID_HALLAZGO)
|
||||||
|
.describe("El hallazgo que este beneficio resuelve. Si no responde a ninguno, no lo incluyas."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(8),
|
||||||
|
exclusiones: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
texto: z.string().min(15).describe("Especifica de ESTE proyecto, nunca generica."),
|
||||||
|
razon: z.string().min(5),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(10),
|
||||||
|
backlogEvolucion: z
|
||||||
|
.array(z.object({ problema: z.string().min(10), momentoSugerido: z.string() }))
|
||||||
|
.max(8),
|
||||||
|
notasInternas: z
|
||||||
|
.array(z.string().min(5))
|
||||||
|
.max(10)
|
||||||
|
.describe("SOLO para el asesor. Nunca se imprime en el documento del cliente."),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type Hechos = z.infer<typeof hechosSchema>;
|
||||||
|
export type Diagnostico = z.infer<typeof diagnosticoSchema>;
|
||||||
|
export type Redaccion = z.infer<typeof redaccionSchema>;
|
||||||
|
|
||||||
|
/** El objeto completo que se persiste, se edita y se renderiza. */
|
||||||
|
export interface PropuestaConsultiva {
|
||||||
|
hechos: Hechos;
|
||||||
|
diagnostico: Diagnostico;
|
||||||
|
redaccion: Redaccion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema del objeto completo, para validar lo que el asesor edita a mano. */
|
||||||
|
export const propuestaCompletaSchema = z.object({
|
||||||
|
hechos: hechosSchema,
|
||||||
|
diagnostico: diagnosticoSchema,
|
||||||
|
redaccion: redaccionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod -> JSON Schema para el `input_schema` de la herramienta.
|
||||||
|
* zod 4.3.6 trae `z.toJSONSchema` nativo: no hace falta `zod-to-json-schema`.
|
||||||
|
* Se borra `$schema` porque el endpoint no lo espera.
|
||||||
|
*/
|
||||||
|
export function aJsonSchema(schema: z.ZodType): Record<string, unknown> {
|
||||||
|
const js = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
|
||||||
|
delete js.$schema;
|
||||||
|
return js;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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. */
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
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}]`, texto: r.enunciado }));
|
||||||
|
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}]`, texto: b.problema }));
|
||||||
|
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: d.texto }));
|
||||||
|
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.calculo.montoAnualMXN;
|
||||||
|
if (m === null) return;
|
||||||
|
const producto = d.calculo.factores.reduce((a, f) => a * f.valor, 1);
|
||||||
|
const suma = d.calculo.factores.reduce((a, f) => a + f.valor, 0);
|
||||||
|
const 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}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user