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]>
1961 lines
85 KiB
Markdown
1961 lines
85 KiB
Markdown
# 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 |
|