Files
cotizador/docs/superpowers/plans/2026-07-28-propuesta-consultiva-ia.md
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

1961 lines
85 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`**
```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<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`:
```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<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**
```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<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`:
```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<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`**
```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<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`:
```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<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:
```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<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**
```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<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`.
```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<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`:
```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**
```bash
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**
```ts
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**
```ts
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**
```bash
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:
```tsx
<PropuestaIAPanel cotizacionId={cot.id} numero={cot.numero} />
```
- [ ] **Step 3: Verificar en local**
```bash
npx tsc --noEmit && npm run lint && npm run build
```
- [ ] **Step 4: Commit**
```bash
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**
```bash
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**
```bash
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 |