Files
cotizador/docs/superpowers/plans/2026-07-28-propuesta-consultiva-ia.md
T
urieljarethandClaude Opus 5 ce3a38f89d Propuesta consultiva con IA: rutas, panel de edicion y tercer documento
Cierra la funcionalidad: el Cotizador ahora ofrece tres descargas por cotizacion
—Excel, PDF economico y PDF consultivo con marca— mas un anexo interno aparte.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:25:39 -06:00

85 KiB
Raw Blame History

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<DatosEconomicos>, 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

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:

  propuestasIA      PropuestaIA[]
  • Step 2: Escribir la migración aditiva

prisma/migrations/20260729000000_add_propuesta_ia/migration.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
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<DatosEconomicos> {
  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:

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
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<string, unknown>.

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
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<typeof hechosSchema>;
export type Diagnostico = z.infer<typeof diagnosticoSchema>;
export type Redaccion = z.infer<typeof redaccionSchema>;

/** 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<string, unknown> {
  const js = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
  delete js.$schema; // MiniMax no lo espera
  return js;
}
  • Step 2: Escribir el script de verificación

scripts/verificar-propuesta-schemas.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
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<T>(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
# --- 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
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<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.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:

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
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

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:

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
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

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<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 = 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:

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
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

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<Hechos>({
    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<Diagnostico>({
    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<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.", 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
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<Buffer>, 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.

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<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",
};

/** 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<Buffer> {
  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<Buffer>((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<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, 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:

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: "</style><script>" } });
  check("un color invalido no rompe el PDF", conColorMalo.length > 1000);

  console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
  process.exit(fallas === 0 ? 0 : 1);
}
main();
  • Step 3: Correr la verificación

Run: npx tsx scripts/verificar-propuesta-pdf.ts Expected: todo paso

  • Step 4: Commit
git add src/lib/propuesta/pdf.ts scripts/verificar-propuesta-pdf.ts
git commit -m "Propuesta IA: generador PDF del documento consultivo y del anexo interno"

Task 8: Rutas de API

Files:

  • Create: src/app/api/propuesta-ia/[id]/route.ts
  • Create: src/app/api/propuesta-ia/[id]/pdf/route.ts

Interfaces:

  • Consumes: cargarEconomia, generarPropuesta, validarPropuesta, generarPropuestaPDF, generarAnexoInternoPDF.

  • Produces: POST /api/propuesta-ia/:id (genera), GET /api/propuesta-ia/:id (lee la última), PATCH /api/propuesta-ia/:id (guarda la edición del asesor), GET /api/propuesta-ia/:id/pdf?anexo=1 (descarga).

  • Step 1: Escribir la ruta principal

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuesta } from "@/lib/propuesta/pipeline";
import { validarPropuesta } from "@/lib/propuesta/validacion";
import { hechosSchema, diagnosticoSchema, redaccionSchema, type PropuestaConsultiva } from "@/lib/propuesta/schemas";
import { z } from "zod";

const propuestaCompletaSchema = z.object({
  hechos: hechosSchema,
  diagnostico: diagnosticoSchema,
  redaccion: redaccionSchema,
});

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(Boolean).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." }, { 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,
    });

    // Las notas internas del asesor son ENTRADA: R0 comprueba que no se colaron a la salida.
    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 }> }) {
  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,
  });
}

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" }, { status: 404 });

    // Revalidar la edicion del asesor: puede haber introducido una fuga o un USD.
    const economia = await cargarEconomia(id);
    const ctx = await 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 });
  }
}
  • Step 2: Escribir la ruta del PDF
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";

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: "No hay propuesta generada" }, { 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,
      incluirAnexoInterno: false,
    };

    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 });
  }
}
  • Step 3: Typecheck y commit
npx tsc --noEmit
git add src/app/api/propuesta-ia
git commit -m "Propuesta IA: rutas de generacion, edicion y descarga"

Task 9: Panel de UI con edición manual

Files:

  • Create: src/components/PropuestaIAPanel.tsx
  • Modify: src/app/(app)/cotizaciones/[id]/page.tsx

Interfaces:

  • Consumes: las rutas de la Task 8; useToast y useConfirm de @/components/ui/DialogProvider.

  • Produces: export default function PropuestaIAPanel(props: { cotizacionId: string; numero: string }).

  • Step 1: Escribir el panel

Requisitos de comportamiento:

  • Botón "Generar propuesta con IA" con un <textarea> opcional para pegar la transcripción.

  • Mientras genera, deshabilitar y mostrar que los tres pasos tardan (puede pasar de un minuto).

  • Al terminar, mostrar los avisos arriba: bloqueantes en rojo, advertencias en ámbar. Deben verse antes que el botón de descarga, para que revisar sea más fácil que aprobar.

  • Campos editables: título, subtítulo, cada hallazgo (título y descripción), cada descripción de alcance, cada beneficio y cada exclusión. Al guardar, PATCH y revalidar.

  • Dos botones de descarga: "Propuesta consultiva (PDF)" y "Anexo interno (PDF)", el segundo con estilo de advertencia y el texto NO ENVIAR.

  • Usar useToast para el resultado, nunca alert() (convención del proyecto: AGENTS.md, sección UI Conventions).

  • Step 2: Montar el panel en la página de detalle

En src/app/(app)/cotizaciones/[id]/page.tsx, después del bloque de notas internas:

<PropuestaIAPanel cotizacionId={cot.id} numero={cot.numero} />
  • Step 3: Verificar en local
npx tsc --noEmit && npm run lint && npm run build
  • Step 4: Commit
git add src/components/PropuestaIAPanel.tsx "src/app/(app)/cotizaciones/[id]/page.tsx"
git commit -m "Propuesta IA: panel de generacion, revision y edicion"

Task 10: Prueba de extremo a extremo y despliegue

  • Step 1: Correr todas las verificaciones
npx tsx scripts/verificar-propuesta-economia.ts
npx tsx scripts/verificar-propuesta-schemas.ts
npx tsx scripts/verificar-propuesta-cliente.ts
npx tsx scripts/verificar-propuesta-prompts.ts
npx tsx scripts/verificar-propuesta-validacion.ts
npx tsx scripts/verificar-propuesta-pdf.ts
npx tsc --noEmit && npm run lint && npm run build
  • Step 2: Prueba con la API real

Requiere MINIMAX_API_KEY en .env (rotada). Generar contra una cotización real y revisar el PDF a ojo.

  • Step 3: Aplicar la migración y desplegar
npx prisma migrate deploy   # local
git push gitea main && git push origin main

Verificar en producción que la tabla PropuestaIA existe y que los datos de Cotizacion siguen intactos.


Riesgos conocidos

Riesgo Mitigación
El caché de MiniMax puede no funcionar como se asume La traza guarda cacheLectura. Si sale 0 en llamadas repetidas, hay un invalidador escondido
stop_reason: "max_tokens" podría truncar el tool_use El reintento lo captura como error de validación de Zod
Los umbrales del ratio son de escritorio Se calibran con los primeros 10 documentos; la lectura se imprime junto al número, no en lugar del número
El asesor aprueba sin leer Los avisos van arriba del botón de descarga, no debajo
La transcripción cruda viaja a MiniMax Decisión consciente (filtro de salida, no de entrada). Queda escrito en el spec