diff --git a/docs/superpowers/plans/2026-07-28-propuesta-consultiva-ia.md b/docs/superpowers/plans/2026-07-28-propuesta-consultiva-ia.md new file mode 100644 index 0000000..36f05c1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-propuesta-consultiva-ia.md @@ -0,0 +1,1960 @@ +# Propuesta Consultiva con IA — Plan de Implementación + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Que el Cotizador genere, a partir del contexto humano de una cotización, un tercer documento descargable — un PDF consultivo con marca de la plataforma que contiene el diagnóstico, los insights y una ponderación — revisable y editable por el asesor antes de enviarlo. + +**Architecture:** Un pipeline de tres pasos contra MiniMax-M3 extrae hechos, los diagnostica y los redacta en un objeto `PropuestaConsultiva` validado por Zod. Ese objeto se persiste en la tabla `PropuestaIA`, donde el asesor puede editarlo campo por campo. Un generador PDFKit lo compone con el branding de `Configuracion`. **Ningún campo de dinero entra al schema que llena el modelo**: los precios y totales los inyecta el código desde `calcularTotalesCotizacion()`, de modo que el principio "la IA no toca los números" lo garantiza el compilador y no una regla que alguien pueda olvidar. + +**Tech Stack:** Next.js 16 (App Router) · Prisma 7 · Zod 4.3.6 · `@anthropic-ai/sdk` apuntando al endpoint compatible de MiniMax · PDFKit · Tailwind v4. + +## Global Constraints + +- **Proveedor:** MiniMax-M3 vía `https://api.minimax.io/anthropic` con `@anthropic-ai/sdk`. Variables **`MINIMAX_API_KEY` / `MINIMAX_BASE_URL` / `MINIMAX_MODEL`**, pasadas explícitas al constructor. **Nunca** un prefijo `ANTHROPIC_*`: el SDK lo lee solo y mandaría la clave de MiniMax a Anthropic. +- **Sin structured outputs.** MiniMax no soporta `output_config`/`json_schema`. El contrato se fuerza con tool-calling: una herramienta por paso cuyo `input_schema` es el schema. Zod valida siempre del lado del código. +- **`tool_choice` no se envía.** No está documentado en MiniMax y fijarlo solo en el reintento rompería el prefijo de caché justo cuando más cuesta. +- **Presupuesto de reintentos:** 3 intentos totales por paso (1 + 2). El paso 3 recibe 4 (1 + 3). +- **P1 — la IA no toca los números.** Ningún schema que llena el modelo contiene un campo de dinero de la cotización. El modelo devuelve `refPartida` (`/^P\d{2}$/`) y prosa. +- **Clave de partida:** `refPartida`, formato `P01`. El cuid de `ServicioCotizado` nunca sale hacia el proveedor. +- **Evidencia:** arrays de IDs (`citas[]`, `hechos[]`), nunca prosa. +- **Ratio precio/valor:** base `totalPrimerAnio` (único + mensual × 12, **con IVA**), calculado en código con `calcularTotalesCotizacion()`. +- **Marca:** el CRM se llama **Bucéfalo**. E3 solo ofrece servicios digitales. MXN, IVA 16%, CFDI. Español de México, tuteo, sin lenguaje corporativo vacío. +- **Nombres de empleados detectados:** se advierten en el reporte, no bloquean. +- **Sin suite de pruebas en el repo.** La verificación es `npx tsc --noEmit` + `npm run lint` + un script de comprobación funcional por tarea (`npx tsx`). Cada tarea define el suyo. +- **Dependencias del API Python fijadas a versión exacta.** No aplica a este plan (todo es TypeScript), pero si se toca `api/requirements.txt`, se fija. + +--- + +## Estructura de archivos + +| Archivo | Responsabilidad | +|---|---| +| `prisma/schema.prisma` | Modelo `PropuestaIA` (persistencia + ediciones + auditoría) | +| `src/lib/propuesta/schemas.ts` | Los tres schemas Zod y el tipo raíz `PropuestaConsultiva` | +| `src/lib/propuesta/economia.ts` | `DatosEconomicos` desde Prisma: partidas con `refPartida`, totales canónicos, ratio | +| `src/lib/propuesta/cliente-ia.ts` | Cliente MiniMax + `llamarConHerramienta()` con reintentos | +| `src/lib/propuesta/prompts.ts` | Capa 1 (filosofía y reglas), capa 3 (contexto por cotización) | +| `src/lib/propuesta/pipeline.ts` | Orquesta los tres pasos y devuelve `PropuestaConsultiva` + traza | +| `src/lib/propuesta/validacion.ts` | Reglas R0-R6 y filtro de PII de salida | +| `src/lib/propuesta/pdf.ts` | Generador PDFKit del documento consultivo, con branding | +| `src/app/api/propuesta-ia/[id]/route.ts` | `POST` generar · `GET` leer · `PATCH` editar | +| `src/app/api/propuesta-ia/[id]/pdf/route.ts` | `GET` descargar el PDF | +| `src/components/PropuestaIAPanel.tsx` | UI: generar, revisar, editar, descargar | + +El corte es por responsabilidad, no por capa técnica: `economia.ts` existe para que el dinero viva en un solo sitio y nunca cruce hacia el prompt. + +--- + +## Task 1: Modelo de datos y datos económicos + +**Files:** +- Modify: `prisma/schema.prisma` +- Create: `prisma/migrations/20260729000000_add_propuesta_ia/migration.sql` +- Create: `src/lib/propuesta/economia.ts` +- Test: `scripts/verificar-propuesta-economia.ts` + +**Interfaces:** +- Consumes: `calcularTotalesCotizacion` y `conIva` de `src/lib/calculators.ts`. +- Produces: `type PartidaCanonica = { refPartida: string; servicioCotizadoId: string; nombre: string; fase: number; tipoPago: string; precio: number; tiempoEntrega: string; modeloCobro: string; horas: number | null; tarifaHora: number | null; entregables: string[] }`, `type DatosEconomicos = { partidas: PartidaCanonica[]; totales: TotalesCotizacion; moneda: string }`, `async function cargarEconomia(cotizacionId: string): Promise`, `function calcularRatio(totalPrimerAnio: number, valorAnual: number | null): { ratio: number; lectura: "subcotizado" | "en_rango" | "alto" | "objecion_probable" } | null`. + +- [ ] **Step 1: Añadir el modelo a `prisma/schema.prisma`** + +```prisma +model PropuestaIA { + id String @id @default(cuid()) + cotizacionId String + estado String @default("borrador") // borrador | aprobada + // Objeto PropuestaConsultiva validado por Zod. Se guarda el generado por la IA + // y, aparte, el editado por el asesor, para poder mostrar que cambio. + contenidoIA Json + contenidoEditado Json? + // Traza de la generacion: tokens, cache hits, intentos por paso. + traza Json? + // Hallazgos de validacion y PII que el asesor debe revisar. + 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]) +} +``` + +Y en `model Cotizacion`, junto a `registrosHoras`: + +```prisma + propuestasIA PropuestaIA[] +``` + +- [ ] **Step 2: Escribir la migración aditiva** + +`prisma/migrations/20260729000000_add_propuesta_ia/migration.sql`: + +```sql +-- Aditiva: tabla nueva, sin tocar datos existentes. +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; +``` + +- [ ] **Step 3: Regenerar el cliente Prisma** + +Run: `npx prisma generate` +Expected: `Generated Prisma Client` + +- [ ] **Step 4: Escribir `src/lib/propuesta/economia.ts`** + +```ts +import { prisma } from "@/lib/db"; +import { calcularTotalesCotizacion, type TotalesCotizacion } from "@/lib/calculators"; + +export interface PartidaCanonica { + refPartida: string; // P01, P02... lo unico que ve el modelo + servicioCotizadoId: string; // cuid real; NUNCA sale hacia el proveedor + 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; +} + +/** Trae de la BD todo lo que el documento necesita del lado del dinero. + * El pipeline de IA recibe de aqui SOLO refPartida y nombre; los importes + * se inyectan al renderizar. */ +export async function cargarEconomia(cotizacionId: string): Promise { + const cot = await prisma.cotizacion.findUnique({ + where: { id: cotizacionId }, + include: { + servicios: { + include: { servicioCatalogo: true }, + 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. Base: desembolso real del primer ano CON IVA, para que + * sea comparable contra un valor ANUAL del problema. */ +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 }; +} +``` + +- [ ] **Step 5: Escribir el script de verificación** + +`scripts/verificar-propuesta-economia.ts`: + +```ts +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", calcularRatio(100000, 0) === 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"); +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); +``` + +- [ ] **Step 6: Correr la verificación** + +Run: `npx tsx scripts/verificar-propuesta-economia.ts` +Expected: `todo paso` + +- [ ] **Step 7: Typecheck y commit** + +```bash +npx tsc --noEmit +git add prisma/schema.prisma prisma/migrations/20260729000000_add_propuesta_ia src/lib/propuesta/economia.ts scripts/verificar-propuesta-economia.ts +git commit -m "Propuesta IA: modelo PropuestaIA y capa de datos economicos" +``` + +--- + +## Task 2: Los schemas Zod del contrato con la IA + +**Files:** +- Create: `src/lib/propuesta/schemas.ts` +- Test: `scripts/verificar-propuesta-schemas.ts` + +**Interfaces:** +- Consumes: nada (solo `zod`). +- Produces: `hechosSchema`, `diagnosticoSchema`, `redaccionSchema` (los tres `z.ZodType`), los tipos `Hechos`, `Diagnostico`, `PropuestaConsultiva`, y `function aJsonSchema(schema: z.ZodType): Record`. + +Nota crítica: **ningún schema contiene un campo de dinero de la cotización.** `montoAnualMXN` sí existe, pero es el costo del *problema del cliente* que la IA estima desde la transcripción — no un precio de E3. + +- [ ] **Step 1: Escribir los schemas** + +```ts +import { z } from "zod"; + +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, "id de cita debe ser C01, C02..."), + textoLiteral: z.string().min(10).describe("Cita TEXTUAL del cliente, palabra por palabra. Nunca parafraseada."), + quienLoDijo: z.string().describe("Rol o nombre del interlocutor comercial, no de empleados."), + })).max(30), + hechos: z.array(z.object({ + id: z.string().regex(ID_HECHO), + enunciado: z.string().min(10), + confianza: z.enum(CONFIANZA), + citas: z.array(z.string().regex(ID_CITA)).describe("IDs de las citas que lo sostienen."), + })).max(40), + materialesPendientes: z.array(z.object({ + texto: z.string(), bloqueaEntrega: z.boolean(), + })).max(15), + decisionesPendientes: z.array(z.object({ + texto: z.string(), quienDecide: z.string(), + })).max(15), + mencionesFueraDeAlcance: z.array(z.string()).max(15), + redFlags: z.array(z.object({ + senal: z.string(), severidad: z.enum(["baja", "media", "alta"]), + })).max(10), +}).strict(); + +// ---------- Paso 2: diagnostico y valoracion ---------- +export const diagnosticoSchema = z.object({ + hallazgos: z.array(z.object({ + id: z.string().regex(ID_HALLAZGO), + 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(), + 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."), + }), + confianza: z.enum(CONFIANZA), + hechos: z.array(z.string().regex(ID_HECHO)), + })).max(4), + notaMetodologia: z.string(), + }), + 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 ---------- +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 del paso 1. Se imprime literal; no la reescribas."), + alcance: z.array(z.object({ + refPartida: z.string().regex(REF_PARTIDA), + descripcionResultado: z.string().min(20).describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar precios."), + })).max(40), + beneficios: z.array(z.object({ + etiqueta: z.string().max(40), + texto: z.string().min(20), + hallazgoId: z.string().regex(ID_HALLAZGO).describe("El dolor 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 del proyecto, nunca generica."), + razon: z.string(), + })).min(1).max(10), + backlogEvolucion: z.array(z.object({ + problema: z.string(), momentoSugerido: z.string(), + })).max(8), + notasInternas: z.array(z.string()).max(10).describe("Solo para el asesor. Nunca se imprime en el documento del cliente."), +}).strict(); + +export type Hechos = z.infer; +export type Diagnostico = z.infer; +export type Redaccion = z.infer; + +/** El objeto completo que se persiste y se renderiza. */ +export interface PropuestaConsultiva { + hechos: Hechos; + diagnostico: Diagnostico; + redaccion: Redaccion; +} + +/** 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. */ +export function aJsonSchema(schema: z.ZodType): Record { + const js = z.toJSONSchema(schema, { io: "input" }) as Record; + delete js.$schema; // MiniMax no lo espera + return js; +} +``` + +- [ ] **Step 2: Escribir el script de verificación** + +`scripts/verificar-propuesta-schemas.ts`: + +```ts +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++; +} + +// Ningun schema debe tener campos de dinero de la cotizacion (P1 por construccion). +const todos = JSON.stringify([aJsonSchema(hechosSchema), aJsonSchema(diagnosticoSchema), aJsonSchema(redaccionSchema)]); +for (const prohibido of ["precio", "total", "subtotal", "iva", "montoMinimo", "tarifaHora"]) { + check(`ningun schema declara "${prohibido}"`, !todos.includes(`"${prohibido}"`)); +} + +check("refPartida exige formato P01", !redaccionSchema.safeParse({ + hero: { titulo: "Titulo de prueba", subtitulo: "x".repeat(25) }, + citaDestacadaId: "C01", + alcance: [{ refPartida: "p1", descripcionResultado: "x".repeat(25) }], + beneficios: [], exclusiones: [{ texto: "x".repeat(20), razon: "y" }], backlogEvolucion: [], notasInternas: [], +}).success); + +check("exige al menos una exclusion", !redaccionSchema.safeParse({ + hero: { titulo: "Titulo de prueba", subtitulo: "x".repeat(25) }, + citaDestacadaId: "C01", alcance: [], beneficios: [], exclusiones: [], backlogEvolucion: [], notasInternas: [], +}).success); + +check("el calculo exige al menos 2 factores", !diagnosticoSchema.safeParse({ + hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "t", descripcion: "x".repeat(25), confianza: "confirmado", citas: [], hechos: [] }], + valorProblema: { dimensiones: [{ tipo: "tiempo", descripcion: "d", calculo: { factores: [{ nombre: "a", valor: 1 }], montoAnualMXN: 100 }, confianza: "estimado", hechos: [] }], notaMetodologia: "n" }, + resultados: [], +}).success); + +const js = aJsonSchema(redaccionSchema); +check("el JSON Schema no lleva $schema", !("$schema" in js)); +check("el JSON Schema conserva additionalProperties", JSON.stringify(js).includes("additionalProperties")); +check("el JSON Schema conserva las descripciones", JSON.stringify(js).includes("description")); + +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); +``` + +- [ ] **Step 3: Correr la verificación** + +Run: `npx tsx scripts/verificar-propuesta-schemas.ts` +Expected: `todo paso` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/propuesta/schemas.ts scripts/verificar-propuesta-schemas.ts +git commit -m "Propuesta IA: schemas Zod de los tres pasos, sin campos de dinero" +``` + +--- + +## Task 3: Cliente MiniMax y runner con reintentos + +**Files:** +- Modify: `package.json` (añadir `@anthropic-ai/sdk`) +- Modify: `.env.example` +- Create: `src/lib/propuesta/cliente-ia.ts` +- Test: `scripts/verificar-propuesta-cliente.ts` + +**Interfaces:** +- Consumes: `aJsonSchema` de `schemas.ts`. +- Produces: `function crearCliente(): Anthropic`, `async function llamarConHerramienta(opts: { system: {texto: string, cachear: boolean}[]; mensajeUsuario: string; herramienta: { nombre: string; descripcion: string; schema: z.ZodType }; maxIntentos: number; maxTokens: number }): Promise<{ datos: T; uso: UsoTokens; intentos: number }>`, `interface UsoTokens { entrada: number; salida: number; cacheLectura: number; cacheEscritura: number }`. + +- [ ] **Step 1: Instalar el SDK** + +Run: `npm install @anthropic-ai/sdk` +Expected: se añade a `dependencies`. + +- [ ] **Step 2: Documentar las variables en `.env.example`** + +```bash +# --- Propuesta consultiva con IA (MiniMax) --- +# La clave se saca del panel de MiniMax. NUNCA se commitea: .env esta en .gitignore. +# Ojo: no usar el prefijo ANTHROPIC_*. El SDK lo lee solo y mandaria esta clave +# a api.anthropic.com en vez de a MiniMax. +MINIMAX_API_KEY= +MINIMAX_BASE_URL=https://api.minimax.io/anthropic +MINIMAX_MODEL=MiniMax-M3 +``` + +- [ ] **Step 3: Escribir `src/lib/propuesta/cliente-ia.ts`** + +```ts +import Anthropic from "@anthropic-ai/sdk"; +import { z } from "zod"; +import { aJsonSchema } from "./schemas"; + +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 sola y mandaria la clave de MiniMax a Anthropic)." + ); + } + // baseURL y apiKey explicitos: si se dejan al SDK, toma las ANTHROPIC_* del shell. + 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"; +} + +interface OpcionesLlamada { + system: { texto: string; cachear: boolean }[]; + mensajeUsuario: string; + herramienta: { nombre: string; descripcion: string; schema: z.ZodType }; + maxIntentos: number; + maxTokens: number; +} + +/** + * Llama al modelo forzando el contrato via tool-calling y valida con Zod. + * + * MiniMax no soporta structured outputs, asi que el input_schema de la herramienta + * ES el contrato. tool_choice no se envia (no esta documentado, y fijarlo solo en + * el reintento romperia el prefijo de cache justo cuando mas cuesta). + */ +export async function llamarConHerramienta( + 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.InputSchema, + }, + ]; + 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, + }); + + uso.entrada += res.usage.input_tokens ?? 0; + uso.salida += res.usage.output_tokens ?? 0; + uso.cacheLectura += (res.usage as { cache_read_input_tokens?: number }).cache_read_input_tokens ?? 0; + uso.cacheEscritura += (res.usage as { cache_creation_input_tokens?: number }).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 seguir. + // Un mensaje de usuario plano aqui 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 no esperada.", + })), + { type: "text" as const, text: `Corrige estos errores y vuelve a llamar a ${opts.herramienta.nombre}:\n${ultimoError}` }, + ], + } + ); + continue; + } + + // No llamo a ninguna herramienta: turno de usuario plano, sin tool_result. + ultimoError = "El modelo no llamo 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. Ultimo error:\n${ultimoError}` + ); +} +``` + +- [ ] **Step 4: Escribir el script de verificación** + +Este script **no llama a la API**: comprueba el contrato local y el fallo limpio sin clave. + +`scripts/verificar-propuesta-cliente.ts`: + +```ts +import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia"; + +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; +delete process.env.MINIMAX_API_KEY; +let msg = ""; +try { crearCliente(); } catch (e) { msg = e instanceof Error ? e.message : String(e); } +check("sin clave falla con mensaje claro", msg.includes("MINIMAX_API_KEY"), msg.slice(0, 60)); +check("el mensaje advierte del prefijo ANTHROPIC_", msg.includes("ANTHROPIC_API_KEY")); + +process.env.MINIMAX_API_KEY = "prueba"; +const c = crearCliente(); +check("baseURL apunta a MiniMax", String(c.baseURL).includes("minimax"), String(c.baseURL)); +check("modelo por defecto es MiniMax-M3", modelo() === "MiniMax-M3", modelo()); + +if (guardada) process.env.MINIMAX_API_KEY = guardada; else delete process.env.MINIMAX_API_KEY; +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); +``` + +- [ ] **Step 5: Correr la verificación** + +Run: `npx tsx scripts/verificar-propuesta-cliente.ts` +Expected: `todo paso` + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json .env.example src/lib/propuesta/cliente-ia.ts scripts/verificar-propuesta-cliente.ts +git commit -m "Propuesta IA: cliente MiniMax con tool-calling y reintentos" +``` + +--- + +## Task 4: Prompts en capas + +**Files:** +- Create: `src/lib/propuesta/prompts.ts` +- Test: `scripts/verificar-propuesta-prompts.ts` + +**Interfaces:** +- Consumes: `DatosEconomicos` y `PartidaCanonica` de `economia.ts`; `Hechos`, `Diagnostico` de `schemas.ts`. +- Produces: `const SYSTEM_BASE: string`, `function bloqueContexto(entrada: ContextoEntrada): string`, `function mensajePaso1/2/3(...)`, `interface ContextoEntrada { transcripcion: string; notas: string; observaciones: string; cliente: string; empresa: string; proyecto: string }`. + +- [ ] **Step 1: Escribir el prompt de capa 1 y los mensajes de cada paso** + +```ts +import type { DatosEconomicos } from "./economia"; +import type { Hechos, Diagnostico } from "./schemas"; + +/** Capa 1: identica en toda cotizacion, por eso es cacheable. + * NADA dinamico aqui — ni fechas, ni numeros de cotizacion. Un byte que cambie + * invalida el cache de todo lo que sigue. */ +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, MX). No eres vendedor: eres +diagnosticador. Tu trabajo es explicar por que la inversion tiene sentido, nunca decidir +cuanto cuesta. + +# 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. Si necesitas referirte a una partida, usa su + refPartida (P01, P02...). El sistema inyecta los importes despues. + 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 de E3. + +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. + +P3. ETIQUETAS LA CERTEZA. Tres estados, 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 la reunion. + +P4. LAS PALABRAS DEL CLIENTE SON SAGRADAS. Las citas van textuales, con sus muletillas si + hace falta. No las pulas. "se nos van los clientes porque nadie contesta el WhatsApp + el fin de semana" es infinitamente mejor que "oportunidades de mejora en la gestion + omnicanal". + +P5. VENDES RESULTADO, NO HERRAMIENTA. Traduce toda capacidad tecnica a dinero, tiempo, + riesgo evitado o tranquilidad. + Mal: "Configuracion de Google Ads con estructura SKAG." + 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 8 pendientes honestos cierra mejor que + una con 8 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 el + catalogo cubre y no esta cotizada, va a notasInternas, nunca al documento. + +# REGLAS DE MARCA Y CONTENIDO + +- Espanol de Mexico. Tono cercano y profesional. Tutea al cliente. +- Prohibido el lenguaje corporativo vacio: sinergias, holistico, stakeholders, ecosistema, + disruptivo, robusto, potenciar, empoderar. +- El CRM se llama Bucefalo. No menciones ningun otro CRM, por ningun motivo. +- E3 ofrece UNICAMENTE servicios digitales. Si en la reunion pidieron marketing tradicional + o diseno para imprenta, va a exclusiones aclarando que no es un servicio de E3. +- Moneda MXN, IVA 16%, facturacion CFDI. Jamas montos en USD. +- No prometas resultados garantizados de mercado: ni ventas, ni posicion #1 en Google, ni + numero de leads. Prometes entregables, procesos y metricas de seguimiento. La palabra + "garantizado" no aparece sobre resultados. +- No prometas soporte ilimitado. +- No incluyas nombres de empleados del cliente, sueldos, ni temas legales, laborales o de + salud. Si la reunion los toco, se omiten del documento. + +# CUANDO EL MATERIAL ES POBRE + +Es el caso mas importante y el que peor se suele resolver. Si la transcripcion es corta, +vaga o no tiene cifras: NO inventas para rellenar. Produces un documento honesto — pocos +hallazgos, muchos marcados por_validar, 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 le transfiere la responsabilidad del retraso a +donde corresponde. +`.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 del asesor\n${e.observaciones.trim()}`); + if (e.notas.trim()) partes.push(`\n## Notas del asesor\n${e.notas.trim()}`); + if (e.transcripcion.trim()) partes.push(`\n## Transcripcion de la reunion\n${e.transcripcion.trim()}`); + else partes.push(`\n## Transcripcion\n(No se entrego transcripcion. Trabaja solo con lo de arriba y se generoso marcando por_validar.)`); + 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 del paso: +- Las citas van TEXTUALES. Copialas del material, no las reescribas. Si no hay material + citable, devuelve el array vacio en vez de inventar una. +- Numera las citas C01, C02... y los hechos H01, H02... +- Cada hecho apunta a las citas que lo sostienen. Un hecho sin cita solo puede ser + "por_validar". +- En quienLoDijo usa el ROL ("quien atiende el WhatsApp", "el socio"), no el nombre propio, + salvo que sea el interlocutor comercial.`; +} + +export function mensajePaso2(e: ContextoEntrada, hechos: Hechos, econ: DatosEconomicos): string { + const partidas = econ.partidas + .map((p) => `- ${p.refPartida}: ${p.nombre} (fase ${p.fase}, ${p.tipoPago})`) + .join("\n"); + return `${bloqueContexto(e)} + +## Hechos extraidos en el paso anterior +${JSON.stringify(hechos, null, 2)} + +## Partidas que el asesor ya eligio (sin precios, a proposito) +${partidas || "(ninguna)"} + +--- + +Diagnostica y llama a registrar_diagnostico. + +Reglas del paso: +- Reformula al problema de NEGOCIO. El cliente describe sintomas ("quiero una pagina web"); + tu nombras la enfermedad ("pierden prospectos porque no tienen donde mandarlos desde los + anuncios"). +- Intenta cubrir el semaforo completo: al menos un rojo, un ambar, un azul y un verde. El + verde es obligatorio en espiritu: reconoce lo que el cliente YA hizo bien. Pero si no hay + evidencia de alguno, omitelo antes que inventarlo. +- En valorProblema, cada dimension lleva su calculo desglosado en factores. La aritmetica + debe cuadrar: montoAnualMXN tiene que ser el producto o la suma de los 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.`; +} + +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}, ${p.tipoPago}, entrega: ${p.tiempoEntrega})`) + .join("\n"); + return `## Hechos +${JSON.stringify(hechos, null, 2)} + +## Diagnostico +${JSON.stringify(diag, null, 2)} + +## Partidas a describir (usa su refPartida; los precios los pone el sistema) +${partidas || "(ninguna)"} + +--- + +Redacta la propuesta y llama a registrar_propuesta. + +Reglas del paso: +- citaDestacadaId debe ser el ID de una cita que ya exista en los hechos. No escribas una + cita nueva ni corrijas su gramatica. +- En alcance, una entrada por cada refPartida de arriba, traducida a resultado de negocio. + No menciones importes: el sistema los inyecta. +- Cada beneficio apunta al hallazgoId que resuelve. Si un beneficio no responde a ningun + hallazgo, borralo. +- Minimo una exclusion, y que sea especifica de este proyecto. +- notasInternas es lo unico que el cliente NO vera: mete ahi las red flags, lo que el + asesor deberia confirmar, y cualquier servicio del catalogo que creas que aplica y no + este cotizado.`; +} +``` + +- [ ] **Step 2: Escribir el script de verificación** + +`scripts/verificar-propuesta-prompts.ts`: + +```ts +import { SYSTEM_BASE, bloqueContexto, mensajePaso1 } from "@/lib/propuesta/prompts"; + +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: cualquier cosa dinamica invalida el cache. +const anioActual = String(new Date().getFullYear()); +check("la capa 1 no contiene el anio actual", !SYSTEM_BASE.includes(anioActual)); +check("la capa 1 no contiene ISO de fecha", !/\d{4}-\d{2}-\d{2}/.test(SYSTEM_BASE)); +check("la capa 1 es identica entre llamadas", SYSTEM_BASE === SYSTEM_BASE); +check("la capa 1 supera el minimo cacheable de 512 tokens", SYSTEM_BASE.length > 2500, `${SYSTEM_BASE.length} chars`); + +// Reglas de marca presentes. +check("nombra Bucefalo", SYSTEM_BASE.includes("Bucefalo")); +check("prohibe USD", SYSTEM_BASE.includes("USD")); +check("prohibe garantizar resultados", SYSTEM_BASE.toLowerCase().includes("garantizado")); +check("prohibe lenguaje corporativo vacio", SYSTEM_BASE.includes("sinergias")); +check("cubre el caso de material pobre", SYSTEM_BASE.includes("MATERIAL ES POBRE")); + +// El contexto degrada con gracia sin transcripcion. +const sinTrans = bloqueContexto({ transcripcion: "", notas: "", observaciones: "Nota", cliente: "C", empresa: "", proyecto: "P" }); +check("sin transcripcion lo declara", sinTrans.includes("No se entrego transcripcion")); +check("incluye las observaciones", sinTrans.includes("Nota")); + +const m1 = mensajePaso1({ transcripcion: "hola", notas: "", observaciones: "", cliente: "C", empresa: "E", proyecto: "P" }); +check("el paso 1 pide llamar a la herramienta", m1.includes("registrar_hechos")); +check("el paso 1 exige citas textuales", m1.includes("TEXTUALES")); + +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); +``` + +- [ ] **Step 3: Correr la verificación** + +Run: `npx tsx scripts/verificar-propuesta-prompts.ts` +Expected: `todo paso` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/propuesta/prompts.ts scripts/verificar-propuesta-prompts.ts +git commit -m "Propuesta IA: prompts en capas con la capa 1 cacheable" +``` + +--- + +## Task 5: Validación y filtro de PII + +**Files:** +- Create: `src/lib/propuesta/validacion.ts` +- Test: `scripts/verificar-propuesta-validacion.ts` + +**Interfaces:** +- Consumes: `PropuestaConsultiva` de `schemas.ts`, `DatosEconomicos` de `economia.ts`. +- Produces: `interface Aviso { regla: string; severidad: "bloqueante" | "advertencia"; mensaje: string; ruta?: string }`, `function validarPropuesta(p: PropuestaConsultiva, econ: DatosEconomicos, fuentes: { textoCliente: string; textoInterno: string }): Aviso[]`, `function normalizar(s: string): string`. + +- [ ] **Step 1: Escribir la validación** + +```ts +import type { PropuestaConsultiva } from "./schemas"; +import type { DatosEconomicos } from "./economia"; + +export interface Aviso { + regla: string; + severidad: "bloqueante" | "advertencia"; + mensaje: string; + ruta?: string; +} + +/** Normalizacion para comparar citas contra las fuentes: sin acentos, minusculas, + * sin puntuacion y con espacios colapsados. Suficientemente laxa para sobrevivir + * al ruido de ASR 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 TERMINOS_CRM_PROHIBIDOS = /\b(hubspot|salesforce|pipedrive|zoho|monday\s*crm|go\s*high\s*level|gohighlevel|clientify|kommo)\b/i; +const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r)?\b|\baseguramos\s+(ventas|resultados|posici)/i; +const MONTO_USD = /\b(usd|d[oó]lares|dlls?|\$\s*\d[\d,.]*\s*(usd|dls))\b/i; +const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|enfermedad|embarazo|renuncia)\b/i; + +function textoVisibleAlCliente(p: PropuestaConsultiva): { ruta: string; texto: string }[] { + const out: { ruta: string; texto: string }[] = []; + out.push({ ruta: "hero.titulo", texto: p.redaccion.hero.titulo }); + out.push({ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo }); + 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.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 })); + out.push({ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia }); + return out; +} + +/** Devuelve los n-gramas de `n` palabras de un texto normalizado. */ +function ngramas(texto: string, n: number): Set { + const palabras = normalizar(texto).split(" ").filter(Boolean); + const out = new Set(); + 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 = textoVisibleAlCliente(p); + + // R0 — BLOQUEANTE. Nada del texto interno del asesor puede aparecer en el documento + // del cliente. Es entrada, no salida: ninguna otra regla lo cubre. + if (fuentes.textoInterno.trim()) { + const internos = ngramas(fuentes.textoInterno, 7); + if (internos.size) { + for (const { ruta, texto } of visible) { + const propios = ngramas(texto, 7); + for (const g of propios) { + 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, ninguna omitida. + 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: `refPartida ${a.refPartida} no existe en la cotizacion.` }); + } + } + + // R3 — BLOQUEANTE. La cita destacada debe existir y ser 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.` }); + } else if (fuentes.textoCliente.trim()) { + const fuente = normalizar(fuentes.textoCliente); + if (!fuente.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, 60)}..."` }); + } + } + + // R4 — ADVERTENCIA. Un hallazgo "confirmado" sin respaldo se degrada. + 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 debe 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 cuadra = Math.abs(producto - m) / Math.max(m, 1) < 0.02 || Math.abs(suma - m) / Math.max(m, 1) < 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}).` }); + } + }); + + // Filtro de contenido sobre lo visible. + for (const { ruta, texto } of visible) { + if (TERMINOS_CRM_PROHIBIDOS.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." }); + } + + return avisos; +} +``` + +- [ ] **Step 2: Escribir el script de verificación** + +`scripts/verificar-propuesta-validacion.ts` — construye una propuesta base válida y va rompiéndola: + +```ts +import { validarPropuesta, normalizar, 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"; +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 atencion", confianza: "confirmado", citas: ["C01"] }], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }, + diagnostico: { + hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes se pierden el fin de semana.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }], + valorProblema: { dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas", calculo: { factores: [{ nombre: "horas semana", valor: 8 }, { nombre: "semanas", valor: 52 }, { nombre: "costo hora", valor: 400 }], montoAnualMXN: 166400 }, confianza: "estimado", hechos: ["H01"] }], notaMetodologia: "Ocho horas por semana a costo cargado." }, + resultados: [{ enunciado: "Ningun mensaje sin respuesta en 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("¿Cómo estás, Juan?") === "como estas juan"); + +check("propuesta valida no genera bloqueantes", + validarPropuesta(base(), econ, fuentes).filter((a) => a.severidad === "bloqueante").length === 0, + JSON.stringify(validarPropuesta(base(), econ, fuentes).slice(0, 2))); + +let p = base(); p.redaccion.alcance[0].refPartida = "P99"; +check("R2 detecta partida inventada", tiene(validarPropuesta(p, econ, fuentes), "R2")); + +p = base(); p.redaccion.citaDestacadaId = "C09"; +check("R3 detecta 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 cita no verificable", tiene(validarPropuesta(p, econ, fuentes), "R3")); + +p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = []; +check("R4 degrada 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.redaccion.beneficios[0].texto = "Lo integramos con HubSpot sin problema."; +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."; +check("MONEDA detecta USD", tiene(validarPropuesta(p, econ, fuentes), "MONEDA")); + +p = base(); p.redaccion.beneficios[0].texto = "Te garantizamos la posicion 1 en Google."; +check("GARANTIA detecta promesa", tiene(validarPropuesta(p, econ, fuentes), "GARANTIA")); + +p = base(); p.redaccion.hero.subtitulo = "El sueldo de la recepcionista sale caro."; +check("PII advierte tema salarial", tiene(validarPropuesta(p, econ, fuentes), "PII")); + +// R0: el texto interno se cuela al documento del cliente. +const interno = "el socio pidio descuento antes de entender el alcance del proyecto completo"; +p = base(); p.redaccion.hero.subtitulo = `Contexto: ${interno}.`; +check("R0 detecta fuga de notas internas", + tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0")); +check("R0 no dispara si no hay fuga", + !tiene(validarPropuesta(base(), econ, { ...fuentes, textoInterno: interno }), "R0")); + +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); +``` + +- [ ] **Step 3: Correr la verificación** + +Run: `npx tsx scripts/verificar-propuesta-validacion.ts` +Expected: `todo paso` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/propuesta/validacion.ts scripts/verificar-propuesta-validacion.ts +git commit -m "Propuesta IA: reglas de validacion R0-R5 y filtro de contenido" +``` + +--- + +## Task 6: Pipeline de tres pasos + +**Files:** +- Create: `src/lib/propuesta/pipeline.ts` +- Test: cubierto por la Task 9 (integración real). Aquí solo typecheck. + +**Interfaces:** +- Consumes: todo lo anterior. +- Produces: `async function generarPropuesta(opts: { entrada: ContextoEntrada; economia: DatosEconomicos }): Promise<{ propuesta: PropuestaConsultiva; traza: Traza }>`, `interface Traza { modelo: string; pasos: { paso: string; intentos: number; uso: UsoTokens }[]; totalUso: UsoTokens }`. + +- [ ] **Step 1: Escribir el pipeline** + +```ts +import { llamarConHerramienta, modelo, type UsoTokens } 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"; + +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 min de MiniMax alcanza. +const SYSTEM = [{ 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({ + system: SYSTEM, + mensajeUsuario: mensajePaso1(opts.entrada), + herramienta: { nombre: "registrar_hechos", descripcion: "Registra los hechos, citas literales y pendientes extraidos del material.", schema: hechosSchema }, + maxIntentos: 3, + maxTokens: 8000, + }); + pasos.push({ paso: "extraccion", intentos: r1.intentos, uso: r1.uso }); + acumular(r1.uso); + + const r2 = await llamarConHerramienta({ + system: SYSTEM, + mensajeUsuario: mensajePaso2(opts.entrada, r1.datos, opts.economia), + herramienta: { nombre: "registrar_diagnostico", descripcion: "Registra los hallazgos con semaforo, el valor del problema y los resultados 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 atraviesa mas compuertas. + const r3 = await llamarConHerramienta({ + 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.", 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 }, + }; +} +``` + +- [ ] **Step 2: Typecheck y commit** + +```bash +npx tsc --noEmit +git add src/lib/propuesta/pipeline.ts +git commit -m "Propuesta IA: pipeline de tres pasos" +``` + +--- + +## Task 7: Generador del PDF consultivo + +**Files:** +- Create: `src/lib/propuesta/pdf.ts` +- Test: `scripts/verificar-propuesta-pdf.ts` + +**Interfaces:** +- Consumes: `PropuestaConsultiva`, `DatosEconomicos`, `calcularRatio`. +- Produces: `async function generarPropuestaPDF(datos: DatosPropuestaPDF): Promise`, `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 }; incluirAnexoInterno: boolean }`. + +Sigue la arquitectura argumental de la plantilla HTML (hero → diagnóstico con semáforo → valor → resultados → alcance → beneficios → exclusiones → pendientes → backlog), pero en tema claro con PDFKit, como el resto del sistema. **El anexo interno va en su propio archivo**, nunca como sección oculta. + +- [ ] **Step 1: Escribir el generador** + +Reusa el patrón de `src/lib/pdf-generator.ts`: contador vertical manual `y`, helpers `need()` y `txtHeight()`, `bufferPages: true` y el bucle de footers con `margins.bottom = 0`. + +```ts +import PDFDocument from "pdfkit"; +import { formatCurrency, formatDate } from "@/lib/calculators"; +import { calcularRatio, type DatosEconomicos } from "./economia"; +import type { PropuestaConsultiva } from "./schemas"; + +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 }; + incluirAnexoInterno: boolean; +} + +const COLOR_SEMAFORO: Record = { + rojo: "#dc2626", ambar: "#d97706", azul: "#2563eb", verde: "#16a34a", +}; +const ETIQUETA_SEMAFORO: Record = { + rojo: "Problema critico", ambar: "Area de mejora", azul: "Oportunidad", verde: "Ventaja existente", +}; +const ETIQUETA_CONFIANZA: Record = { + confirmado: "Confirmado", estimado: "Estimado", por_validar: "Por validar", +}; + +/** Valida el hex antes de meterlo en el PDF. `PUT /api/configuracion` 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; +} + +export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise { + const PRIMARY = hexSeguro(d.branding.colorPrimario, "#2563eb"); + const DARK = hexSeguro(d.branding.colorSecundario, "#1e293b"); + const MUTED = "#64748b"; + const BORDER = "#e2e8f0"; + + 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((res) => doc.on("end", () => res(Buffer.concat(chunks)))); + + const pw = doc.page.width, ph = doc.page.height, m = doc.page.margins; + const W = pw - m.left - m.right, L = m.left, maxY = ph - m.bottom - 5; + let y = m.top; + + const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), m.top) : yy); + const txtH = (s: string, w: number, size: number) => doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w }); + + function titulo(t: string) { + y = need(24, y); + doc.rect(L, y, 3, 10).fill(PRIMARY); + doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(t.toUpperCase(), L + 10, y); + y += 18; + } + function parrafo(t: string, size = 9) { + 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(DARK).text(p, L, y, { width: W }); + y += h + 4; + } + } + + // ── HERO ── + if (d.branding.logoBase64 && d.branding.logoMime) { + try { doc.image(Buffer.from(d.branding.logoBase64, "base64"), L, y, { height: 26 }); y += 34; } catch { /* logo invalido: se omite */ } + } + doc.font("Helvetica-Bold").fontSize(9).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); + y += 6; + doc.font("Helvetica").fontSize(8).fillColor(MUTED) + .text(`${d.empresa || d.cliente} · ${d.numero} · ${formatDate(d.fecha)} · Vigencia ${formatDate(d.vigencia)} · ${d.asesor}`, L, y, { width: W }); + y += 20; + doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke(); + y += 16; + + // ── 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 = txtH(texto, W - 20, 11); + y = need(h + 16, y); + doc.rect(L, y - 4, 2.5, h + 12).fill(PRIMARY); + doc.font("Helvetica-Oblique").fontSize(11).fillColor(DARK).text(texto, L + 12, y + 2, { width: W - 20 }); + y += h + 16; + doc.font("Helvetica").fontSize(7.5).fillColor(MUTED).text(`— ${cita.quienLoDijo}`, L + 12, y); + y += 16; + } + for (const hal of d.propuesta.diagnostico.hallazgos) { + const cuerpo = `${hal.titulo} — ${hal.descripcion}`; + const h = txtH(cuerpo, W - 22, 9); + y = need(h + 12, y); + doc.circle(L + 4, y + 5, 3.2).fill(COLOR_SEMAFORO[hal.urgencia] || MUTED); + doc.font("Helvetica").fontSize(9).fillColor(DARK).text(cuerpo, L + 14, y, { width: W - 22 }); + y += h + 2; + doc.font("Helvetica").fontSize(7).fillColor(MUTED) + .text(`${ETIQUETA_SEMAFORO[hal.urgencia] || ""} · ${ETIQUETA_CONFIANZA[hal.confianza] || ""}`, L + 14, y); + y += 12; + } + y += 6; + + // ── 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)}/ano` : " — sin cuantificar"}`; + const h = txtH(linea, W - 14, 9); + y = need(h + 12, 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 += 12; + } + parrafo(d.propuesta.diagnostico.valorProblema.notaMetodologia, 8); + y += 6; + } + + // ── 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 + 5, y); + doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 }); + y += h + 5; + } + y += 6; + } + + // ── 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; aqui simplemente no se dibuja + const h = Math.max(txtH(p.nombre, W * 0.55, 9.5), txtH(a.descripcionResultado, W * 0.55, 8)) + 14; + y = need(h, y); + doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.55 }); + doc.font("Helvetica-Bold").fontSize(9.5).fillColor(PRIMARY) + .text(formatCurrency(p.precio), L + W * 0.75, y, { width: W * 0.25, align: "right" }); + const hd = txtH(a.descripcionResultado, W * 0.7, 8); + doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(a.descripcionResultado, L, y + 12, { width: W * 0.7 }); + y += Math.max(hd + 18, 26); + } + y = need(46, y); + doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke(); + y += 8; + const t = d.economia.totales; + const filaTotal = (etiqueta: string, valor: string, fuerte = false) => { + y = need(14, y); + doc.font(fuerte ? "Helvetica-Bold" : "Helvetica").fontSize(fuerte ? 10 : 9).fillColor(fuerte ? DARK : MUTED).text(etiqueta, L, y); + doc.font("Helvetica-Bold").fontSize(fuerte ? 10 : 9).fillColor(fuerte ? PRIMARY : DARK) + .text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" }); + y += 14; + }; + if (t.subtotalUnico) filaTotal("Pago unico", formatCurrency(t.totalUnico), true); + if (t.subtotalMensual) filaTotal("Mensualidad", formatCurrency(t.totalMensual), true); + doc.font("Helvetica").fontSize(7.5).fillColor(MUTED) + .text(t.incluyeIva ? "Importes con IVA incluido. Moneda nacional (MXN)." : "Importes sin IVA. Moneda nacional (MXN).", L, y); + y += 18; + + // ── BENEFICIOS ── + if (d.propuesta.redaccion.beneficios.length) { + titulo("Por que tiene sentido"); + for (const b of d.propuesta.redaccion.beneficios) { + const h = Math.max(txtH(b.etiqueta, W * 0.25, 9), txtH(b.texto, W * 0.7, 8.5)); + y = need(h + 8, y); + doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(b.etiqueta, L, y, { width: W * 0.25 }); + doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(b.texto, L + W * 0.28, y, { width: W * 0.7 }); + y += h + 8; + } + y += 6; + } + + // ── 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 + 5, y); + doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 }); + y += h + 5; + } + y += 8; + + // ── PENDIENTES: el canal de honestidad ── + 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 ? " (bloquea el avance)" : ""}`; + const h = txtH(t2, W - 8, 8.5); + y = need(h + 5, y); + doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 }); + y += h + 5; + } + for (const dd of decs) { + const t2 = `· ${dd.texto} (decide: ${dd.quienDecide})`; + const h = txtH(t2, W - 8, 8.5); + y = need(h + 5, y); + doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(t2, L + 4, y, { width: W - 8 }); + y += h + 5; + } + y += 8; + } + + // ── BACKLOG ── + if (d.propuesta.redaccion.backlogEvolucion.length) { + titulo("Registrado para mas adelante"); + doc.font("Helvetica-Oblique").fontSize(7.5).fillColor(MUTED) + .text("No comprometido en esta propuesta.", L, y); + y += 12; + 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 + 5, y); + doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 }); + y += h + 5; + } + } + + // ── FOOTERS ── + 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; // sin esto, escribir aqui dispara pagina nueva + 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, nunca una seccion oculta del documento del cliente. */ +export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise { + 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((res) => doc.on("end", () => res(Buffer.concat(chunks)))); + const m = doc.page.margins, W = doc.page.width - m.left - m.right, L = m.left; + let y = m.top; + + doc.rect(0, 0, doc.page.width, 34).fill("#b91c1c"); + doc.font("Helvetica-Bold").fontSize(13).fillColor("#ffffff") + .text("USO INTERNO — NO ENVIAR AL CLIENTE", L, 10); + y = 52; + + doc.font("Helvetica").fontSize(9).fillColor("#1e293b") + .text(`${d.numero} · ${d.empresa || d.cliente}`, L, y); + y += 22; + + 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); + + doc.font("Helvetica-Bold").fontSize(10).fillColor("#1e293b").text("Ponderacion", L, y); y += 16; + const linea = (k: string, v: string) => { + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text(k, L, y); + doc.font("Helvetica-Bold").fontSize(9).fillColor("#1e293b").text(v, L + W * 0.5, y, { width: W * 0.5, align: "right" }); + y += 14; + }; + linea("Inversion primer ano", formatCurrency(d.economia.totales.totalPrimerAnio)); + linea("Valor anual detectado", valorAnual ? formatCurrency(valorAnual) : "sin cuantificar"); + linea("Ratio precio/valor", r ? `${(r.ratio * 100).toFixed(1)}% — ${r.lectura.replace(/_/g, " ")}` : "no calculable"); + linea("Hallazgos", String(d.propuesta.diagnostico.hallazgos.length)); + linea("Exclusiones definidas", String(d.propuesta.redaccion.exclusiones.length)); + linea("Materiales pendientes", String(d.propuesta.hechos.materialesPendientes.length)); + linea("Red flags", String(d.propuesta.hechos.redFlags.length)); + y += 12; + + if (d.propuesta.hechos.redFlags.length) { + doc.font("Helvetica-Bold").fontSize(10).fillColor("#1e293b").text("Red flags", L, y); y += 16; + for (const rf of d.propuesta.hechos.redFlags) { + const t = `· [${rf.severidad}] ${rf.senal}`; + const h = doc.font("Helvetica").fontSize(9).heightOfString(t, { width: W - 8 }); + doc.font("Helvetica").fontSize(9).fillColor("#1e293b").text(t, L + 4, y, { width: W - 8 }); + y += h + 5; + } + y += 10; + } + + if (d.propuesta.redaccion.notasInternas.length) { + doc.font("Helvetica-Bold").fontSize(10).fillColor("#1e293b").text("Notas para el asesor", L, y); y += 16; + for (const n of d.propuesta.redaccion.notasInternas) { + const t = `· ${n}`; + const h = doc.font("Helvetica").fontSize(9).heightOfString(t, { width: W - 8 }); + doc.font("Helvetica").fontSize(9).fillColor("#1e293b").text(t, L + 4, y, { width: W - 8 }); + y += h + 5; + } + } + + doc.end(); + return listo; +} +``` + +- [ ] **Step 2: Escribir el script de verificación** + +Reusa el extractor de texto de PDF que ya se validó en Fase 0 (decodifica hex de un byte, porque PDFKit con fuentes base no embebe ni lleva ToUnicode). + +`scripts/verificar-propuesta-pdf.ts`: + +```ts +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"; + +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, 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; } + 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 += " "; + } + } + 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: [] }], + totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, 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", confianza: "confirmado", citas: ["C01"] }], + materialesPendientes: [{ texto: "MARCADORMATERIAL accesos al dominio", bloqueaEntrega: true }], + decisionesPendientes: [{ texto: "Definir responsable", quienDecide: "el socio" }], + mencionesFueraDeAlcance: [], redFlags: [{ senal: "MARCADORFLAG pidio descuento antes de ver alcance", severidad: "alta" }], + }, + diagnostico: { + hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "MARCADORHALLAZGO", descripcion: "Se pierden prospectos.", 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: "Calculo directo." }, + resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }], + }, + redaccion: { + hero: { titulo: "MARCADORTITULO", subtitulo: "MARCADORSUB ordenar la atencion." }, + citaDestacadaId: "C01", + alcance: [{ refPartida: "P01", descripcionResultado: "MARCADORALCANCE un lugar a donde mandar prospectos." }], + beneficios: [{ etiqueta: "Respuesta", texto: "MARCADORBENEFICIO cada mensaje con responsable.", hallazgoId: "D01" }], + exclusiones: [{ texto: "MARCADOREXCLUSION no incluye migrar historico.", razon: "fase 2" }], + backlogEvolucion: [{ problema: "MARCADORBACKLOG automatizar reportes", momentoSugerido: "tras la fase 1" }], + notasInternas: ["MARCADORINTERNO el socio decide, no el que vino"], + }, +}; +const datos: DatosPropuestaPDF = { + propuesta, economia: econ, cliente: "Cliente", 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" }, incluirAnexoInterno: false, +}; + +async function main() { + const pdf = await generarPropuestaPDF(datos); + const txt = textoDePdf(pdf); + check("se genera el PDF", pdf.length > 1000, `${pdf.length} bytes`); + for (const marca of ["MARCADORTITULO", "MARCADORSUB", "MARCADORCITA", "MARCADORHALLAZGO", "MARCADORVALOR", "MARCADORRESULTADO", "MARCADORALCANCE", "MARCADORBENEFICIO", "MARCADOREXCLUSION", "MARCADORMATERIAL", "MARCADORBACKLOG"]) { + check(`el documento del cliente incluye ${marca}`, txt.includes(marca)); + } + check("el documento del cliente NO lleva notas internas", !txt.includes("MARCADORINTERNO")); + check("el documento del cliente NO lleva red flags", !txt.includes("MARCADORFLAG")); + check("el precio sale del codigo, no de la IA", txt.includes("12,000") || txt.includes("12000")); + check("el total con IVA aparece", txt.includes("13,920") || txt.includes("13920")); + + const anexo = await generarAnexoInternoPDF(datos); + const txtA = textoDePdf(anexo); + check("el anexo se genera", anexo.length > 1000, `${anexo.length} bytes`); + check("el anexo se marca como interno", 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 calcula el ratio", /8\.4|8,4/.test(txtA), "13920/166400 = 8.4%"); + + // Color invalido no debe romper el PDF (la API de configuracion no valida hex). + const conColorMalo = await generarPropuestaPDF({ ...datos, branding: { colorPrimario: "