Compare commits

..
19 Commits
Author SHA1 Message Date
urieljarethandClaude Opus 5 3a1eaf6e5b Propuesta IA: aflojar las restricciones que peleaban con una respuesta honesta
Medicion de 3 vueltas mas contra datos reales. El aplanado del commit anterior
NO redujo los reintentos: subieron de 3 a 10. Se mantiene porque quitar ese
nivel es correcto de todos modos, pero no cumplio su proposito.

Lo que si salio de esa medicion son dos restricciones mias que provocaban el
rechazo:

- min(5) en los textos de materiales, decisiones, menciones y red flags. Con una
  transcripcion pobre no hay nada que listar, asi que el modelo mete "N/A" o un
  guion para rellenar y falla. Ocurrio 4 veces SEGUIDAS en una vuelta. Baja a
  min(3), y el prompt ahora dice explicitamente que devolver el array vacio es la
  respuesta correcta y que no rellene.
- beneficios.etiqueta.max(40) se pasaba en 2 de 3 vueltas. Sube a 70.

Es el mismo patron que el tope de 40 partidas: el schema peleandose con la
realidad, no el modelo fallando.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:25:20 -06:00
urieljarethandClaude Opus 5 0c127b44c4 Propuesta IA: cerrar los hallazgos medios de la auditoria
El mas urgente lo cause yo al hacer que el bloqueo bloqueara de verdad: si un
aviso bloqueante caia en un campo que el panel no dejaba editar, el asesor
quedaba sin salida salvo regenerar. Ocho campos eran editables y bastantes mas
se imprimen al cliente. Ahora se pueden editar tambien la cita destacada y su
autor, las dimensiones del valor y su nota de metodologia, los resultados con su
metrica y periodo, los materiales, las decisiones con quien decide, y el backlog.
El boton de descarga del documento del cliente aparece deshabilitado cuando hay
bloqueantes, en vez de invitar a un clic que devuelve 409.

Doble propuesta: se rechaza generar. El documento asume UNA lista de alcance y
UNA caja de totales, asi que sumaria las dos opciones —que son alternativas
excluyentes— y mostraria un total que no existe. Soportarlas es rediseniar el
documento; mientras tanto es mejor no producir uno incorrecto. Hoy no hay
cotizaciones dobles en produccion, asi que no bloquea a nadie.

La transcripcion ahora se guarda con la propuesta (columna nueva, migracion
aditiva). Solo vivia en memoria durante la generacion, asi que al editar y
revalidar, R3 se quedaba sin fuente contra la cual comprobar que la cita
destacada siguiera siendo literal, y el aviso desaparecia solo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:15:03 -06:00
urieljarethandClaude Opus 5 434a427f70 Propuesta IA: aplanar el nivel calculo y subir el tope del subtitulo
Segunda tanda de rechazos observados contra datos reales (3 vueltas mas):

- Cada dimension del valor del problema tenia sus factores dentro de un objeto
  `calculo`. Ese nivel no aportaba nada semantico y era justo donde el modelo se
  perdia: devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Ahora
  `factores` y `montoAnualMXN` cuelgan de la dimension. Un nivel menos de
  anidamiento en el punto exacto donde fallaba.
- subtitulo.max(300) se quedaba corto. Sube a 400.

La primera tanda de arreglos ya habia bajado los rechazos de 7 a 3 en tres
vueltas, y de 3 vueltas con reintento en la redaccion a solo 1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:07:39 -06:00
urieljarethandClaude Opus 5 bdd9319fd0 Propuesta IA: corregir lo que encontro la auditoria de herramientas y plantillas
La auditoria confirma lo que se pedia verificar: la IA SOLO puede usar las tres
herramientas internas. Una por llamada, ninguna se ejecuta jamas (el input del
modelo solo va a schema.safeParse, no hay despachador), y no se envia ningun
tool del proveedor, web search, ejecucion de codigo, MCP ni beta.

Lo que NO cumplia era la otra mitad, la correspondencia con las plantillas:

CRITICO - el documento cobraba un IVA que la cotizacion no cobra. Cada partida
se imprimia a precio SIN IVA y debajo un unico total CON IVA, rematado con
"Importes con IVA incluido", mientras el PDF economico del mismo correo dice
"los precios no incluyen IVA" sobre las mismas cifras. Las lineas no sumaban su
propio total. Ahora la caja de totales desglosa subtotal / IVA / total como la
plantilla autorizada, las lineas siguen sin IVA igual que el otro documento, y
la nota al pie dice lo que de verdad hacen las lineas.

CRITICO - los avisos bloqueantes no bloqueaban nada. hayBloqueantes() no se
llamaba en ningun sitio y la ruta del PDF nunca leia avisos: el documento del
cliente se descargaba igual con una fuga de notas internas dentro. Ahora
devuelve 409 con la lista de lo que hay que corregir. El anexo interno si se
permite: es justo el que el asesor necesita para arreglarlo.

ALTO - seis campos que SI se imprimen al cliente no pasaban por los filtros de
marca, moneda, garantias y PII: el texto y el autor de la cita destacada, la
metrica y el periodo de cada resultado, quien decide cada pendiente, y el
momento del backlog. textoVisible ahora los cubre y queda documentado que debe
seguir a lo que dibuja el PDF.

ALTO - el plan Bucefalo se caia del documento y de sus totales, aunque si
aparece en el PDF economico y en el Excel: cargarEconomia nunca leia la
relacion. El cliente recibia dos documentos con alcances distintos.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:01:34 -06:00
urieljarethandClaude Opus 5 3f46d8a734 Propuesta IA: quitar los rechazos autoinfligidos y tolerar rarezas de MiniMax
Seis vueltas completas del pipeline contra los datos reales de UJ2606UR001
mostraron que la mayoria de los reintentos los provocaba el propio schema, no
el modelo:

- alcance.max(40) contra una cotizacion de 58 partidas garantizaba un rechazo de
  Zod en CADA corrida. Sube a 120. El tope solo acota una respuesta desbocada;
  la completitud del documento ya no depende de este array desde d20007d2.
- titulo.max(80) se quedaba corto y costo un reintento en 2 de 6 corridas.
  Sube a 140.

Y dos rarezas del proveedor, ambas observadas contra la API real:

- MiniMax a veces envuelve los elementos de un array en {item: {...}}. Se
  normaliza antes de validar, solo cuando "item" es la unica clave, para no
  tocar un campo legitimo con ese nombre.
- A veces emite DOS bloques tool_use en una respuesta. Antes se tomaba el
  primero a secas; ahora se prueban todos y gana el que valide.

Sobre el error de tipo de documento que se vio en produccion: NO se reprodujo en
seis corridas completas contra los mismos datos, y la generacion que lo siguio
completo sin problema (la fila quedo en la tabla). La evidencia apunta a un
fallo transitorio del proveedor, no a un defecto determinista nuestro. En vez de
inventar un arreglo para algo que no se puede reproducir, se reintentan los
fallos transitorios (5xx, 429, timeouts, red y los 400 con mensaje de parseo
interno) con espera creciente.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 20:54:48 -06:00
urieljarethandClaude Opus 5 d20007d252 Propuesta IA: el documento lista TODAS las partidas de la cotizacion
Encontrado con datos reales de produccion. UJ2606UR001 tiene 58 partidas y el
schema acota alcance a 40, asi que 18 servicios cotizados desaparecian del
documento del cliente en silencio: aparecian en el PDF economico pero no en el
consultivo, para el mismo envio.

El arreglo no es subir el tope. El generador ahora recorre las partidas de la
COTIZACION, no las que la IA alcanzo a describir, y usa la prosa de la IA cuando
existe. Si falta, imprime el detalle del catalogo (entregables y tiempo de
entrega) como respaldo. La completitud la manda la base de datos; la IA solo
aporta la redaccion. Es el mismo principio que ya rige para el dinero.

Verificado contra la propuesta real que genero produccion: 58 de 58 presentes,
cero faltantes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 20:40:43 -06:00
urieljarethandClaude Opus 5 7682fcb86b Coolify: pasar las variables MINIMAX_* al contenedor web
El compose filtra que variables llegan al contenedor. Sin declararlas aqui,
ponerlas en el panel de Coolify no habria servido de nada: el boton de generar
propuesta habria fallado con 'Falta MINIMAX_API_KEY' aun estando configurada.

Editados los dos archivos, como exige AGENTS.md: docker-compose.yaml es una copia
identica de coolify.yml que existe para la deteccion por defecto de Coolify, y
editar solo uno despliega el viejo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 19:03:35 -06:00
urieljarethandClaude Opus 5 74c0374a2a Propuesta IA: correcciones encontradas probando contra la API real de MiniMax
Tres defectos que solo se veian llamando al modelo de verdad.

1. min(2) en los factores obligaba a inventar relleno. Con una dimension sin
   cifras, MiniMax produjo "herramienta_de_seguimiento_actual=0 x canal=1" solo
   para satisfacer la restriccion. Ahora los dos factores se exigen unicamente
   cuando hay montoAnualMXN.

2. El modelo OMITE montoAnualMXN en vez de mandar null explicito, que es lo
   natural para un LLM. Exigirlo presente quemaba los tres intentos del paso.
   Ahora es .default(null) y la omision se tolera.

3. Sin confianza por factor, el modelo la metia dentro del nombre
   ("tasa_conversion (por_validar)=0.1"). Ahora es un campo.

Y el hallazgo que mas importa, porque es comercial y no de formato: el modelo
puede OMITIR un factor y aun asi cuadrar la aritmetica. En una corrida calculo
40 mensajes x 0.5 sin contestar x 52 semanas x 3,000 de utilidad POR CLIENTE =
3,120,000, asumiendo que cada mensaje sin responder es un cliente perdido. R5 lo
acepto porque los factores si multiplican al monto: R5 no puede ver lo que falta.

El ratio habria dicho "subcotizado" con el denominador inflado diez veces. Como
no se puede impedir que el modelo produzca estimaciones plausibles y erroneas, lo
que se hace es que el asesor las cache de un vistazo:
- R5b avisa cuando una cifra no tiene ni un factor confirmado.
- El anexo interno desglosa cada factor con su confianza, y alerta en rojo si el
  valor descansa entero en estimaciones.

Ademas, el presupuesto de reintentos sube (5 en extraccion, 4 en los otros dos):
MiniMax se equivoca de array de vez en cuando con schemas anidados, y la
extraccion es el paso fundacional. Una corrida real necesito los 5.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:59:42 -06:00
urieljarethandClaude Opus 5 ce3a38f89d Propuesta consultiva con IA: rutas, panel de edicion y tercer documento
Cierra la funcionalidad: el Cotizador ahora ofrece tres descargas por cotizacion
—Excel, PDF economico y PDF consultivo con marca— mas un anexo interno aparte.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:25:39 -06:00
urieljarethandClaude Opus 5 91db8cc55c Propuesta IA: pipeline de tres pasos y generadores PDF
El documento del cliente sigue la arquitectura argumental de la plantilla de
referencia pero en tema claro con PDFKit: la plantilla original es oscura, y en
impresion eso depende de una casilla del navegador desactivada por defecto.

El anexo interno va en archivo separado, no como seccion oculta.

44 comprobaciones sobre PDFs reales: contenido presente, notas internas y red
flags ausentes del documento del cliente, precios inyectados por el codigo,
branding invalido tolerado y contenido largo multipagina.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:22:02 -06:00
urieljarethandClaude Opus 5 45f166c116 Propuesta IA: reglas de validacion R0-R6 y filtro de contenido
R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son
una ENTRADA que el modelo recibe en el prompt y puede copiar literalmente.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:17:30 -06:00
urieljarethandClaude Opus 5 7f375b3564 Propuesta IA: prompts en capas, capa 1 cacheable y sin fugas de dinero
Verificado que ni el precio ni el cuid de ServicioCotizado aparecen en ningun
prompt: el modelo solo ve refPartida. P1 deja de depender de una validacion.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:15:49 -06:00
urieljarethandClaude Opus 5 877a181a03 Propuesta IA: cliente MiniMax con tool-calling y reintentos
El contrato se fuerza con el input_schema de la herramienta porque MiniMax no
soporta structured outputs. tool_choice no se envia: fijarlo solo en el reintento
le daria un prefijo distinto y se pagaria el contexto completo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:14:02 -06:00
urieljarethandClaude Opus 5 5bad273977 Propuesta IA: schemas Zod de los tres pasos, sin campos de dinero de E3
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:12:54 -06:00
urieljarethandClaude Opus 5 4e103beb27 Propuesta IA: modelo PropuestaIA y capa de datos economicos
La capa de economia existe para que el dinero viva en un solo sitio y nunca
cruce hacia el prompt. El pipeline recibe de ahi solo refPartida y nombre.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:10:51 -06:00
urieljarethandClaude Opus 5 20f535d485 Retirar el servidor MCP y fijar todas las dependencias del API
MCP: se retira por completo (endpoint, paquete api/app/mcp/ y dependencia).
Tres razones, en orden de peso:

- Nadie lo usa.
- Llevaba roto desde antes de este trabajo. Con credencial valida devolvia 500:
  el handler construia un StreamableHTTPServerTransport nuevo por peticion, sin
  manejo de sesion. Lo que estaba expuesto a internet era la puerta abierta de un
  cuarto averiado.
- Su SDK sin fijar tumbo el API entero en produccion al saltar a 2.0.0.

Retirarlo es una mitigacion mas fuerte que autenticarlo, que fue lo que hizo el
commit anterior. Recuperable con `git show edb500f5:api/app/mcp/server.py`.
Se limpia tambien la ruta "/mcp" que el endpoint raiz seguia anunciando, y las
menciones a MCP del docstring y la descripcion de Swagger.

Dependencias: once de las doce eran rangos `>=` sin techo, o sea que cada
reconstruccion era una tirada de dados contra PyPI. `pydantic>=2.0` habria
aceptado pydantic 3 con la misma alegria con la que `mcp>=1.0.0` acepto 2.0.0.
Ahora todas van fijadas a la version exacta que corre sana en produccion,
capturada con pip freeze del contenedor healthy.

El lado Next.js ya era reproducible via package-lock.json; por eso el web nunca
se cayo durante el incidente y el api si.

Docs actualizados: AGENTS.md, README.md, api/COTIZADOR_API_SKILL.md y el spec,
que ademas registra en su seccion 0 las tres desviaciones de Fase 0 respecto a
lo disenado (el Despliegue B cancelado, la retirada del MCP y la deriva de
dependencia).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 17:51:21 -06:00
urieljarethandClaude Opus 5 edb500f517 Fix: fijar mcp a la serie 1.x y evitar que su fallo tumbe el REST
El primer rebuild del api en dos semanas trajo mcp 2.0.0, que elimino
Server.list_tools(). app/mcp/server.py lo usa como decorador en su linea 27,
asi que el import lanzaba AttributeError al arrancar.

El agravante: main.py envolvia el montaje del MCP en `except ImportError`.
Un AttributeError no es ImportError, asi que se escapaba y tumbaba toda la
aplicacion. En produccion el contenedor quedo en crash-loop y el REST dejo de
responder por completo, no solo el MCP.

Dos arreglos:
- requirements.txt fija `mcp>=1.28.1,<2.0.0`. La imagen anterior que llevaba dos
  semanas sana tenia 1.28.1; el rango abierto `>=1.0.0` permitio el salto mayor.
- main.py captura cualquier excepcion al montar el MCP y la registra. El servidor
  MCP es opcional; el REST no. Si el MCP no monta, la API sigue de pie.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:45:53 -06:00
urieljarethandClaude Opus 5 9023384de9 Fase 0: cerrar el MCP, unificar totales y separar el contexto interno del cliente
Primera fase del plugin de propuesta consultiva (docs/superpowers/specs/
2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md). Recupera el contexto
humano que hoy se captura y se descarta, y cierra los bloqueadores que el
analisis previo destapo.

Seguridad (lo mas urgente):
- POST /mcp no tenia NINGUNA autenticacion: cero Depends() en main.py, mientras
  el servicio recibe dominio publico en produccion (SERVICE_FQDN_API_8000).
  Cualquiera en internet podia leer y escribir cotizaciones. Ahora exige
  require_auth, que ya existia en app/auth.py y no se estaba usando ahi.
- _obtener_cotizacion hacia SELECT c.* y devolvia dict(cot) al agente, asi que
  cualquier columna nueva se publicaba sola. Ahora usa lista blanca espejo de
  CotizacionResponse, excluyendo observacionesInternas.

Totales:
- Nueva calcularTotalesCotizacion() en calculators.ts como fuente de verdad
  unica. El calculo estaba duplicado a mano en siete consumidores.
- conIva() sustituye a los `* 1.16` hardcodeados de pdf-generator y
  excel-builder, que ignoraban IVA_RATE y el flag incluirIva.

Contexto humano:
- Campo nuevo Cotizacion.observacionesInternas. La migracion es PURAMENTE
  ADITIVA: no mueve ni una fila. El movimiento de datos no hace falta porque la
  unica fila de produccion con observaciones ya contiene texto dirigido al
  cliente, y separarlo en dos despliegues mantiene el rollback limpio.
- Dos textareas visualmente inconfundibles en el formulario.
- observaciones se imprime por primera vez en el PDF y el Excel; el dato ya
  viajaba hasta las rutas de borrador y se tiraba.
- observacionesInternas solo se ve en la app. La garantia es estructural: el
  campo no existe en CotizacionPDFData ni en ExcelData, asi que el generador no
  puede filtrarlo aunque alguien lo intente.

Bugs vecinos:
- orderBy explicito en las cuatro rutas de export: el PDF asume las partidas
  agrupadas por fase y sin orderBy podia diferir del Excel del mismo envio.
- El PDF de borrador leia solo el branding, no los datos bancarios, e imprimia
  los hardcodeados del generador.
- detalleModelo local en PDF y Excel omitia la rama "demanda": esa partida
  salia en $0 y sin explicacion. Ahora delegan en la version canonica.
- Los bonos salen de la tabla Bono; la lista hardcodeada queda de respaldo y su
  texto ya no coincidia con el seed.
- La palomita de los bonos mide 0pt en las fuentes base de PDFKit (verificado),
  o sea que salia como dos espacios. Sustituida por una vineta.

Ademas: zod pasa a ser dependencia declarada. Se importaba en schemas.ts y
resolvia transitivamente, asi que un npm ci --omit=dev reventaba.

Verificado con build, lint y 22 comprobaciones funcionales sobre PDF y Excel
reales (texto del cliente presente, texto interno ausente incluso inyectandolo
a la fuerza, texto largo multipagina, y totales con y sin IVA).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:50:03 -06:00
urieljarethandClaude Opus 5 0ff783fd65 Spec: diseño de Fase 0 y Fase 1 del plugin de propuesta consultiva con IA
Convierte el análisis de negocio de docs/BI-propuesta-consultiva-IA.md en una
especificación cerrada para las dos primeras fases del roadmap.

El análisis previo destapó cuatro problemas que el documento de negocio no
anticipaba, los cuatro verificados en disco:

- POST /mcp no tiene autenticación (api/main.py:107) y _obtener_cotizacion hace
  SELECT c.* sin lista blanca, con dominio público en producción. Publicaría la
  columna de notas internas sin tocar una línea de código.
- No existe una fuente de verdad para los totales: el cálculo está duplicado en
  siete consumidores y el IVA está hardcodeado como * 1.16 en PDF y Excel.
- El Excel es un documento del cliente, no una herramienta interna. El diseño
  inicial proponía poner ahí las notas internas; se corrigió.
- Las transcripciones se sincronizarían a la nube de MEGA: .megaignore solo
  excluye .next, node_modules y .turbo.

También reconcilia nueve contradicciones de contrato entre los diseños
explorados (clave de partida, forma de la evidencia, nombres del valor anual)
que habrían hecho que la capa de validación validara el vacío.

Actualiza AGENTS.md, que había quedado desfasado: 14 modelos y 10 migraciones
(decía 13 y 3), el registro de horas, la doble propuesta, los modelos de cobro
y la convención de no usar diálogos nativos del navegador.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:20:03 -06:00
51 changed files with 7166 additions and 888 deletions
+8
View File
@@ -28,3 +28,11 @@ SEED_ADMIN_NAME=Administrador
SEED_ASESOR_EMAIL= SEED_ASESOR_EMAIL=
SEED_ASESOR_PASSWORD= SEED_ASESOR_PASSWORD=
SEED_ASESOR_NAME= SEED_ASESOR_NAME=
# --- Propuesta consultiva con IA (MiniMax) ---
# Clave del panel de MiniMax. NUNCA se commitea: .env esta en .gitignore.
# Ojo con el prefijo: NO uses ANTHROPIC_API_KEY. El SDK la lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
MINIMAX_API_KEY=
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
MINIMAX_MODEL=MiniMax-M3
+48 -13
View File
@@ -7,15 +7,19 @@
| `npm run dev` | Start Next.js dev server on port 3000 | | `npm run dev` | Start Next.js dev server on port 3000 |
| `npm run build` | Production build (runs TypeScript check) | | `npm run build` | Production build (runs TypeScript check) |
| `npm run lint` | ESLint (flat config, eslint-config-next) | | `npm run lint` | ESLint (flat config, eslint-config-next) |
| `npx tsx prisma/seed.ts` | Run seed (upserts all data, idempotent) | | `npm run db:seed` | Run seed (upserts all data, idempotent) |
| `npx prisma migrate dev` | Create/apply migration | | `npm run db:migrate` | Create/apply migration (`prisma migrate dev`) |
| `npx prisma generate` | Regenerate Prisma client | | `npm run db:generate` | Regenerate Prisma client |
| `npx prisma studio` | Prisma Studio GUI | | `npm run db:studio` | Prisma Studio GUI |
**There is no test suite** — no runner, no test files, in either backend. Verification is `npm run build` (typechecks) + `npm run lint`. Don't claim a change is verified on the strength of a build alone; exercise the affected route or page.
**Windows environment.** Use `start.bat` / `stop.bat` to manage Docker PostgreSQL + Next.js together. PowerShell is the shell. Paths with brackets (e.g. `[id]`) require `-LiteralPath` in PowerShell, not `-Path`. **Windows environment.** Use `start.bat` / `stop.bat` to manage Docker PostgreSQL + Next.js together. PowerShell is the shell. Paths with brackets (e.g. `[id]`) require `-LiteralPath` in PowerShell, not `-Path`.
**Two backends, one database.** The Next.js app (`src/`, port 3000) and a standalone Python FastAPI service (`api/`, port 8000) both talk to the same PostgreSQL DB. Prisma owns the schema/migrations; the Python API reads/writes the same tables independently. `docker-compose.yml` runs `postgres` + the `api` service; Next.js is run separately via `npm run dev` / `start.bat`. **Two backends, one database.** The Next.js app (`src/`, port 3000) and a standalone Python FastAPI service (`api/`, port 8000) both talk to the same PostgreSQL DB. Prisma owns the schema/migrations; the Python API reads/writes the same tables independently. `docker-compose.yml` runs `postgres` + the `api` service; Next.js is run separately via `npm run dev` / `start.bat`.
**Three compose files, and two of them are the same file.** `docker-compose.yaml` is a byte-identical copy of `docker-compose.coolify.yml` — it exists only so Coolify's default detection finds the production stack. Local dev is `docker-compose.yml` (postgres with port 5432 published + api). Because `.yml` and `.yaml` both exist, a bare `docker compose` warns about ambiguity before resolving to `docker-compose.yml`; pass `-f docker-compose.yml` explicitly, as `start.bat` does. **When you edit the Coolify stack, edit both `docker-compose.coolify.yml` and `docker-compose.yaml`** or Coolify deploys the stale copy.
## Prisma 7 — Critical Gotchas ## Prisma 7 — Critical Gotchas
- **Prisma client is NOT at `@prisma/client`.** It's generated to `src/generated/prisma/` and imported as `@/generated/prisma/client`. - **Prisma client is NOT at `@prisma/client`.** It's generated to `src/generated/prisma/` and imported as `@/generated/prisma/client`.
@@ -46,6 +50,25 @@
- **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly). - **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly).
- **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical. - **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical.
### Charge models (`ServicioCotizado.modeloCobro`)
A quoted line item is `fijo` (default), `horas`, `retainer`, or `demanda` — see `MODELOS_COBRO` in `calculators.ts`. The time-based three are `MODELOS_COBRO_TIEMPO`. Supporting fields: `esPersonalizado`, `horas`, `tarifaHora`, `montoMinimo`, `horasIncluidas`. `TARIFA_HORA_DEFAULT = 700`.
**`precio` is always the authoritative total.** For retainer and demanda lines, `horas × tarifaHora` is display/comparison metadata only — never re-derive a total from it. `calcularTotalesOpcion` and the PDF/Excel builders all sum `precio`.
### Doble propuesta (two-option quotes)
`Cotizacion.esDoble` turns one quote into two comparable proposals. Each `ServicioCotizado.opcion` is `"1"`, `"2"`, or `"ambas"` (shared by both). `opcionesMetadata` (Json) holds per-option `titulo` / `descripcion` / `noIncluye` (`MetaOpcion`). `calcularTotalesOpcion(servicios, "1" | "2")` sums lines matching that option **plus** all `"ambas"` lines. Anything that renders or totals a quote must handle both the single and double shape.
### Registro de horas (hours log → payment notes)
`RegistroHoras` tracks worked time against a quote so the client can be billed for it. Only offered when `esCotizacionPorTiempo(servicios)` is true (i.e. some line uses a time-based `modeloCobro`).
- `estadoPago` is `"por_pagar"` (default) or `"pagada"`, with `fechaPago` stamped on transition. The two buckets must stay separated in totals — pending is what goes on the payment note, paid is history. `resumenPagoHoras` / `esPagada` in `calculators.ts` are the only place that logic should live.
- Routes: `GET|POST /api/cotizaciones/[id]/horas` (GET takes `?from=&to=` day filters), `PATCH|DELETE .../horas/[registroId]`, and `POST .../nota-horas` which renders the PDF **server-side** via `src/lib/nota-horas-pdf.ts` (no browser URL).
- Hours are entered as `horaInicio`/`horaFin` strings and converted by `calcularHorasRango`. Dates come in as `YYYY-MM-DD` and must go through `fechaRegistroDesdeISO` to avoid UTC off-by-one — don't `new Date(iso)` directly.
- Grouping for display/PDF: `ModoAgrupacion` = `detalle | dia | semana | mes` via `periodoAgrupacion`.
## Auth ## Auth
- **JWT sessions** (`src/lib/auth.ts`) signed with `jose` (HS256, 7-day expiry), stored in the `cotizador-session` httpOnly cookie. `JWT_SECRET` env var is **required** (throws at startup if missing). - **JWT sessions** (`src/lib/auth.ts`) signed with `jose` (HS256, 7-day expiry), stored in the `cotizador-session` httpOnly cookie. `JWT_SECRET` env var is **required** (throws at startup if missing).
@@ -66,27 +89,39 @@
``` ```
src/ src/
app/ app/
(app)/ # Authed route group: dashboard, cotizaciones, clientes, catalogo, configuracion (has its own layout.tsx + Sidebar) (app)/ # Authed route group: dashboard, cotizaciones, clientes, catalogo, configuracion (has its own layout.tsx + Sidebar + DialogProvider)
api/ # Next.js route handlers (REST): auth, catalogo, categorias, cotizaciones, configuracion, paquetes, export, import api/ # Next.js route handlers (REST): auth, catalogo, categorias, cotizaciones (+ horas, nota-horas, precio),
# configuracion, paquetes, export, import, health
login/ # Public login page login/ # Public login page
components/ # CotizacionForm, ExportButtons, EstadoBadge, layout/Sidebar components/ # CotizacionForm (~1.4k lines), ExportButtons, EstadoBadge, layout/Sidebar, ui/DialogProvider
lib/ # auth, db, store, calculators, pdf-generator, schemas, config-helpers lib/ # auth, db, store, calculators, schemas, config-helpers,
# pdf-generator (quote PDF), nota-horas-pdf (hours-note PDF), excel-builder
generated/prisma/ # Prisma client output (gitignored) generated/prisma/ # Prisma client output (gitignored)
prisma/ prisma/
schema.prisma # 13 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete, schema.prisma # 14 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete,
# ServicioCatalogo, ServicioPaquete, ServicioCotizado, PlanBucefaloCotizacion, # ServicioCatalogo, ServicioPaquete, ServicioCotizado, PlanBucefaloCotizacion,
# Configuracion, Bono, FinanciamientoPlan) # RegistroHoras, Configuracion, Bono, FinanciamientoPlan)
seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts
migrations/ # 3 migrations migrations/ # 10 migrations
api/ # Standalone Python FastAPI + MCP server (see below) api/ # Standalone Python FastAPI service (see below)
docs/ # Business/product notes (Spanish), not code docs
``` ```
- **Zustand store** (`src/lib/store.ts`) holds the cotización draft. Used by both the new (`cotizaciones/nueva`) and edit (`cotizaciones/[id]/editar`) pages, both of which render `CotizacionForm.tsx`. - **Zustand store** (`src/lib/store.ts`) holds the cotización draft. Used by both the new (`cotizaciones/nueva`) and edit (`cotizaciones/[id]/editar`) pages, both of which render `CotizacionForm.tsx`.
- **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body). - **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body).
- **Business logic belongs in `calculators.ts`**, not in components or route handlers. It's the shared source of truth for totals, charge models, hours, phases, and formatting — and the file the Python `calculators.py` mirrors.
## UI Conventions
- **Never use the browser's native `confirm()` / `alert()` / `prompt()`.** They render as "«domain» dice…" and break the brand. Use the platform's own dialogs: `useConfirm()`, `usePrompt()`, `useToast()` from `@/components/ui/DialogProvider`, mounted once in `src/app/(app)/layout.tsx`. `confirm` and `prompt` return promises (`boolean` / `string | null`); pass `danger: true` for destructive actions.
- Everything user-facing is in **Spanish**. Code identifiers are Spanish too (`cotizacion`, `servicio`, `horas`) — match the surrounding naming rather than introducing English terms.
- Icons come from `lucide-react`. Colors use the CSS custom properties from `globals.css` (`bg-card-bg`, `border-border`, `text-primary`, `text-muted`), not hardcoded Tailwind palette values.
## Python API (`api/`) — Optional Second Backend ## Python API (`api/`) — Optional Second Backend
- **FastAPI app** (`api/main.py`) exposing the same domain as REST, **plus an MCP server at `/mcp`** for AI agents (n8n, Claude, ChatGPT). Tools/resources defined in `api/app/mcp/`. - **FastAPI app** (`api/main.py`) exposing the same domain as REST, for n8n and other integrations.
- **The MCP server was removed on 2026-07-28.** It was unused, its transport had been broken for some time (it built a `StreamableHTTPServerTransport` per request with no session handling and returned 500 even with valid credentials), and its unpinned SDK took the whole API down in production when `mcp` jumped to 2.0.0. To revive it: `git show edb500f5:api/app/mcp/server.py`.
- **`api/requirements.txt` pins exact versions on purpose.** That outage is why. Bump a dependency deliberately — never by accident on a redeploy.
- Auth: **API key** (`X-API-Key` or `Authorization: Bearer`) for agents; JWT for human login. Routers in `api/app/routers/`, business logic in `api/app/services/` (its own `calculators.py`, `pdf_generator.py`, `excel_generator.py` — mirror the TS versions). - Auth: **API key** (`X-API-Key` or `Authorization: Bearer`) for agents; JWT for human login. Routers in `api/app/routers/`, business logic in `api/app/services/` (its own `calculators.py`, `pdf_generator.py`, `excel_generator.py` — mirror the TS versions).
- Run: `cd api && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`. Swagger at `/docs`. Full endpoint reference in `api/COTIZADOR_API_SKILL.md`. - Run: `cd api && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`. Swagger at `/docs`. Full endpoint reference in `api/COTIZADOR_API_SKILL.md`.
- It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma). - It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma).
+4 -3
View File
@@ -10,7 +10,7 @@ y financiamiento opcional. Exporta a PDF y Excel.
- **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`) - **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`)
- **Base de datos:** PostgreSQL 16 (vía Docker) - **Base de datos:** PostgreSQL 16 (vía Docker)
- **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs` - **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs`
- **API alterna:** servicio Python FastAPI + servidor MCP en [`api/`](api/) (para n8n / agentes de IA) - **API alterna:** servicio Python FastAPI en [`api/`](api/) (para n8n / integraciones)
## Requisitos ## Requisitos
@@ -51,7 +51,7 @@ npm run dev # http://localhost:3000
```bash ```bash
cd api cd api
pip install -r requirements.txt pip install -r requirements.txt
uvicorn main:app --reload --port 8000 # Swagger en /docs, MCP en /mcp uvicorn main:app --reload --port 8000 # Swagger en /docs
``` ```
Referencia completa de endpoints en [`api/COTIZADOR_API_SKILL.md`](api/COTIZADOR_API_SKILL.md). Referencia completa de endpoints en [`api/COTIZADOR_API_SKILL.md`](api/COTIZADOR_API_SKILL.md).
@@ -89,7 +89,8 @@ El stack de producción está en [docker-compose.coolify.yml](docker-compose.coo
- No expongas el puerto 5432: los servicios se comunican por la red interna del compose. - No expongas el puerto 5432: los servicios se comunican por la red interna del compose.
- El volumen `postgres_data` persiste la base de datos entre deploys. No lo borres. - El volumen `postgres_data` persiste la base de datos entre deploys. No lo borres.
- El servidor MCP queda en `https://<dominio-api>/mcp` (auth por `X-API-Key`). - El API expone REST en `https://<dominio-api>` con auth por `X-API-Key` o JWT. El servidor MCP se retiro el 2026-07-28 (ver `AGENTS.md`).
- Las versiones de `api/requirements.txt` estan fijas a proposito: un rango abierto dejo entrar `mcp` 2.0.0 y tumbo el API en produccion. Sube dependencias a proposito, no al redesplegar.
- La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad. - La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad.
## Documentación para agentes ## Documentación para agentes
+10 -23
View File
@@ -418,32 +418,19 @@ granTotal = totalMensual × meses
--- ---
## MCP Integration ## MCP Integration — removed (2026-07-28)
MCP server at `/mcp` for OpenClaw, Claude, ChatGPT. The `/mcp` endpoint and the `api/app/mcp/` package no longer exist. Agents should use
the REST endpoints documented above, authenticating with `X-API-Key` or a Bearer JWT.
### Tools It was removed for three reasons: nobody was using it; its transport had been broken
| Tool | Description | for some time (it constructed a `StreamableHTTPServerTransport` per request with no
|------|-------------| session handling, returning 500 even with valid credentials); and its unpinned SDK
| `buscar_servicios` | Search catalog by phase, payment type, category, text | took the entire API down in production when `mcp` released 2.0.0 and dropped
| `crear_cotizacion` | Create complete quotation in one call | `Server.list_tools()`.
| `obtener_cotizacion` | Get quotation details |
| `listar_cotizaciones` | List with filters |
| `cambiar_estado_cotizacion` | Change status |
| `actualizar_precio_servicio` | Adjust service price |
| `duplicar_cotizacion` | Clone as draft |
| `calcular_financiamiento` | Calculate payments |
| `generar_pdf_cotizacion` | Generate PDF |
| `obtener_configuracion` | Company config |
| `listar_bonos` | Available bonuses |
| `listar_planes_bucefalo` | CRM plans |
### Resources To bring it back: `git show edb500f5:api/app/mcp/server.py`. Pin the SDK to the 1.x
| Resource | Description | series and fix the transport before mounting it again.
|----------|-------------|
| `cotizador://servicios` | Full catalog |
| `cotizador://categorias` | Categories |
| `cotizador://configuracion` | Company config |
--- ---
View File
-453
View File
@@ -1,453 +0,0 @@
"""MCP Server for the Cotizador E3 API.
Provides MCP tools and resources for AI agents (OpenClaw, Claude, ChatGPT, etc.)
to interact with the quotation system.
"""
from __future__ import annotations
import json
from datetime import datetime
from mcp.server import Server
from mcp.types import Resource, TextContent, Tool
from app.database import get_pool
from app.mcp.tools import RESOURCES, TOOLS
from app.services.calculators import (
BONOS,
PLANES_BUCEFALO,
calcular_financiamiento,
bucefalo_precio,
)
server = Server("cotizador-e3")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name=t["name"],
description=t["description"],
inputSchema=t["inputSchema"],
)
for t in TOOLS
]
@server.list_resources()
async def list_resources() -> list[Resource]:
return [
Resource(
uri=r["uri"],
name=r["name"],
description=r["description"],
mimeType=r["mimeType"],
)
for r in RESOURCES
]
@server.read_resource()
async def read_resource(uri: str) -> str:
pool = await get_pool()
async with pool.acquire() as conn:
if uri == "cotizador://servicios":
rows = await conn.fetch(
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, s.activo, s.orden,
c.nombre as categoria_nombre
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
WHERE s.activo = true
ORDER BY s.fase ASC, s.orden ASC"""
)
return json.dumps([dict(r) for r in rows], default=str)
elif uri == "cotizador://categorias":
rows = await conn.fetch(
'SELECT id, nombre, descripcion, color, activo, orden FROM "Categoria" ORDER BY orden ASC'
)
return json.dumps([dict(r) for r in rows], default=str)
elif uri == "cotizador://configuracion":
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
return json.dumps({r["clave"]: r["valor"] for r in rows})
return json.dumps({"error": f"Unknown resource: {uri}"})
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
pool = await get_pool()
async with pool.acquire() as conn:
result = await _handle_tool(conn, name, arguments)
return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
async def _handle_tool(conn, name: str, arguments: dict) -> dict:
if name == "buscar_servicios":
return await _buscar_servicios(conn, arguments)
elif name == "crear_cotizacion":
return await _crear_cotizacion(conn, arguments)
elif name == "obtener_cotizacion":
return await _obtener_cotizacion(conn, arguments)
elif name == "listar_cotizaciones":
return await _listar_cotizaciones(conn, arguments)
elif name == "cambiar_estado_cotizacion":
return await _cambiar_estado(conn, arguments)
elif name == "actualizar_precio_servicio":
return await _actualizar_precio(conn, arguments)
elif name == "duplicar_cotizacion":
return await _duplicar_cotizacion(conn, arguments)
elif name == "calcular_financiamiento":
return await _calcular_financiamiento(arguments)
elif name == "generar_pdf_cotizacion":
return await _generar_pdf(conn, arguments)
elif name == "obtener_configuracion":
return await _obtener_configuracion(conn)
elif name == "listar_bonos":
return {"bonos": BONOS}
elif name == "listar_planes_bucefalo":
return {"planes": PLANES_BUCEFALO}
else:
return {"error": f"Unknown tool: {name}"}
async def _buscar_servicios(conn, args: dict) -> dict:
conditions = ['s.activo = true']
params = []
idx = 1
if args.get("fase") is not None:
conditions.append(f's.fase = ${idx}')
params.append(args["fase"])
idx += 1
if args.get("tipo_pago"):
conditions.append(f's."tipoPago" = ${idx}')
params.append(args["tipo_pago"])
idx += 1
if args.get("categoria"):
conditions.append(f'LOWER(c.nombre) = LOWER(${idx})')
params.append(args["categoria"])
idx += 1
if args.get("busqueda"):
conditions.append(f'(LOWER(s.nombre) LIKE LOWER(${idx}) OR LOWER(s.descripcion) LIKE LOWER(${idx}))')
params.append(f"%{args['busqueda']}%")
idx += 1
where = " AND ".join(conditions)
query = f"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, c.nombre as categoria
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
WHERE {where}
ORDER BY s.fase ASC, s.orden ASC"""
rows = await conn.fetch(query, *params)
servicios = []
for r in rows:
d = dict(r)
if d.get("entregablesDefault") and isinstance(d["entregablesDefault"], str):
try:
d["entregablesDefault"] = json.loads(d["entregablesDefault"])
except (json.JSONDecodeError, TypeError):
pass
servicios.append(d)
return {"servicios": servicios, "total": len(servicios)}
async def _crear_cotizacion(conn, args: dict) -> dict:
cliente_data = args["cliente"]
servicios_data = args["servicios"]
plan_bucefalo = args.get("plan_bucefalo")
moneda = args.get("moneda", "MXN")
proyecto = args.get("proyecto", "MKT Digital")
esquema = args.get("esquema_pago", "Pago Unico/Mensual")
es_doble = bool(args.get("es_doble", False))
opciones_metadata = args.get("opciones_metadata") if es_doble else None
cliente = await conn.fetchrow(
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
cliente_data["nombre"],
cliente_data.get("empresa", ""),
)
if not cliente:
cliente = await conn.fetchrow(
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) RETURNING id',
cliente_data["nombre"],
cliente_data.get("empresa", ""),
cliente_data.get("email", ""),
cliente_data.get("telefono", ""),
)
cliente_id = cliente["id"]
now = datetime.now()
numero = f"UJ{str(now.year)[-2:]}{now.month:02d}AGENT001"
from app.services.calculators import calcular_vigencia
vigencia = calcular_vigencia(now)
async with conn.transaction():
cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, $7, $8, '', $9, $10, NOW(), NOW())
RETURNING id, numero""",
numero, now, vigencia, moneda, proyecto, esquema,
es_doble,
json.dumps(opciones_metadata) if opciones_metadata else None,
cliente_id, "agent",
)
cot_id = cot["id"]
for srv in servicios_data:
catalogo_id = srv["servicio_id"]
cat_row = await conn.fetchrow(
'SELECT id, "precioBase", "tiempoEntrega", "entregablesDefault", fase, "tipoPago" FROM "ServicioCatalogo" WHERE id = $1',
catalogo_id,
)
if not cat_row:
continue
precio = srv.get("precio_personalizado") or cat_row["precioBase"]
entregables = cat_row["entregablesDefault"]
if isinstance(entregables, str):
try:
entregables = json.loads(entregables)
except (json.JSONDecodeError, TypeError):
entregables = []
opcion = (srv.get("opcion") or "ambas") if es_doble else None
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true, NOW(), NOW())""",
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), opcion,
)
if plan_bucefalo:
nivel = plan_bucefalo if isinstance(plan_bucefalo, str) else plan_bucefalo.get("nivel", "basico")
precio_bp = bucefalo_precio(nivel)
await conn.fetchrow(
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, true, NOW(), NOW())""",
cot_id, nivel, precio_bp,
)
return {"cotizacion_id": str(cot_id), "numero": numero, "estado": "borrador", "cliente_id": str(cliente_id)}
async def _obtener_cotizacion(conn, args: dict) -> dict:
cot = await conn.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
cl.email as cliente_email, cl.telefono as cliente_telefono
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
WHERE c.id = $1""",
args["cotizacion_id"],
)
if not cot:
return {"error": "Cotización no encontrada"}
servicios = await conn.fetch(
"""SELECT sc.*, s.nombre as servicio_nombre, s.fase as servicio_fase, s."tipoPago" as "servicio_tipoPago"
FROM "ServicioCotizado" sc
LEFT JOIN "ServicioCatalogo" s ON sc."servicioCatalogoId" = s.id
WHERE sc."cotizacionId" = $1""",
args["cotizacion_id"],
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
args["cotizacion_id"],
)
result = dict(cot)
result["cliente"] = {
"nombre": cot["cliente_nombre"],
"empresa": cot["cliente_empresa"],
"email": cot["cliente_email"],
"telefono": cot["cliente_telefono"],
}
result["servicios"] = [dict(s) for s in servicios]
result["planBucefalo"] = dict(plan) if plan else None
for k in ["cliente_nombre", "cliente_empresa", "cliente_email", "cliente_telefono"]:
result.pop(k, None)
return result
async def _listar_cotizaciones(conn, args: dict) -> dict:
conditions = []
params = []
idx = 1
if args.get("estado"):
conditions.append(f'c.estado = ${idx}')
params.append(args["estado"])
idx += 1
if args.get("cliente_nombre"):
conditions.append(f'LOWER(cl.nombre) LIKE LOWER(${idx})')
params.append(f"%{args['cliente_nombre']}%")
idx += 1
if args.get("busqueda"):
conditions.append(
f'(LOWER(c.numero) LIKE LOWER(${idx}) OR LOWER(c.proyecto) LIKE LOWER(${idx}) OR LOWER(cl.nombre) LIKE LOWER(${idx}))'
)
params.append(f"%{args['busqueda']}%")
idx += 1
where = "WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.estado, c.proyecto,
c.moneda, c."esquemaPago",
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
{where}
ORDER BY c."createdAt" DESC
LIMIT 50"""
rows = await conn.fetch(query, *params)
cotizaciones = []
for r in rows:
d = dict(r)
d["cliente"] = {"nombre": d.pop("cliente_nombre"), "empresa": d.pop("cliente_empresa")}
cotizaciones.append(d)
return {"cotizaciones": cotizaciones, "total": len(cotizaciones)}
async def _cambiar_estado(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
estado = args["estado"]
cot = await conn.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cot_id)
if not cot:
return {"error": "Cotización no encontrada"}
await conn.execute('UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', estado, cot_id)
return {"ok": True, "cotizacion_id": cot_id, "nuevo_estado": estado}
async def _actualizar_precio(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
servicio_id = args["servicio_id"]
nuevo_precio = args["nuevo_precio"]
srv = await conn.fetchrow(
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
servicio_id, cot_id,
)
if not srv:
return {"error": "Servicio no encontrado en esta cotización"}
await conn.execute(
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
nuevo_precio, servicio_id,
)
return {"ok": True, "servicio_id": servicio_id, "nuevo_precio": nuevo_precio}
async def _duplicar_cotizacion(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
if not original:
return {"error": "Cotización no encontrada"}
now = datetime.now()
new_numero = f"{original['numero']}-COPY"
async with conn.transaction():
new_cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
RETURNING id, numero""",
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
original["proyecto"], original["esquemaPago"], original["incluirBonos"],
original["incluirFinanciamiento"], original["esDoble"], original["opcionesMetadata"],
original["observaciones"], original["clienteId"], original["asesorId"],
)
new_id = new_cot["id"]
servicios = await conn.fetch(
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cot_id
)
for s in servicios:
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, notas, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())""",
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["opcion"], s["seleccionado"],
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id
)
if plan:
await conn.fetchrow(
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())""",
new_id, plan["nivel"], plan["precio"], plan["seleccionado"],
)
return {"cotizacion_id": str(new_id), "numero": new_numero, "estado": "borrador"}
async def _calcular_financiamiento(args: dict) -> dict:
monto = args["monto"]
meses = args["meses"]
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
if not plan:
from app.services.calculators import FINANCIAMIENTO_PLANES
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
if not plan:
return {"error": f"Plan de {meses} meses no disponible"}
result = calcular_financiamiento(monto, meses, plan["tasa"], plan["comision"])
result["meses"] = meses
result["tasa"] = plan["tasa"]
result["comision"] = plan["comision"]
return result
async def _generar_pdf(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
cot = await conn.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
WHERE c.id = $1""",
cot_id,
)
if not cot:
return {"error": "Cotización no encontrada"}
return {
"status": "pdf_generated",
"cotizacion_id": cot_id,
"numero": cot["numero"],
"filename": f"{cot['cliente_nombre']} - {cot['numero']}.pdf",
"message": "PDF generation will be implemented with reportlab",
}
async def _obtener_configuracion(conn) -> dict:
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
return {"config": {r["clave"]: r["valor"] for r in rows}}
from app.services.calculators import FINANCIAMIENTO_PLANES
-305
View File
@@ -1,305 +0,0 @@
"""MCP (Model Context Protocol) tool definitions for the Cotizador API.
Each tool is designed to be semantically clear for AI agents like OpenClaw, Claude, and ChatGPT.
"""
TOOLS = [
{
"name": "buscar_servicios",
"description": (
"Busca servicios del catálogo de marketing digital de Consultoría E3. "
"Útil cuando el cliente pregunta por servicios disponibles, precios, o por fase del proyecto. "
"Las fases son: 0=Auditoría (diagnóstico inicial), 1=Setup (infraestructura y configuración), "
"2=Publicidad (anuncios y manejo de redes), 3=Contenido/SEO (producción de contenido y posicionamiento). "
"Tipos de pago: 'unico' (pago único) o 'mensual' (recurso recurrente)."
),
"inputSchema": {
"type": "object",
"properties": {
"fase": {
"type": "integer",
"enum": [0, 1, 2, 3],
"description": "Fase del proyecto: 0=Auditoría/Acompañamiento, 1=Setup/Infraestructura, 2=Publicidad/Manejo, 3=Contenido/SEO",
},
"tipo_pago": {
"type": "string",
"enum": ["unico", "mensual"],
"description": "Tipo de pago: 'unico' para pago único, 'mensual' para recurrente",
},
"categoria": {
"type": "string",
"description": "Nombre de categoría: SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado",
},
"busqueda": {
"type": "string",
"description": "Texto libre para buscar en nombre y descripción del servicio",
},
},
},
},
{
"name": "crear_cotizacion",
"description": (
"Crea una cotización completa de servicios de marketing digital para un cliente. "
"El cliente se crea automáticamente si no existe (busca por nombre+empresa). "
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
"y configuración de moneda y esquema de pago. "
"La cotización se crea en estado 'borrador'. "
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes. "
"Soporta DOBLE PROPUESTA: con es_doble=true se presentan dos opciones comparables; cada servicio "
"se asigna a la opción '1', '2' o 'ambas' (compartido), y opciones_metadata define el título, "
"descripción y exclusiones de cada opción."
),
"inputSchema": {
"type": "object",
"properties": {
"cliente": {
"type": "object",
"properties": {
"nombre": {"type": "string", "description": "Nombre completo del contacto"},
"empresa": {"type": "string", "description": "Nombre de la empresa (opcional)"},
"email": {"type": "string", "description": "Email de contacto"},
"telefono": {"type": "string", "description": "Teléfono de contacto"},
},
"required": ["nombre"],
},
"servicios": {
"type": "array",
"items": {
"type": "object",
"properties": {
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"},
"precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
"opcion": {"type": "string", "enum": ["1", "2", "ambas"], "description": "Solo en doble propuesta: opción a la que pertenece el servicio ('ambas' = compartido). Default 'ambas'."},
},
"required": ["servicio_id"],
},
"description": "Lista de servicios a incluir en la cotización",
},
"es_doble": {
"type": "boolean",
"description": "Si es true, la cotización presenta dos opciones comparables (doble propuesta).",
},
"opciones_metadata": {
"type": "object",
"description": "Solo en doble propuesta. Metadatos por opción, p.ej. {\"1\": {\"titulo\": \"...\", \"descripcion\": \"...\", \"noIncluye\": \"...\"}, \"2\": {...}}.",
"properties": {
"1": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
"2": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
},
},
"plan_bucefalo": {
"type": "string",
"enum": ["basico", "estandar", "premium", "empresarial"],
"description": "Nivel del plan CRM Bucefalo (opcional)",
},
"proyecto": {
"type": "string",
"description": "Nombre o descripción del proyecto (default: 'MKT Digital')",
},
"moneda": {
"type": "string",
"enum": ["MXN", "USD"],
"description": "Moneda de la cotización (default: MXN)",
},
"esquema_pago": {
"type": "string",
"enum": ["Pago Unico", "Mensual", "Pago Unico/Mensual"],
"description": "Esquema de pago (default: Pago Unico/Mensual)",
},
},
"required": ["cliente", "servicios"],
},
},
{
"name": "obtener_cotizacion",
"description": (
"Obtiene los detalles completos de una cotización existente incluyendo: "
"datos del cliente, servicios seleccionados con precios, estado actual, "
"plan CRM Bucefalo si aplica, observaciones, fechas y vigencia."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "listar_cotizaciones",
"description": (
"Lista cotizaciones con filtros opcionales. "
"Útil para revisar el pipeline de ventas, cotizaciones pendientes, o historial de un cliente. "
"Estados: borrador (en proceso), enviada (esperando respuesta), aprobada (cerrada ganada), rechazada (cerrada perdida)."
),
"inputSchema": {
"type": "object",
"properties": {
"estado": {
"type": "string",
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
"description": "Filtrar por estado",
},
"cliente_nombre": {
"type": "string",
"description": "Buscar por nombre de cliente",
},
"busqueda": {
"type": "string",
"description": "Texto libre para buscar en número, proyecto o cliente",
},
},
},
},
{
"name": "cambiar_estado_cotizacion",
"description": (
"Cambia el estado de una cotización. "
"Flujo normal: borrador → enviada → aprobada o rechazada. "
"Solo cambiar a 'enviada' cuando la cotización esté lista para el cliente. "
"Cambiar a 'aprobada' cuando el cliente acepte, o 'rechazada' cuando decline."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
"estado": {
"type": "string",
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
"description": "Nuevo estado de la cotización",
},
},
"required": ["cotizacion_id", "estado"],
},
},
{
"name": "actualizar_precio_servicio",
"description": (
"Actualiza el precio de un servicio específico dentro de una cotización. "
"No modifica el precio base del catálogo, solo el precio en esta cotización. "
"Útil para negociar precios individuales sin recrear toda la cotización."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
"servicio_id": {"type": "string", "description": "ID del servicio cotizado (no el del catálogo)"},
"nuevo_precio": {"type": "number", "description": "Nuevo precio en la moneda de la cotización"},
},
"required": ["cotizacion_id", "servicio_id", "nuevo_precio"],
},
},
{
"name": "duplicar_cotizacion",
"description": (
"Duplica una cotización existente como nueva copia en estado 'borrador'. "
"Crea una copia exacta con nuevo ID y número. "
"Útil para crear variaciones de una propuesta o reenviar una cotización actualizada."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización a duplicar"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "calcular_financiamiento",
"description": (
"Calcula las mensualidades para financiar una cotización o monto específico. "
"Plazos disponibles: 3 meses (7.7% tasa), 6 meses (10.7%), 9 meses (13.7%), 12 meses (16.7%). "
"Todos incluyen 2.5% de comisión + 16% IVA. "
"Devuelve: pago mensual, IVA mensual, total mensual, comisión total, y gran total."
),
"inputSchema": {
"type": "object",
"properties": {
"monto": {"type": "number", "description": "Monto total a financiar en MXN"},
"meses": {"type": "integer", "enum": [3, 6, 9, 12], "description": "Plazo en meses"},
},
"required": ["monto", "meses"],
},
},
{
"name": "generar_pdf_cotizacion",
"description": (
"Genera un PDF profesional de una cotización con: logo de la empresa, colores de marca, "
"tabla de servicios agrupados por fase, bonos incluidos, términos y condiciones, "
"y datos bancarios para transferencia. Devuelve el archivo PDF."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización guardada"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "obtener_configuracion",
"description": (
"Obtiene la configuración de la empresa Consultoría E3: "
"razón social, RFC, domicilio fiscal, datos bancarios (cuenta nacional, CLABE, cuenta internacional, SWIFT), "
"colores de marca, logo, y términos y condiciones. "
"Útil para generar documentos o verificar información fiscal."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "listar_bonos",
"description": (
"Lista los bonos/disponibles que se pueden incluir en una cotización: "
"1) Servicio Centinela Web (monitoreo 30 min/mes), "
"2) Workshop Buyer Persona, "
"3) Workshop Propuesta de Valor, "
"4) Membresía Premium (1 año), "
"5) Mes Gratis CRM Bucefalo, "
"6) Script de Ventas (100+ complementos)."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "listar_planes_bucefalo",
"description": (
"Lista los niveles del CRM Bucefalo con precios mensuales: "
"Básico ($1,000/mes), Estándar ($3,500/mes), Premium ($4,500/mes), Empresarial ($7,500/mes). "
"Bucefalo es un CRM para gestión de ventas y clientes."
),
"inputSchema": {"type": "object", "properties": {}},
},
]
RESOURCES = [
{
"uri": "cotizador://servicios",
"name": "Catálogo de Servicios",
"description": (
"Lista completa de servicios de marketing digital de Consultoría E3 organizados por fase: "
"Fase 0 (Auditorías), Fase 1 (Setup/Infraestructura), Fase 2 (Publicidad/Manejo), "
"Fase 3 (Contenido/SEO). Cada servicio incluye nombre, descripción, precio base, "
"tiempo de entrega, tipo de pago (único/mensual), y entregables."
),
"mimeType": "application/json",
},
{
"uri": "cotizador://categorias",
"name": "Categorías de Servicios",
"description": (
"Categorías disponibles para clasificar servicios: "
"SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado."
),
"mimeType": "application/json",
},
{
"uri": "cotizador://configuracion",
"name": "Configuración de la Empresa",
"description": (
"Datos fiscales, bancarios y de marca de Consultoría E3. "
"Incluye razón social, RFC, domicilio fiscal, cuentas bancarias nacionales e internacionales, "
"colores de marca, logo, y términos y condiciones."
),
"mimeType": "application/json",
},
]
+9 -29
View File
@@ -1,7 +1,7 @@
"""Cotizador E3 — FastAPI Application """Cotizador E3 — FastAPI Application
REST API + MCP Server for digital marketing quotation management. REST API for digital marketing quotation management.
Optimized for n8n workflows and AI agents (OpenClaw, Claude, ChatGPT). Optimized for n8n workflows and other integrations.
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,8 +28,8 @@ app = FastAPI(
title="Cotizador E3 API", title="Cotizador E3 API",
description=( description=(
"API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. " "API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. "
"Optimizada para integración con n8n y agentes de IA (OpenClaw, Claude, ChatGPT) " "Optimizada para integración con n8n y otros consumidores. "
"via MCP (Model Context Protocol)." "Autenticación por X-API-Key o Bearer JWT."
), ),
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
@@ -98,30 +98,11 @@ app.include_router(financiamiento.router)
app.include_router(export_.router) app.include_router(export_.router)
app.include_router(import_.router) app.include_router(import_.router)
# Mount MCP server # El servidor MCP se retiro (2026-07-28). No se estaba usando, su transporte
try: # llevaba tiempo roto (instanciaba un StreamableHTTPServerTransport por peticion,
from app.mcp.server import server as mcp_server # sin manejo de sesion, y devolvia 500 con credencial valida) y su SDK sin fijar
# tumbo el API entero en produccion al saltar a 2.0.0. Si algun dia se retoma,
from mcp.server.streamable_http import StreamableHTTPServerTransport # el codigo esta en el historial: `git show edb500f5:api/app/mcp/server.py`.
@app.post("/mcp")
async def mcp_endpoint(request: Request):
"""MCP (Model Context Protocol) endpoint for AI agents."""
transport = StreamableHTTPServerTransport(mcp_server)
return await transport.handle_request(request)
@app.get("/mcp")
async def mcp_info():
"""MCP server info. Use POST for actual MCP communication."""
return {
"name": "cotizador-e3",
"version": "1.0.0",
"protocol": "mcp",
"description": "MCP server for Cotizador E3 quotation system",
}
except ImportError:
# MCP SDK not installed, skip MCP mount
pass
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
@@ -132,7 +113,6 @@ async def root():
"docs": "/docs", "docs": "/docs",
"openapi": "/openapi.json", "openapi": "/openapi.json",
"health": "/health", "health": "/health",
"mcp": "/mcp",
} }
+30 -12
View File
@@ -1,12 +1,30 @@
fastapi>=0.115.0 # Versiones FIJAS a proposito.
uvicorn[standard]>=0.34.0 #
asyncpg>=0.30.0 # Por que: el 2026-07-28 una reconstruccion del API tomo mcp 2.0.0 (el rango era
pydantic>=2.0 # `mcp>=1.0.0`), que elimino Server.list_tools(). El import lanzo AttributeError
pydantic-settings>=2.0 # al arrancar y dejo el contenedor en crash-loop ~50 minutos. Once de las doce
python-jose[cryptography]>=3.3.0 # dependencias eran rangos `>=` sin techo, o sea que cada build era una tirada de
passlib[bcrypt]>=1.7.4 # dados contra PyPI: `pydantic>=2.0` habria aceptado pydantic 3 igual de alegre.
python-multipart>=0.0.18 #
reportlab>=4.0 # Estas versiones son exactamente las que corrian sanas en produccion cuando se
openpyxl>=3.1.0 # fijaron (capturadas con `pip freeze` del contenedor healthy).
mcp>=1.0.0 #
httpx>=0.27.0 # Para subir una dependencia: cambiala aqui a proposito, reconstruye y prueba.
# Nunca por accidente al redesplegar.
#
# Limitacion conocida: esto fija las dependencias DIRECTAS. Las transitivas
# (starlette, cryptography, anyio...) las sigue resolviendo pip. Es un riesgo
# mucho menor, pero si algun dia muerde, el siguiente paso es un lock completo
# con pip-tools o uv.
fastapi==0.140.13
uvicorn[standard]==0.51.0
asyncpg==0.31.0
pydantic==2.13.4
pydantic-settings==2.14.2
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.32
reportlab==5.0.0
openpyxl==3.1.5
httpx==0.28.1
+113
View File
@@ -0,0 +1,113 @@
/**
* Caza del error intermitente: corre el pipeline real contra UJ2606UR001 hasta que
* falle, e imprime TODO lo que sepamos de la peticion que lo provoco.
*/
import { cargarEconomia } from "@/lib/propuesta/economia";
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia";
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema, type Hechos, type Diagnostico } from "@/lib/propuesta/schemas";
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "@/lib/propuesta/prompts";
import { prisma } from "@/lib/db";
import type Anthropic from "@anthropic-ai/sdk";
import type { z } from "zod";
import fs from "node:fs";
const LOG = process.argv[2];
// Escritura directa: el buffer de stdout se pierde si el proceso muere.
function log(...a: unknown[]) {
const linea = a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" ");
fs.appendFileSync(LOG, linea + "\n");
}
const console = { log } as unknown as Console;
const NUMERO = "UJ2606UR001";
const TRANSCRIPCION = "Una propuesta de zero to hero para un negocio inicial";
/** Igual que llamarConHerramienta pero registra cada peticion y explota con detalle. */
async function correrPaso(nombre: string, msg: string, schema: z.ZodType, maxTokens: number, maxIntentos: number) {
const cliente = crearCliente();
const tools = [{ name: nombre, description: "Registra el resultado.", input_schema: aJsonSchema(schema) as never }];
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: msg }];
for (let intento = 1; intento <= maxIntentos; intento++) {
let res: Anthropic.Message;
try {
res = await cliente.messages.create({
model: modelo(), max_tokens: maxTokens,
system: [{ type: "text", text: SYSTEM_BASE, cache_control: { type: "ephemeral" } }],
tools, messages: mensajes,
});
} catch (e) {
const err = e as { status?: number; message?: string; error?: unknown };
console.log(`\n*** ERROR EN ${nombre} intento ${intento} ***`);
console.log(" status:", err.status);
console.log(" message:", String(err.message).slice(0, 600));
if (err.error) console.log(" error:", JSON.stringify(err.error).slice(0, 800));
console.log("\n --- FORMA DE LOS MENSAJES ENVIADOS ---");
mensajes.forEach((m, i) => {
const c = m.content;
const tipos = typeof c === "string" ? "string" : (c as { type: string }[]).map((b) => b.type).join(",");
console.log(` [${i}] role=${m.role} bloques=${tipos}`);
});
throw new Error("REPRODUCIDO");
}
const bloques = res.content.map((b) => b.type).join(",");
console.log(` ${nombre} intento ${intento}: stop=${res.stop_reason} bloques=[${bloques}] out=${res.usage.output_tokens}`);
const tus = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const ok = tus.find((b) => b.name === nombre);
if (ok) {
const parsed = schema.safeParse(ok.input);
if (parsed.success) return parsed.data;
const errTxt = (await import("zod")).z.prettifyError(parsed.error);
console.log(` zod rechazo: ${errTxt.split("\n")[0]}`);
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: [
...tus.map((b) => ({ type: "tool_result" as const, tool_use_id: b.id, is_error: true, content: errTxt })),
{ type: "text" as const, text: "Corrige y reintenta." },
] },
);
continue;
}
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: `Debes llamar a ${nombre}.` },
);
}
return null;
}
async function unaVuelta(entrada: ContextoEntrada, econ: Awaited<ReturnType<typeof cargarEconomia>>, n: number) {
console.log(`\n===== VUELTA ${n} =====`);
const h = (await correrPaso("registrar_hechos", mensajePaso1(entrada), hechosSchema, 8000, 5)) as Hechos | null;
if (!h) { console.log(" extraccion agoto intentos"); return; }
const d = (await correrPaso("registrar_diagnostico", mensajePaso2(entrada, h, econ), diagnosticoSchema, 8000, 4)) as Diagnostico | null;
if (!d) { console.log(" diagnostico agoto intentos"); return; }
const r = await correrPaso("registrar_propuesta", mensajePaso3(entrada, h, d, econ), redaccionSchema, 12000, 4);
console.log(r ? " vuelta completa OK" : " redaccion agoto intentos");
}
async function main() {
const cot = await prisma.cotizacion.findFirst({ where: { numero: NUMERO }, include: { cliente: true, servicios: true } });
if (!cot) throw new Error("no encontrada");
const econ = await cargarEconomia(cot.id);
const entrada: ContextoEntrada = {
transcripcion: TRANSCRIPCION,
notas: cot.servicios.map((s) => s.notas).filter(Boolean).join("\n"),
observaciones: cot.observaciones || "",
cliente: cot.cliente.nombre, empresa: cot.cliente.empresa || "", proyecto: cot.proyecto,
};
console.log(`${econ.partidas.length} partidas`);
for (let i = 1; i <= 3; i++) {
try { await unaVuelta(entrada, econ, i); }
catch (e) {
if (e instanceof Error && e.message === "REPRODUCIDO") { console.log("\n>>> error reproducido, deteniendo"); break; }
throw e;
}
}
await prisma.$disconnect();
}
main().catch((e) => { console.error("fallo:", e); process.exit(1); });
+7
View File
@@ -40,6 +40,13 @@ services:
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_* # Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
# generar devuelve un error claro; el resto de la app funciona igual.
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue # Seed inicial: pon RUN_SEED=true solo en el primer despliegue
- RUN_SEED=${RUN_SEED:-false} - RUN_SEED=${RUN_SEED:-false}
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]} - SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
+7
View File
@@ -40,6 +40,13 @@ services:
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_* # Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
# generar devuelve un error claro; el resto de la app funciona igual.
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
# mandaria la clave de MiniMax a api.anthropic.com.
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue # Seed inicial: pon RUN_SEED=true solo en el primer despliegue
- RUN_SEED=${RUN_SEED:-false} - RUN_SEED=${RUN_SEED:-false}
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]} - SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
+859
View File
@@ -0,0 +1,859 @@
# Business Intelligence — Plugin de Propuesta Consultiva con IA
> **Qué es este documento:** el análisis de negocio, la filosofía y los lineamientos para una **futura** implementación de un plugin que, a partir de transcripciones de reuniones y notas de texto, genere un **segundo documento** de cotización — narrativo y consultivo — como complemento al documento económico que hoy produce el Cotizador E3 de forma manual.
>
> **Qué NO es:** una especificación técnica cerrada ni un plan de implementación. No hay código aquí. Es el mapa que debe leerse antes de escribir la primera línea.
>
> **Fuentes analizadas:**
> - Proyecto real: `H:\MegaSync\Proyectos\Cotizador` (Next.js 16 + Prisma 7 + PostgreSQL, con API Python/MCP paralela)
> - Plantilla de referencia: `D:\Documents\plantilla_cotizacion.html`
> - Base de conocimiento: `Guia_Onboarding_Cliente_y_Ruta_de_Trabajo.md`, `08-Monetizacion-Onboarding-Pricing-Seguimiento.md`, `Onboarding-Cliente/04-Sesion-Propuesta-y-Continuidad.md`, `sintesis/s2-Pricing-Cobranza.md`, `sintesis/s8-Prompts-Plantillas.md`, `00-Guia-de-Uso-y-Checklist-Calidad.md`
>
> **Fecha:** 2026-07-28 · **Estado:** análisis previo a implementación
---
## 1. Resumen ejecutivo
El Cotizador E3 hoy resuelve muy bien **el qué y el cuánto**: un catálogo de servicios por fase, precios, IVA, financiamiento, planes Bucéfalo, exportación a PDF y Excel. Es un motor contable y comercial sólido.
Lo que no resuelve —y lo que la base de conocimiento identifica como el factor que multiplica el ticket entre 3x y 15x— es **el por qué**: el diagnóstico del dolor del cliente en sus propias palabras, la cuantificación del costo de no hacer nada, el anclaje del precio en el valor anual, y la narrativa que permite al cliente defender la inversión internamente.
Ese "por qué" ya se está capturando de forma parcial pero **se está tirando a la basura**:
- El campo `observaciones` de `Cotizacion` y el campo `notas` de cada `ServicioCotizado` se guardan en la base de datos y **no se imprimen ni en el PDF ni en el Excel** (verificado por búsqueda en `src/lib/pdf-generator.ts` y `src/lib/excel-builder.ts`: cero coincidencias).
- Las reuniones de discovery generan transcripciones que hoy viven fuera del sistema.
- La plantilla HTML de `D:\Documents\` ya tiene la estructura narrativa correcta (diagnóstico con semáforo, exclusiones explícitas, argumentos de venta, estado de materiales) pero se llena **a mano**, una por una.
**La oportunidad:** un plugin que lea el contexto humano disperso (transcripción + notas + observaciones + la cotización ya armada) y genere un segundo archivo —el documento consultivo— siguiendo la filosofía de la plantilla HTML, sin tocar un solo número.
**La tesis central, en una frase:**
> El documento manual es la **fuente de verdad económica**. El documento de IA es la **fuente de verdad narrativa**. La IA nunca inventa un precio; sólo explica el que ya existe.
---
## 2. Inventario: los tres activos que ya existen
### 2.1 El Cotizador real (`H:\MegaSync\Proyectos\Cotizador`)
**Stack.** Next.js 16 (App Router) + React 19 + Tailwind v4 + Zustand · Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`) · PostgreSQL 16 en Docker · JWT con `jose` en cookie httpOnly · PDFKit server-side · despliegue en Coolify.
**Segundo backend.** Un servicio FastAPI en `api/` que expone el mismo dominio como REST **y un servidor MCP en `/mcp`** con 12 herramientas y 3 recursos para agentes de IA. Esto es el hallazgo arquitectónico más importante del inventario: **el proyecto ya tiene una superficie diseñada para que una IA lo opere.**
Herramientas MCP existentes: `buscar_servicios`, `crear_cotizacion`, `obtener_cotizacion`, `listar_cotizaciones`, `cambiar_estado_cotizacion`, `actualizar_precio_servicio`, `duplicar_cotizacion`, `calcular_financiamiento`, `generar_pdf_cotizacion`, `obtener_configuracion`, `listar_bonos`, `listar_planes_bucefalo`. Recursos: `cotizador://servicios`, `cotizador://categorias`, `cotizador://configuracion`.
**Modelo de datos relevante.**
| Modelo | Campos que importan para el plugin |
|---|---|
| `Cotizacion` | `numero`, `fecha`, `vigencia`, `moneda`, `esquemaPago`, `estado`, `esDoble`, `opcionesMetadata` (JSON), **`observaciones`** |
| `ServicioCotizado` | `nombre`, `fase`, `tipoPago`, `precio`, `tiempoEntrega`, `entregables` (JSON), `beneficios` (JSON), **`notas`**, `modeloCobro`, `horas`, `tarifaHora`, `opcion` |
| `ServicioCatalogo` | `precioBase`, `descripcion`, `entregablesDefault`, `fase`, `nivel`, `variante` |
| `Cliente` | `nombre`, `empresa`, `email`, `telefono`, `rfc` |
| `RegistroHoras` | `fecha`, rango horario, `horas`, `tarifaHora`, `descripcion`, `estadoPago` |
| `PlanBucefaloCotizacion` | `nivel`, `precio` |
| `FinanciamientoPlan` | `meses` (3/6/9/12), `tasa`, `comision`, `montoMinimo`, `iva` |
| `Bono` | `numero`, `titulo`, `descripcion` |
| `Configuracion` | key-value: `color_primario`, `color_secundario`, `logo_base64` |
**Reglas de negocio ya codificadas** (`src/lib/calculators.ts`):
- `IVA_RATE = 0.16`
- `TARIFA_HORA_DEFAULT = 700`
- Número de cotización: `UJ{YY}{MM}{InicialesAsesor}{seq}` (ej. `UJ2605AG001`)
- Vigencia = 15 días hábiles desde la fecha (excluye sábado y domingo)
- 4 fases: F0 Auditoría · F1 Setup/Infra · F2 Publicidad/Manejo · F3 Contenido/SEO
- Planes Bucéfalo mensuales: básico $1,000 · estándar $3,500 · premium $4,500 · empresarial $7,500 (hardcodeados en `calculators.ts`, **no en la BD**)
- Modelos de cobro: `fijo`, `horas`, `retainer`, `demanda`
- 6 paquetes PyME pre-armados + paquete "General" con todo el catálogo
**El hallazgo clave: `esDoble` ya existe.** El sistema soporta cotización de doble propuesta: `esDoble: boolean` + `opcionesMetadata` con estructura `{ "1": {titulo, descripcion, noIncluye}, "2": {...} }`, y cada `ServicioCotizado` puede asignarse a la opción `"1"`, `"2"` o `"ambas"`. La herramienta MCP `crear_cotizacion` ya acepta `es_doble=true`. **Ya hay una vía nativa para presentar opciones comparables** — y la base de conocimiento dice que 3 opciones cierran 16-28% más alto. Está a mitad de camino.
### 2.2 La plantilla HTML (`D:\Documents\plantilla_cotizacion.html`)
Un documento de 690 líneas, autocontenido, con tokens CSS, tipografía editorial (Playfair Display serif + DM Sans), tema oscuro (`--bg: #0a0f1e`), responsive y con reglas `@media print` (`page-break-inside: avoid`).
Su valor no es el CSS. Es **la arquitectura argumental**:
| # | Sección | Función retórica |
|---|---|---|
| Hero | Título + subtítulo + 4 metadatos (elaborado por / cliente / plataforma / vigencia) | Encuadre y contexto |
| 01 | **Diagnóstico del proyecto** — un `.quote` con el insight clave + filas con semáforo (`dot-red` problema crítico, `dot-amber` área de mejora, `dot-blue` oportunidad, `dot-green` ventaja existente) | Demuestra que entendiste el negocio antes de vender |
| 02 | **Alcance y desglose económico** — filas categoría / entregable+descripción / precio, más caja de totales (subtotal, descuento, IVA, total) | El qué y el cuánto |
| 03 | **Qué no incluye esta propuesta** — lista con ✕ | Anti-scope-creep, preventivo |
| 04 | **Por qué tiene sentido esta solución** — pares etiqueta/explicación | Argumentos de venta traducidos a beneficio |
| 05 | **Plan de mantenimiento opcional** — dos tarjetas (mensual vs anual, una `.featured`) con features `highlight` / `good` / `muted` | Siembra de recurrencia, con límites explícitos |
| 06 | **Estado de materiales del cliente** — dos tarjetas: materiales (`done`/`pend`) y decisiones pendientes (`pend`/`neutral`) | Transparencia y transferencia de responsabilidad |
| Footer | Datos de contacto + condiciones (50/50, CFDI, avance condicionado a materiales, cambios se cotizan aparte) | Blindaje comercial |
**El semáforo de la sección 01 es el activo intelectual más valioso de la plantilla.** Obliga a clasificar cada hallazgo por urgencia — exactamente lo que la base de conocimiento pide en las 3 Preguntas de Oro. Y el marcador `dot-green` ("ventaja existente") obliga a reconocer lo que el cliente ya hizo bien, que es el detalle que convierte una propuesta en una conversación entre pares.
**La sección 06 es la que resuelve la honestidad de la IA.** Ver §10.2.
### 2.3 La base de conocimiento (vault de Obsidian)
Sintetizada desde 91 transcripts. Lo que aporta al plugin, en orden de utilidad:
**Framework de diagnóstico (Fase 2 del playbook):**
- Las 3 Preguntas de Oro: (1) ¿cuál es el problema de **negocio**, no técnico? (2) ¿cuánto le está costando hoy? (3) ¿cuánto ingreso/ahorro esperas en 12 meses?
- Costo desglosado en 4 dimensiones: dinero directo · tiempo del equipo · costo de oportunidad · costo de error humano
- Filtro de viabilidad de 4 preguntas: costo de no hacer nada · ¿los datos existen HOY? · ¿quién es el usuario final real? · ¿qué pasa si funciona?
- The Mom Test: preguntas ancladas en el pasado ("¿cómo lo resolvieron la última vez?"), no en el futuro hipotético
**Framework de pricing:**
- `Valor Anual = (horas/mes × costo horario × 12) + costo anual de errores + ingresos recuperados`
- `Precio objetivo = 15% a 25% del Valor Anual`
- Marco VALOR (techo) / COSTO real (piso, 2-3× horas técnicas) / MERCADO
- Anclaje: presentar A (2× objetivo) → B (objetivo, se elige 60%) → C (50% del objetivo)
- 3 opciones cierran **16-28% más alto** que una sola
**Reglas transversales de comunicación** (del checklist de calidad):
1. Hablar de resultados, no de herramientas
2. **Usar las palabras del cliente** — registrar frases textuales del dolor y reutilizarlas
3. Investigar hechos pasados
4. 80% habla el cliente / 20% el consultor
5. **Distinguir evidencia de hipótesis** — etiquetar cifras como confirmadas, estimadas o pendientes de validar
6. No prometer solución antes de validar
7. Recapitular y confirmar
8. Comunicar riesgos temprano
**Separación de tres conceptos** (Sesión 4 de onboarding): proyecto inicial ≠ mantenimiento ≠ evolución/retainer. Cada uno con su alcance, precio y forma de contratación explícitos.
**Criterio ético de monetización:** una mejora posterior se propone sólo si hay evidencia de que resuelve un problema real, el cliente puede decidir libremente, y el nuevo alcance/precio/plazo quedan claros.
**Los 15 errores de monetización más caros** — de los cuales el plugin puede prevenir directamente: mandar propuesta con 1 sola opción (#4), aceptar scope creep sin orden de cambio (#7), entregar sin documentación (#8), no ofrecer retainer post-entrega (#9), vender habilidades técnicas en vez de resultados (#13).
---
## 3. Diagnóstico: la brecha entre los tres activos
Aplico a este proyecto el mismo semáforo de la plantilla.
### 🔴 Problema crítico — el contexto humano se captura y se descarta
`Cotizacion.observaciones` y `ServicioCotizado.notas` existen en el esquema, se llenan en el formulario (`CotizacionForm.tsx:1222`, placeholder literal *"Notas adicionales para la cotizacion..."*) y **no aparecen en ningún exportador**. Es información de discovery que el asesor ya se tomó el trabajo de escribir y que muere en la base de datos.
Esto no es sólo desperdicio: es la evidencia de que el sistema fue diseñado como calculadora y no como instrumento de venta consultiva. El plugin no tiene que crear el canal de entrada — **tiene que empezar a leerlo.**
### 🔴 Problema crítico — el pricing es de menú, la filosofía es de valor
El Cotizador calcula desde `ServicioCatalogo.precioBase`: precio fijo por servicio, sumado. Es pricing de catálogo. La base de conocimiento predica pricing por valor: 15-25% del valor anual del problema.
**No son incompatibles, pero hoy no se hablan.** El precio de catálogo es el piso operativo y la base de facturación (con CFDI, IVA, RFC — cosas que un porcentaje del valor anual no te da). El valor anual es el argumento que justifica ese precio ante el cliente.
El plugin no debe reemplazar uno por el otro. Debe **superponer la capa de valor sobre la capa contable**: tomar el total que el humano ya fijó y demostrar que representa X% del valor anual del problema. Si el asesor cotizó $50,000 MXN y la IA detecta en la transcripción un dolor de ~$400,000 MXN/año, la propuesta escribe *"la inversión equivale al 12.5% del costo anual del problema"* — sin mover el $50,000.
### 🟠 Área de mejora — la doble propuesta existe pero está subutilizada
`esDoble` + `opcionesMetadata` da dos opciones. La filosofía pide tres, con anclaje psicológico (cara primero). El plugin puede generar el **argumento** de las tres opciones en su documento narrativo aun cuando el documento económico sólo tenga dos, o recomendar al asesor cómo estructurar las dos que sí caben.
Aquí hay que ser honesto: **no** hay que forzar tres opciones si el proyecto no las admite. La Sesión 4 lo dice explícito: *"Cuando haya alternativas reales, usa tres opciones… las otras no son una trampa ni una lista de extras artificiales."*
### 🟠 Área de mejora — la base de conocimiento es de otro negocio
Esto es una tensión real que hay que documentar antes de que alguien la descubra a medio proyecto:
| | Base de conocimiento | Cotizador E3 |
|---|---|---|
| Negocio | Freelance/consultoría de automatización con Python | Consultoría de digitalización y marketing digital |
| Geografía | LATAM, cobro internacional | Querétaro, MX |
| Moneda | USD (Wise/Stripe/Payoneer) | MXN con IVA 16% y CFDI |
| Rangos | $300 $25,000 USD | $7,490 $90,800 MXN (paquetes) |
| Servicios | Scripts, RAG, dashboards, APIs | Ads, SEO, WordPress, CRM Bucéfalo, contenido |
| Recurrencia | Retainer de mantenimiento de software | Mensualidades de manejo de pauta y CRM |
**Los frameworks son transferibles; las cifras no.** La IA debe importar el *método* de diagnóstico, el *marco* de pricing y las *reglas* de comunicación — y jamás las tablas de precios en USD. Si el plugin sugiere "$1,800 USD" en una cotización de E3, es un bug, no una feature.
### 🔵 Oportunidad — el servidor MCP ya está construido
`api/app/mcp/server.py` (453 líneas) + `tools.py` (305 líneas) exponen el catálogo, las cotizaciones y la configuración a agentes. El plugin no necesita una capa de acceso a datos nueva: puede consumir la que existe. Y como los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` con paridad obligatoria, la IA puede **leer** totales sin riesgo de recalcularlos mal.
### 🔵 Oportunidad — la plantilla HTML es un contrato de salida listo
No hay que diseñar el formato del segundo documento. La plantilla ya define las secciones, la jerarquía y el vocabulario visual. El trabajo es mapear cada sección a su fuente de datos (ver §7) y dejar que la IA llene el contenido, no la estructura.
### 🟢 Ventaja existente — el asesor ya hace el trabajo difícil
El humano ya: califica al cliente, corre la reunión, elige servicios del catálogo, ajusta precios, decide el esquema de pago. El plugin no reemplaza ese juicio. **Se monta sobre él.** Esto reduce drásticamente el riesgo: si la IA falla, el documento manual sigue siendo entregable.
### 🟢 Ventaja existente — el vault ya tiene el system prompt esbozado
`sintesis/s8-Prompts-Plantillas.md` §1 tiene un system prompt maestro, §3 un prompt de diagnóstico de proyecto, §4 un prompt de propuesta comercial y §9 una plantilla de output estándar. No hay que empezar de cero — hay que **adaptar de USD/Python a MXN/marketing digital**.
---
## 4. Filosofía: los 10 principios del plugin
Estos son los principios no negociables. Si una decisión de implementación los contradice, la decisión está mal.
### P1 · La IA no toca los números
La IA **lee** precios, totales, IVA, vigencia y financiamiento. **Nunca los calcula, propone ni modifica.** Todo número que aparezca en el documento de IA debe ser byte-por-byte el mismo que produjo `calculators.ts`.
*Por qué:* un precio inventado por la IA en un documento con el logo de E3 es un pasivo comercial y potencialmente fiscal. Además destruye la confianza en todo el resto del documento.
*Implicación técnica:* los totales se inyectan como datos ya calculados en el prompt y se instruye a la IA a citarlos literalmente; idealmente el render final los toma del objeto de cotización, no del texto generado.
### P2 · Dos documentos, dos trabajos, cero solapamiento
| | Documento 1 (manual, PDF/Excel) | Documento 2 (IA, HTML) |
|---|---|---|
| Autor | Asesor humano | IA, revisada por el asesor |
| Función | Contractual y económica | Consultiva y narrativa |
| Contenido | Servicios, precios, totales, condiciones | Diagnóstico, valor, argumentos, exclusiones, pendientes |
| Números | **Fuente de verdad** | Espejo, sólo citados |
| Si se contradicen | Gana este | Se corrige este |
| Vive en | `pdf-generator.ts` / `excel-builder.ts` | Nuevo generador |
El documento 2 es un **superconjunto narrativo** y un **subconjunto numérico** del documento 1.
### P3 · Toda afirmación es trazable
Cada dato que la IA escriba sobre el cliente debe apuntar a su origen: una línea de transcripción, un archivo de notas, un campo del formulario, o el catálogo. Si no hay origen, no se escribe.
*Implicación:* el contrato de salida (§8) lleva un campo `evidencia` por afirmación. El documento renderizado puede ocultarlo al cliente, pero el asesor debe poder auditarlo antes de enviar.
### P4 · Evidencia, estimación e hipótesis se etiquetan distinto
Directo de la regla 5 del checklist de calidad. Tres estados:
- **Confirmado** — el cliente lo dijo textualmente o hay un dato duro.
- **Estimado** — se derivó de algo que el cliente dijo, con un cálculo explícito.
- **Por validar** — hipótesis del consultor que aún nadie confirmó.
Un "estimado" presentado como "confirmado" es la forma más rápida de perder credibilidad en una reunión de presentación. La IA debe marcar cada cifra.
### P5 · Las palabras del cliente son sagradas
La sección de diagnóstico debe usar frases textuales del cliente, no paráfrasis corporativas. Si en la transcripción dice *"se nos van los clientes porque nadie contesta el WhatsApp el fin de semana"*, eso va entre comillas — no *"oportunidades de mejora en la gestión omnicanal de la comunicación"*.
*Regla operativa:* el bloque `.quote` de la sección 01 debe ser **siempre** una cita literal, nunca una síntesis de la IA.
### P6 · Se vende resultado, no herramienta
Error #13 de la lista de errores de monetización. Traducir toda capacidad técnica a tiempo recuperado, errores evitados, ingresos protegidos o tranquilidad operativa.
*Malo:* "Configuración de Google Ads con estructura SKAG y scripts de puja automatizada."
*Bueno:* "Cada peso de pauta se dirige a las búsquedas que sí compran, en lugar de repartirse entre términos que sólo generan clics. Reportamos costo por prospecto real, no impresiones."
### P7 · Lo que NO incluye vale tanto como lo que sí
La sección 03 de la plantilla no es un trámite: es prevención de scope creep, que según la base de conocimiento promedia **27% de sobrecosto** en proyectos sin control de alcance. La IA debe generar exclusiones **específicas del proyecto**, no genéricas.
*Malo:* "No incluye servicios no mencionados."
*Bueno:* "No incluye la migración del histórico de 4 años de pedidos que mencionaron en la reunión; eso se evalúa como fase 2 cuando el sistema base esté operando."
### P8 · Mantenimiento y evolución van separados, con límites por escrito
De la Sesión 4: *"No prometas mantenimiento gratuito indefinido y no vendas un retainer como si incluyera trabajo ilimitado."* La sección 05 debe listar explícitamente qué **no** cubre el plan (la plantilla ya tiene la clase `.plan-feat.muted` con ✕ para eso). El backlog de evolución se registra aparte y **no se compromete**.
### P9 · La incertidumbre se declara, no se rellena
Si falta información, la IA **no la inventa ni la omite silenciosamente**: la escribe en la sección 06 como material o decisión pendiente. Ver §10.2 — esto es el mecanismo central de honestidad del sistema.
### P10 · El asesor aprueba antes de enviar
El documento de IA nace en estado borrador. Nadie lo manda al cliente sin que un humano lo lea. El plugin es un copiloto de redacción, no un autopiloto de ventas.
*Corolario:* el flujo de UI debe hacer que revisar sea fácil (diff, semáforo de confianza, evidencia expandible), no que aprobar sea rápido.
---
## 5. Insights de monetización que hay que codificar
Estos son los mecanismos concretos de la base de conocimiento que el plugin debe hacer operativos. Cada uno con su traducción a comportamiento del sistema.
| # | Insight de la base de conocimiento | Cómo lo codifica el plugin |
|---|---|---|
| M1 | El cliente describe síntomas; tu trabajo es diagnosticar la enfermedad | La sección 01 nunca repite la petición del cliente ("quiero una página web"); reformula al problema de negocio ("pierden prospectos porque no tienen dónde mandarlos desde los anuncios") |
| M2 | El costo del problema se desglosa en 4 dimensiones | La IA busca en la transcripción las 4 dimensiones y las presenta con su etiqueta de confianza; las que faltan van a la sección 06 como pendiente de validar |
| M3 | Precio = 15-25% del valor anual | La IA calcula el **ratio inverso**: dado el total que fijó el humano y el valor anual detectado, reporta qué porcentaje representa. Si sale <10%, señala al asesor que probablemente subcotizó. Si sale >40%, señala que va a haber objeción de precio |
| M4 | 3 opciones cierran 16-28% más alto | Si `esDoble = false`, el plugin sugiere al asesor un esquema de 2-3 opciones basado en los servicios ya elegidos (quitando o agregando del mismo catálogo, sin inventar servicios) |
| M5 | Anclaje: la opción cara primero | En el render de la sección de opciones, el orden es A (mayor) → B (recomendada, `.featured`) → C (esencial) |
| M6 | La objeción de precio se responde con alcance, no con descuento | El documento incluye, en notas internas para el asesor (no visibles al cliente), el "plan de recorte": qué servicios quitar primero si el cliente pide bajar el precio, ordenados por menor impacto en el resultado prometido |
| M7 | Anticipo 50% obligatorio antes de empezar | El footer lo enuncia siempre; no es opcional ni negociable por la IA |
| M8 | Updates 2×/semana aumentan confiabilidad percibida +125% | La propuesta incluye el compromiso de cadencia de comunicación como **parte del alcance**, no como cortesía |
| M9 | El retainer se ofrece a las 2 semanas post-entrega, no al inicio | La sección 05 presenta el mantenimiento como **opción con límites explícitos**, nunca como requisito ni como amenaza velada ("para que no se rompa") |
| M10 | Las 3 capas de documentación triplican la tarifa | Los entregables documentales (README ejecutivo, registro de decisiones, guía de mantenimiento — adaptados a marketing digital: manual de operación de campañas, bitácora de decisiones de segmentación, guía de qué puede ajustar el cliente solo) se listan como entregables con valor, no como anexos |
| M11 | Red flags del cliente | El plugin genera, en notas internas, una sección de riesgo: red flags detectadas en la transcripción (pidió descuento antes de entender el valor, no hay decisor presente, no hay cifra del problema, "hagan todo y luego vemos") |
| M12 | Separar proyecto / mantenimiento / evolución | Tres bloques distintos en el documento, con encabezados que lo hagan obvio. El backlog de evolución dice explícitamente "no comprometido en esta propuesta" |
| M13 | Criterio ético: sólo se propone lo que hay evidencia de que resuelve un problema real | La IA **no puede** agregar un servicio del catálogo que el asesor no eligió. Puede señalar en notas internas *"el cliente mencionó X, que el catálogo cubre con el servicio Y; considera si aplica"* — pero no lo mete en la propuesta |
### 5.1 El indicador de salud de la cotización
De M3 y M11 sale la feature más útil para el negocio y la más fácil de implementar: un semáforo interno, sólo para el asesor, que evalúa la cotización antes de enviarla.
| Señal | Cómo se calcula | Qué significa |
|---|---|---|
| Ratio precio/valor | `total / valor_anual_detectado` | <10% subcotizado · 15-25% en rango · >40% objeción probable |
| Dolor cuantificado | ¿hay al menos una cifra confirmada del costo del problema? | Si no: no hay ancla de valor; la propuesta va a competir por precio |
| Usuario final entrevistado | ¿la transcripción incluye a quien operará, no sólo al que firma? | Si no: riesgo de baja adopción |
| Decisor presente | ¿se identificó a quien autoriza el gasto? | Si no: riesgo de "voy a preguntar" |
| Exclusiones definidas | ¿hay al menos 3 exclusiones específicas? | Si no: riesgo de scope creep |
| Materiales pendientes | conteo de la sección 06 | Alto: la fecha de entrega es irreal |
| Red flags | conteo de señales de la lista | ≥2: evaluar si conviene el proyecto |
Esto no es un adorno. Es **business intelligence real** sobre el pipeline: agregado en el tiempo, dice qué tipo de cotizaciones cierran y cuáles no.
---
## 6. Anatomía del documento de IA
El documento 2 hereda la estructura de la plantilla HTML, con tres cambios. Marcados **[NUEVO]** los que no existen en la plantilla.
| Sección | Origen del contenido | Notas |
|---|---|---|
| **Hero** | `Cliente`, `Cotizacion.numero`, `fecha`, `vigencia`, `proyecto`, asesor | El subtítulo lo redacta la IA: una o dos oraciones que enmarcan el problema en lenguaje del cliente |
| **01 · Diagnóstico** | Transcripción + notas + observaciones | `.quote` = cita literal obligatoria. Filas con semáforo: al menos 1 rojo, 1 ámbar, 1 azul, 1 verde. Cada fila con etiqueta de confianza |
| **01b · Valor del problema** **[NUEVO]** | Transcripción, con las 4 dimensiones de costo | Tabla: dimensión / cifra / estado (confirmado/estimado/por validar) / evidencia. Cierra con el valor anual y el ratio de M3 |
| **02 · Resultados a lograr** **[NUEVO]** | Derivado del diagnóstico + entregables de los servicios elegidos | 3-5 resultados de negocio cuantificados. Es el puente entre problema y precio. La plantilla no lo tiene y la base de conocimiento lo pide en toda propuesta |
| **03 · Alcance y desglose económico** | `ServicioCotizado[]`, agrupado por fase y `tipoPago` | **Números literales del documento 1.** La IA sólo reescribe las descripciones en lenguaje de resultado (P6). Separar pago único de mensual, como ya hace el PDF |
| **04 · Opciones de inversión** | `esDoble` + `opcionesMetadata` | Sólo si hay opciones reales. Orden A → B(`.featured`) → C |
| **05 · Qué no incluye** | Transcripción (menciones fuera de alcance) + `opcionesMetadata.noIncluye` | Exclusiones específicas, nunca genéricas (P7) |
| **06 · Por qué tiene sentido** | `ServicioCotizado.beneficios` + diagnóstico | Cada beneficio amarrado a un hallazgo del diagnóstico. Si un beneficio no responde a ningún dolor detectado, se elimina |
| **07 · Plan de mantenimiento** | `PlanBucefaloCotizacion`, servicios `tipoPago: mensual`, `modeloCobro: retainer` | Con exclusiones explícitas (`.plan-feat.muted`). Mensual vs anual si aplica |
| **08 · Estado de materiales y decisiones** | Todo lo que la IA no pudo determinar + menciones de dependencias en la transcripción | **El canal de honestidad.** Ver §10.2 |
| **09 · Backlog de evolución** **[NUEVO]** | Fricciones mencionadas por el cliente que no entran en fase 1 | Con encabezado explícito: "registrado, no comprometido en esta propuesta" |
| **Footer** | `Configuracion` (logo, colores) + condiciones fijas | 50/50, CFDI, avance condicionado a materiales, cambios se cotizan aparte, MXN |
| **Anexo interno** **[NUEVO]** | Todo lo de §5.1 + M6 + M11 | **No se entrega al cliente.** Semáforo de salud, plan de recorte, red flags, evidencia completa. Puede ser una segunda página o un archivo aparte |
### 6.1 Nota sobre el anexo interno
Esta es la parte del diseño que más valor operativo tiene y la que más cuidado necesita. Un documento con "plan de recorte de precio" y "red flags del cliente" enviado por error al cliente es un incidente serio.
Recomendación: **archivo separado**, nombre distinto y visualmente inconfundible (fondo distinto, marca de agua "INTERNO — NO ENVIAR"). No una sección oculta del mismo archivo, no un `display: none`, no un comentario HTML.
---
## 7. Arquitectura de ingesta de contexto
### 7.1 Las cuatro fuentes
| Fuente | Formato | Volumen típico | Confianza |
|---|---|---|---|
| **Transcripción de reunión** | `.txt`, `.md`, `.vtt`, `.srt` | 60 min ≈ 9,000 palabras ≈ 13K tokens | Alta para citas; media para cifras (la gente redondea al hablar) |
| **Notas del asesor** | `.md`, `.txt` | 200-2,000 palabras | Alta — es interpretación experta |
| **Campos del formulario** | `Cotizacion.observaciones`, `ServicioCotizado.notas` | 50-500 palabras | Alta — escrito con intención |
| **Cotización estructurada** | JSON desde Prisma/MCP | 3-5K tokens | **Absoluta** — es la fuente de verdad |
### 7.2 Precedencia en caso de conflicto
Cuando dos fuentes se contradicen:
```
Cotización estructurada > Notas del asesor > Observaciones > Transcripción
```
*Razón:* la cotización es dato validado; las notas son interpretación experta posterior a la reunión; la transcripción es materia prima cruda con ruido de ASR, muletillas y correcciones en vivo ("son como 20 horas… no, más bien 30").
Si el conflicto es sobre una **cifra**, la IA no elige: lo reporta en la sección 08 como decisión pendiente. *"En la reunión se mencionaron 20 y 30 horas semanales en momentos distintos; confirmar la cifra antes de calcular el valor anual."*
### 7.3 Pipeline de preprocesamiento
Antes de que un token llegue al modelo:
1. **Normalización de formato** — VTT/SRT a texto plano con marcas de tiempo opcionales; Markdown se conserva.
2. **Sanitización de PII** — Esto es obligatorio, no opcional. Las transcripciones contienen nombres de empleados, comentarios sobre desempeño, cifras salariales, quejas sobre terceros. Se redactan nombres de personas que no son el interlocutor comercial, y se marca cualquier mención de temas laborales, legales o de salud como bloque excluido del documento final.
3. **Segmentación por hablante** cuando el formato lo permite — permite aplicar la regla 80/20 y detectar si se entrevistó al usuario operativo o sólo al decisor.
4. **Conteo de tokens** con `client.messages.countTokens` (nunca con estimadores de otros proveedores) para decidir si cabe entero o requiere segmentación.
5. **Detección de idioma** — si la transcripción tiene mezcla, se preserva; el documento final va en español.
### 7.4 Dónde se suben los archivos
Tres opciones, en orden de preferencia:
1. **Nuevo campo en la UI de cotización** — una zona de drop en la pestaña de observaciones. Los archivos se guardan como adjuntos vinculados a la `Cotizacion`. Requiere un modelo nuevo (`ContextoCotizacion`: `cotizacionId`, `tipo`, `nombreArchivo`, `contenido`/`ruta`, `createdAt`).
2. **Carpeta convenida en disco** — más simple para un MVP, peor para el despliegue en Coolify (contenedor sin volumen persistente por defecto). Sirve para probar localmente.
3. **Files API de Anthropic** (beta `files-api-2025-04-14`) — útil si se quiere pasar un PDF de reunión sin extraer texto; límite 500 MB. Requiere el header beta tanto en el upload como en el `messages.create` que lo referencia.
Para el MVP: opción 2 para validar la calidad del output; opción 1 para producción.
---
## 8. Contrato de salida
La IA **no genera HTML**. Genera un objeto estructurado que un renderizador convierte a HTML usando la plantilla. Esto es crítico por tres razones: valida la estructura antes de renderizar, permite que el asesor edite campo por campo, y evita que la IA rompa el CSS o inyecte markup arbitrario.
La API soporta esto nativamente con `output_config: { format: { type: "json_schema", schema: {...} } }` (structured outputs), disponible en Opus 5, Sonnet 5 y Haiku 4.5. En TypeScript hay helper de Zod: `zodOutputFormat()` + `client.messages.parse()`, que devuelve el objeto ya validado en `response.parsed_output`.
### 8.1 Forma conceptual del schema
```
PropuestaConsultiva
├─ meta: { numeroCotizacion, cliente, empresa, proyecto, vigencia, asesor }
├─ hero: { titulo, subtitulo }
├─ diagnostico:
│ ├─ citaLiteral: { texto, origen } ← obligatorio, literal
│ └─ hallazgos[]: { urgencia: rojo|ambar|azul|verde,
│ titulo, descripcion,
│ confianza: confirmado|estimado|por_validar,
│ evidencia }
├─ valorProblema:
│ ├─ dimensiones[]: { tipo: dinero|tiempo|oportunidad|error,
│ descripcion, montoAnualMXN|null,
│ confianza, evidencia }
│ ├─ valorAnualEstimadoMXN: number|null
│ └─ notaMetodologia: string ← cómo se llegó a la cifra
├─ resultados[]: { enunciado, metrica, lineaBase|null, periodoMedicion }
├─ alcance:
│ └─ items[]: { fase, categoria, nombre,
│ descripcionResultado, ← reescritura en lenguaje de beneficio
│ precioMXN, tipoPago, tiempoEntrega }
├─ totales: { subtotal, descuento, iva, total, moneda } ← COPIADO, nunca calculado
├─ opciones[]|null: { etiqueta, titulo, descripcion, paraQuien,
│ incluye[], noIncluye[], recomendada: bool }
├─ exclusiones[]: { texto, razon, origen } ← específicas del proyecto
├─ beneficios[]: { etiqueta, texto, hallazgoRelacionado } ← amarrado al diagnóstico
├─ mantenimiento|null:
│ └─ planes[]: { nombre, periodicidad, precioMXN, incluye[], noIncluye[], destacado }
├─ pendientes:
│ ├─ materiales[]: { texto, estado: recibido|pendiente, bloqueaEntrega: bool }
│ └─ decisiones[]: { texto, quienDecide, fechaSugerida }
├─ backlogEvolucion[]: { problema, impactoPosible, evidencia, momentoSugerido }
└─ interno: ← NUNCA en el documento del cliente
├─ salud: { ratioPrecioValor, dolorCuantificado, usuarioFinalEntrevistado,
│ decisorPresente, exclusionesDefinidas, materialesPendientes }
├─ planRecorte[]: { servicio, impactoEnResultado: bajo|medio|alto, ordenSugerido }
├─ redFlags[]: { señal, evidencia, severidad }
└─ huecos[]: { queFalta, porQueImporta, comoObtenerlo }
```
### 8.2 Restricciones del JSON Schema en la API
Hay que diseñar el schema con estos límites en mente:
- **Soportado:** tipos básicos, `enum`, `const`, `anyOf`, `allOf`, `$ref`/`$def`, formatos de string (`date`, `date-time`, `email`, `uri`, `uuid`).
- **No soportado:** esquemas recursivos, restricciones numéricas (`minimum`, `maximum`, `multipleOf`), restricciones de string (`minLength`, `maxLength`), restricciones complejas de array.
- **Obligatorio:** `additionalProperties: false` en todos los objetos.
- Los SDK de Python y TypeScript quitan automáticamente las restricciones no soportadas del schema enviado y las validan del lado del cliente — así que se pueden usar en Zod, sabiendo que la validación es local.
- Incompatible con citations (devuelve 400) y con prefill de mensaje de asistente.
- Primera petición con un schema nuevo tiene costo de compilación; después hay caché de 24h.
### 8.3 Reglas de validación post-generación (código, no IA)
Después de recibir el objeto y antes de renderizar:
1. `totales` debe ser **idéntico** al de la cotización en BD. Si no, se descarta el objeto y se registra el fallo. Sin excepciones.
2. Cada `alcance.items[].precioMXN` debe existir en `ServicioCotizado`. Ningún item nuevo, ningún precio distinto.
3. `diagnostico.citaLiteral.texto` debe encontrarse como substring (normalizado) en alguna fuente de contexto. Si no aparece, se marca como no verificada y se pide revisión.
4. Todo `hallazgo` con `confianza: confirmado` debe tener `evidencia` no vacía.
5. `interno` se separa del objeto antes de pasar al renderizador del documento del cliente.
6. Si `valorProblema.valorAnualEstimadoMXN` es `null`, el documento **no** puede afirmar un ratio precio/valor.
---
## 9. Lineamientos del prompt
### 9.1 Estructura de capas
El prompt se arma en tres capas, ordenadas por estabilidad (esto importa para el caché — ver §11.3):
```
Capa 1 — ESTABLE (idéntica en toda cotización, cacheable)
· Identidad y rol
· Filosofía (los 10 principios de §4)
· Frameworks de diagnóstico y pricing
· Reglas de marca y confidencialidad
· Contrato de salida y reglas de validación
· Catálogo de servicios activo (snapshot)
· Ejemplos de buena y mala redacción
Capa 2 — SEMI-ESTABLE (por cliente o sector)
· Historial del cliente si es recurrente
· Notas del sector
Capa 3 — VOLÁTIL (por cotización)
· Cotización estructurada (JSON)
· Transcripción sanitizada
· Notas y observaciones
· Instrucción específica del asesor
```
Nada dinámico —fechas, IDs, timestamps— debe entrar en la capa 1. Una fecha interpolada en el system prompt invalida el caché de todo lo que viene después.
### 9.2 Reglas del system prompt
Adaptando el prompt maestro de `s8-Prompts-Plantillas.md` §1 al contexto de E3:
**Identidad.** Consultor senior de digitalización de negocios para PyMEs mexicanas. Redacta propuestas comerciales consultivas para Consultoría E3 (Querétaro, MX). No es vendedor: es diagnosticador.
**Reglas de contenido.**
1. Cita fuentes internamente para cada dato del cliente. Si no hay fuente, no se escribe.
2. Etiqueta toda cifra como confirmada, estimada o por validar.
3. Traduce toda capacidad técnica a dinero, tiempo, riesgo evitado o tranquilidad operativa.
4. Usa las palabras textuales del cliente en el diagnóstico.
5. Nunca proponer un servicio que el asesor no eligió.
6. Nunca calcular, sugerir ni modificar un precio.
7. Cuando falte información, escribirla como pendiente — jamás rellenarla.
8. Español de México, tono cercano y profesional, tuteo con el cliente cuando el registro de la reunión lo permita. Sin lenguaje corporativo vacío (*sinergias*, *holístico*, *stakeholders*, *ecosistema*, *disruptivo*).
9. Moneda: MXN. IVA 16%. Facturación CFDI.
10. El CRM se llama **Bucéfalo**. No se menciona ninguna otra plataforma de CRM.
11. E3 ofrece únicamente servicios digitales. Si en la reunión se pidió algo de marketing tradicional o diseño para imprenta, va a la sección de exclusiones aclarando que no es un servicio de E3.
**Prohibiciones explícitas.**
- No importar rangos de precio en USD de ninguna base de conocimiento.
- No incluir datos personales de empleados del cliente, información salarial, ni temas legales o de prestaciones. Si la reunión los tocó, se omiten del documento y se anota en el bloque interno que el tema debe manejarse por el canal correspondiente.
- No prometer resultados garantizados en pauta o posicionamiento (número de ventas, posición #1 en Google). Se prometen entregables, procesos y métricas de seguimiento.
- No prometer soporte ilimitado.
- No usar la palabra "garantizado" sobre resultados de mercado.
**Formato de razonamiento.** Con `claude-opus-5` el thinking está activo por defecto. Conviene dejarlo así: el diagnóstico requiere razonamiento multi-paso (leer transcripción → detectar dolor → cuantificar → cruzar con servicios elegidos → redactar). No hay que desactivarlo.
### 9.3 Descomposición en tareas
Un solo prompt monolítico va a producir un documento mediocre en todas sus partes. Mejor pipeline de 3 pasos, cada uno con su schema:
| Paso | Entrada | Salida | Modelo sugerido |
|---|---|---|---|
| **1 · Extracción** | Transcripción + notas | Hechos estructurados: dolores, cifras con confianza, citas literales, materiales mencionados, decisiones pendientes, red flags, menciones fuera de alcance | Opus 5 (`effort: high`) — es el paso que determina la calidad de todo lo demás |
| **2 · Diagnóstico y valoración** | Hechos del paso 1 + cotización estructurada + catálogo | Hallazgos con semáforo, valor del problema, resultados a lograr, ratio precio/valor, semáforo de salud | Opus 5 (`effort: high`) |
| **3 · Redacción** | Salida de pasos 1 y 2 | `PropuestaConsultiva` completa | Opus 5 (`effort: medium`) — con el análisis hecho, esto es principalmente escritura |
Ventajas de separar: cada paso se evalúa por separado, el paso 1 se puede cachear si sólo cambia la cotización, y el asesor puede corregir los hechos del paso 1 antes de que se propaguen.
### 9.4 Ejemplos en el prompt
La base de conocimiento es enfática en que los **ejemplos positivos** funcionan mejor que las prohibiciones. Hay que incluir en la capa 1 al menos:
- 2 ejemplos de diagnóstico bien escrito (uno con dolor cuantificado, uno donde falta la cifra y se declara)
- 2 ejemplos de descripción de servicio traducida a resultado
- 2 ejemplos de exclusión específica vs. genérica
- 1 ejemplo de sección 08 bien poblada
- 1 ejemplo de qué se ve cuando la transcripción es pobre (documento honesto con muchos pendientes, en lugar de documento inventado)
Ese último ejemplo es el más importante y el que se suele omitir.
---
## 10. Guardarraíles y riesgos
### 10.1 Matriz de riesgos
| Riesgo | Severidad | Mitigación |
|---|---|---|
| **La IA inventa un precio** | Crítica | P1 + validación programática §8.3.1. Los totales se toman del objeto de cotización en el render, no del texto generado |
| **La IA inventa una cifra del cliente** | Crítica | Etiqueta de confianza obligatoria + campo `evidencia` + validación de que las citas existan en las fuentes |
| **Fuga de datos personales de empleados** | Crítica | Sanitización de PII previa (§7.3.2) + prohibición explícita en el prompt + revisión humana |
| **El anexo interno llega al cliente** | Crítica | Archivo separado con marca visual inconfundible, nunca sección oculta (§6.1) |
| **Promesa de resultado garantizado en pauta/SEO** | Alta | Prohibición explícita + lista de palabras vetadas en validación post-generación |
| **Importación de precios en USD desde el vault** | Alta | Prohibición explícita + validación: ningún monto del documento puede estar en USD ni ser un número que no exista en la cotización |
| **Se menciona una plataforma de CRM que no es Bucéfalo** | Alta | Regla de marca en prompt + validación por lista de términos prohibidos |
| **La propuesta contradice el PDF manual** | Alta | Los dos documentos se generan del mismo objeto de cotización; el de IA se regenera si la cotización cambia (invalidación por `updatedAt`) |
| **Scope creep porque las exclusiones son genéricas** | Media | Requerir mínimo 3 exclusiones con campo `origen` no vacío; si la IA no puede justificarlas, el documento se marca como incompleto |
| **La IA propone servicios que el asesor no eligió** | Media | P1 + validación §8.3.2. Las sugerencias van sólo al bloque interno |
| **Costo de API descontrolado** | Baja | Ver §11.4: el costo es despreciable frente al ticket. Aun así: caché de prefijo + límite de tamaño de transcripción + conteo previo de tokens |
| **El asesor aprueba sin leer** | Media | La UI debe hacer visible el semáforo de salud y el conteo de afirmaciones sin evidencia **antes** del botón de aprobar |
| **Rechazo por clasificador de seguridad** | Baja | Manejar `stop_reason: "refusal"` antes de leer `content`; activar `fallbacks: "default"`. Poco probable en este dominio pero el código debe no reventar |
### 10.2 El mecanismo central de honestidad
Este es el diseño del que depende que el sistema sea confiable, y merece su propia sección.
**El problema.** Todo generador de documentos con LLM tiene la misma tentación: cuando falta información, rellenar con plausible. Una propuesta comercial con una cifra inventada del negocio del cliente no es un error cosmético — es una mentira con el logo de E3 encima, que el cliente puede refutar en la reunión de presentación.
**La solución.** La plantilla HTML ya trae el canal de salida para la incertidumbre: la **sección 06, Estado de materiales del cliente**, con sus dos tarjetas y tres estados (`done` ✓, `pend` ○, `neutral` ·).
Se convierte esa sección en el destino obligatorio de todo hueco de información:
```
¿La IA no encontró el dato?
├─ ¿Es un material que el cliente debe entregar?
│ → sección 08, tarjeta "Materiales", estado pendiente
├─ ¿Es una decisión que el cliente debe tomar?
│ → sección 08, tarjeta "Decisiones", con quién decide y fecha sugerida
├─ ¿Es una cifra del negocio que nadie confirmó?
│ → sección 01b con confianza "por_validar", y punto a confirmar en la 08
└─ ¿Es información que el asesor necesita conseguir?
→ bloque interno, campo `huecos`, con "qué falta / por qué importa / cómo obtenerlo"
```
**Lo elegante del diseño:** un hueco de información deja de ser un defecto del documento y se vuelve **contenido de valor**. Un cliente que recibe una propuesta con una sección clara de "esto necesitamos de ustedes, y esto está pendiente de decidir" percibe rigor, no incompetencia. Y de paso transfiere la responsabilidad del retraso a donde corresponde — que es exactamente lo que dice el footer de la plantilla: *"El avance queda condicionado a la entrega de materiales por parte del cliente."*
**La regla de oro operativa:** una propuesta con 8 pendientes honestos es infinitamente mejor que una con 8 cifras inventadas. El prompt debe decir esto literalmente, y el ejemplo de §9.4 debe demostrarlo.
---
## 11. Consideraciones técnicas
### 11.1 Elección de modelo
Recomendación: **`claude-opus-5`** para los tres pasos del pipeline.
| Modelo | Precio (entrada / salida por MTok) | Contexto | Cuándo usarlo aquí |
|---|---|---|---|
| `claude-opus-5` | $5 / $25 | 1M | **Recomendado.** Los tres pasos. Es una cotización de decenas de miles de pesos; la diferencia de costo con Sonnet es de centavos |
| `claude-sonnet-5` | $3 / $15 (intro $2 / $10 hasta 2026-08-31) | 1M | Alternativa si el volumen crece mucho. Probar en el paso 3 (redacción) antes que en el 1 (extracción) |
| `claude-haiku-4-5` | $1 / $5 | 200K | Sólo para tareas mecánicas auxiliares: detectar idioma, clasificar tipo de archivo, sanitizar formato |
**Por qué Opus 5 y no el más barato:** la calidad del diagnóstico es el producto. Un diagnóstico mediocre produce una propuesta que compite por precio, y eso cuesta miles de pesos de margen. Ahorrar $0.15 USD por documento para perder 10% de ticket es una decisión de negocio terrible. El costo del modelo no es la variable a optimizar aquí.
**Detalles de la API que importan:**
- El thinking está **activo por defecto** en Opus 5 — no hay que configurarlo. `output_config: { effort: "high" }` para pasos 1 y 2, `"medium"` para el 3.
- `max_tokens` limita thinking + texto juntos. Para el paso 3, que produce un documento completo, hay que dar holgura: **usar streaming** con `max_tokens` alto (>16K obliga a streaming para no chocar con timeouts HTTP del SDK).
- No usar `temperature`, `top_p` ni `top_k` — están removidos en Opus 5 y devuelven 400.
- No usar prefill de mensaje de asistente — devuelve 400. Para forzar formato: structured outputs.
- Manejar `stop_reason: "refusal"` antes de leer `content`, y activar `fallbacks: "default"` con el header beta `server-side-fallback-2026-07-01`.
### 11.2 SDK y punto de integración
El proyecto es TypeScript/Next.js → **`@anthropic-ai/sdk`**. Nada de llamadas HTTP crudas ni de shims compatibles con otros proveedores.
Dos alternativas de arquitectura:
**Opción A — Route handler en Next.js.** `src/app/api/propuesta-ia/[id]/route.ts`, siguiendo el patrón de `export/pdf/[id]`. Más simple, un solo despliegue, comparte auth JWT y middleware. Riesgo: un paso de 3 llamadas con `effort: high` puede tardar minutos; requiere streaming al cliente o un patrón de job asíncrono (crear job → poll de estado → descargar).
**Opción B — En el servicio Python de `api/`.** Ya tiene MCP y auth por API key, y está pensado para agentes. Ventaja: la generación no bloquea el servidor web. Desventaja: duplica la lógica del prompt en otro lenguaje, y el proyecto ya sufre de duplicación de `calculators`.
**Recomendación:** Opción A con patrón de job. Un modelo nuevo (`PropuestaIA`: `cotizacionId`, `estado`, `objetoGenerado` JSON, `htmlRenderizado`, `costoTokens`, `createdAt`, `aprobadaPor`, `aprobadaAt`) permite historial, auditoría y regeneración sin perder versiones anteriores.
### 11.3 Caché de prompt
El caché es prefix match: cualquier cambio de un byte invalida todo lo que sigue. Orden de render: `tools``system``messages`.
Diseño para este caso:
| Contenido | Tamaño estimado | Cacheable | Notas |
|---|---|---|---|
| Filosofía + frameworks + reglas + contrato + ejemplos | 12-20K tokens | **Sí** — breakpoint aquí | Congelado. Cambia sólo cuando se edita la filosofía |
| Catálogo de servicios activo | 3-6K tokens | **Sí** — segundo breakpoint | Cambia cuando se edita el catálogo; serializar con orden determinista (por `orden`, `id`) |
| Cotización estructurada | 3-5K tokens | No | Varía por cotización |
| Transcripción + notas | 5-20K tokens | No | Varía por cotización |
Notas operativas:
- El mínimo cacheable en Opus 5 es **512 tokens** (bajó desde 1024 en Opus 4.8). La capa 1 lo supera con holgura.
- Máximo 4 breakpoints por petición. Con dos alcanza.
- Lectura de caché ≈ 0.1× del precio de entrada; escritura 1.25× (TTL 5 min) o 2× (TTL 1h). Con 2+ peticiones el TTL de 5 minutos ya sale a favor — y el pipeline de 3 pasos garantiza al menos 3 peticiones seguidas con el mismo prefijo.
- **Invalidadores silenciosos a evitar:** `new Date()` en el system prompt, `JSON.stringify` de un objeto con orden de llaves no determinista, el número de cotización en la capa 1, cambio de modelo a media conversación.
- Verificar con `response.usage.cache_read_input_tokens`. Si sale 0 en peticiones repetidas con el mismo prefijo, hay un invalidador escondido.
### 11.4 Costos estimados
Escenario base por documento (una cotización con transcripción de 60 min):
```
Entrada: capa estable ~18K + catálogo ~5K + cotización ~4K + contexto ~15K ≈ 42K tokens
Salida: objeto estructurado + razonamiento ≈ 8K tokens
Peticiones: 3 (pipeline de §9.3), las 3 comparten prefijo de 23K
```
| Configuración | Costo estimado por documento |
|---|---|
| Opus 5 sin caché, 1 petición | ≈ $0.41 USD |
| Opus 5 con caché, pipeline de 3 pasos | ≈ **$0.30 $0.55 USD** |
| Sonnet 5 con caché (precio intro) | ≈ $0.12 $0.22 USD |
| Batch API (50% descuento) si se generan en lote nocturno | ≈ mitad de lo anterior |
A 20 MXN/USD: **entre $6 y $11 MXN por documento** con Opus 5.
**El insight de negocio:** el paquete PyME más barato del catálogo es de ~$7,490 MXN. El costo de generar su propuesta consultiva es **0.1% del ticket**. En el paquete de escalamiento ($90,800 MXN) es 0.01%.
Con 40 cotizaciones al mes: **~$1222 USD/mes** (~$240440 MXN). Menos que una comida.
Conclusión: **el costo del modelo no es una restricción de diseño.** Cualquier decisión que sacrifique calidad de output para ahorrar tokens está optimizando la variable equivocada. Optimizar para calidad, medir con `countTokens` para no llevarse sorpresas, y ya.
### 11.5 Renderizado del documento
La plantilla HTML es autocontenida (CSS inline, sin dependencias externas más que Google Fonts). Dos rutas:
1. **HTML directo** — un renderizador toma el objeto validado y produce el HTML usando la plantilla como base. Ventajas: fiel al diseño, imprimible con `@media print`, editable, ligero. Requiere sustituir Google Fonts por fuentes embebidas si se quiere funcionamiento offline o dentro de un PDF.
2. **PDF vía PDFKit** — reutiliza `pdf-generator.ts`. Coherente con el resto del sistema, pero la plantilla HTML tiene gradientes, radial-gradients y tipografía serif/sans mezclada que en PDFKit son trabajo considerable.
**Recomendación:** HTML como entregable primario (se ve mejor, se comparte por link o adjunto, y el cliente lo abre en el celular). Si se necesita PDF, imprimir el HTML desde el navegador o con un headless — no reimplementar el diseño en PDFKit.
Nota heredada del proyecto: en `pdf-generator.ts` hay un bug documentado de auto-page-break en el footer (`doc.text()` en `y > page.height - margins.bottom` dispara página nueva). Si se va por PDFKit, ese bug ya tiene workaround en el código: poner `margins.bottom = 0` temporalmente.
Los colores y el logo salen de `Configuracion` (`color_primario`, `color_secundario`, `logo_base64`), así que el documento respeta la marca sin hardcodear.
---
## 12. Métricas de éxito del plugin
Si no se puede medir, no se sabe si sirvió. Estas son las métricas, separadas por qué preguntan.
### 12.1 ¿La IA escribe bien? (calidad del output)
| Métrica | Cómo medirla | Meta inicial |
|---|---|---|
| Tasa de edición del asesor | % de campos del objeto modificados antes de aprobar | <30% |
| Afirmaciones sin evidencia | Conteo por documento (validación automática) | 0 |
| Citas no verificables | Citas que no aparecen en las fuentes | 0 |
| Rechazos completos | Documentos descartados y regenerados desde cero | <10% |
| Tiempo de revisión | Minutos entre generación y aprobación | <15 min |
### 12.2 ¿Sirvió comercialmente? (impacto en el negocio)
| Métrica | Cómo medirla | Por qué importa |
|---|---|---|
| Tasa de cierre con vs. sin documento consultivo | Comparar `estado: aprobada` entre cotizaciones con y sin propuesta de IA | La prueba central de la tesis |
| Ticket promedio | Total promedio de cotizaciones aprobadas, ambos grupos | La base de conocimiento predice +16-28% con opciones bien presentadas |
| Objeciones de precio | Registrar en `observaciones` si el cliente pidió descuento | Un buen anclaje de valor debería reducirlas |
| Tiempo de ciclo | Días entre `fecha` y cambio a `aprobada`/`rechazada` | Una propuesta clara decide más rápido, en cualquier dirección |
| Scope creep | Cotizaciones que requirieron orden de cambio | El objetivo de las exclusiones específicas |
| Conversión a mensualidad | % de cotizaciones aprobadas que incluyen servicio `mensual` o plan Bucéfalo | El objetivo de la sección de mantenimiento |
**Advertencia metodológica:** con 40 cotizaciones al mes, cualquier comparación de tasa de cierre tarda meses en ser significativa, y hay mil variables confundidas (el asesor, el sector, la temporada, el tamaño del cliente). No conviene tomar decisiones grandes con 3 semanas de datos. Lo honesto es empezar midiendo §12.1, que sí es medible de inmediato, y dejar §12.2 acumular.
### 12.3 ¿Cuesta lo que debe? (operación)
- Costo por documento generado (tokens de entrada/salida × precio, guardado en `PropuestaIA.costoTokens`)
- Tasa de aciertos de caché (`cache_read_input_tokens / total`)
- Latencia p50 y p95 del pipeline completo
- Tasa de fallos: refusals, timeouts, validaciones rechazadas
---
## 13. Roadmap sugerido
Cada fase entrega algo usable. Ninguna requiere la siguiente para tener valor.
### Fase 0 — Ganancia inmediata sin IA (días)
**Imprimir `observaciones` y `notas` en el PDF y el Excel.** Es un cambio de pocas líneas en `pdf-generator.ts` y `excel-builder.ts`, y recupera información que ya se está capturando y descartando.
Esto no requiere nada de este documento. Debería hacerse ya, independientemente de si el plugin se construye.
### Fase 1 — Validación de calidad (1-2 semanas)
Sin UI, sin base de datos, sin despliegue. Un script que:
- Lee una cotización existente vía la API o Prisma
- Lee una transcripción de un archivo local
- Corre el pipeline de 3 pasos
- Escupe el objeto JSON y un HTML
**El entregable real no es el script: son 5-10 documentos generados sobre cotizaciones reales pasadas, revisados por el asesor.** Si esos documentos no son buenos, nada de lo demás importa y hay que iterar el prompt, no construir infraestructura.
Criterio de avance: el asesor dice *"esto lo mandaría a un cliente después de editarlo 10 minutos."*
### Fase 2 — Integración mínima (2-3 semanas)
- Modelo `PropuestaIA` y `ContextoCotizacion` en Prisma
- Zona de carga de archivos en `CotizacionForm.tsx`
- Route handler con patrón de job (crear → poll → descargar)
- Renderizador HTML desde la plantilla
- Vista de revisión con semáforo de salud y evidencia expandible
- Botón de aprobación que registra quién y cuándo
### Fase 3 — Inteligencia comercial (3-4 semanas)
- Anexo interno como archivo separado
- Semáforo de salud de §5.1 en el dashboard, agregado sobre todas las cotizaciones
- Sugerencia de esquema de 2-3 opciones cuando `esDoble = false`
- Alerta de subcotización (ratio precio/valor <10%)
- Métricas de §12.1 instrumentadas
### Fase 4 — Cierre del ciclo (después, con datos)
- Aprendizaje de las ediciones del asesor: qué corrige sistemáticamente → ajuste del prompt
- Comparación de tasa de cierre (sólo cuando haya volumen suficiente)
- Herramienta MCP nueva: `generar_propuesta_consultiva` en `api/app/mcp/tools.py`, para que un agente externo pueda pedirla
- Reutilización del documento en la conversación de retainer a las 2 semanas post-entrega (M9)
---
## 14. Decisiones abiertas
Cosas que hay que decidir antes de implementar y que este análisis no puede resolver solo.
| # | Decisión | Opciones | Recomendación |
|---|---|---|---|
| D1 | ¿Dónde se genera? | Route handler en Next.js vs. servicio Python | Next.js con patrón de job (§11.2) |
| D2 | ¿Dónde viven las transcripciones? | Adjuntos en BD · disco · Files API | Disco para Fase 1, adjuntos en BD para Fase 2 |
| D3 | ¿Un documento o dos archivos? | Documento del cliente + anexo interno separados, o uno solo | **Dos archivos.** El riesgo de fuga del anexo es demasiado alto (§6.1) |
| D4 | ¿HTML o PDF como entregable? | HTML · PDF · ambos | HTML primario; PDF por impresión del navegador si se necesita |
| D5 | ¿El plugin puede sugerir servicios? | Sí en la propuesta · sólo en el anexo interno · no | **Sólo en el anexo interno** (M13, criterio ético) |
| D6 | ¿Se regenera al cambiar la cotización? | Automático · manual con aviso · manual silencioso | Manual con aviso de desincronización visible |
| D7 | ¿Quién puede aprobar? | Cualquier asesor · sólo el dueño de la cotización · rol admin | El asesor dueño; admin puede aprobar cualquiera |
| D8 | ¿Se guarda la transcripción cruda o sólo la sanitizada? | Cruda · sanitizada · ambas | Sólo sanitizada en BD. La cruda no debe persistir con datos de empleados |
| D9 | ¿Se versiona el prompt? | Sí, con las propuestas apuntando a su versión · no | Sí. Sin esto no se puede saber si un cambio de prompt mejoró o empeoró |
| D10 | ¿Qué pasa si no hay transcripción? | Bloquear · generar con lo que haya · generar sólo el esqueleto | Generar con lo que haya, con la sección 08 muy poblada y aviso claro de confianza baja |
---
## Anexo A — Mapeo campo → sección
Referencia rápida para implementación.
| Sección del documento | Fuentes de datos |
|---|---|
| Hero | `Cliente.nombre`, `.empresa`, `Cotizacion.numero`, `.fecha`, `.vigencia`, `.proyecto`, `User.name` (asesor) |
| 01 Diagnóstico | Transcripción, `Cotizacion.observaciones`, `ServicioCotizado.notas`, archivos de notas |
| 01b Valor del problema | Transcripción (4 dimensiones), `totales.total` para el ratio |
| 02 Resultados | Derivado del diagnóstico + `ServicioCotizado.entregables` |
| 03 Alcance | `ServicioCotizado[]` (nombre, fase, tipoPago, precio, tiempoEntrega, entregables), `Categoria.nombre` |
| Totales | `calculators.ts` — copiados literalmente, `IVA_RATE`, descuentos |
| 04 Opciones | `Cotizacion.esDoble`, `.opcionesMetadata`, `ServicioCotizado.opcion` |
| 05 No incluye | Transcripción (menciones fuera de alcance), `opcionesMetadata[].noIncluye` |
| 06 Por qué tiene sentido | `ServicioCotizado.beneficios`, cruzado con hallazgos del diagnóstico |
| 07 Mantenimiento | `PlanBucefaloCotizacion`, servicios con `tipoPago: mensual`, `modeloCobro: retainer`, `Bono[]` |
| 08 Materiales y decisiones | Huecos detectados + dependencias mencionadas en la transcripción |
| 09 Backlog | Fricciones mencionadas fuera del alcance de fase 1 |
| Footer | `Configuracion.logo_base64`, `.color_primario`, `.color_secundario` + condiciones fijas |
| Financiamiento (si aplica) | `FinanciamientoPlan[]`, `Cotizacion.incluirFinanciamiento`, `calcularFinanciamiento()` |
| Anexo interno | Todo lo anterior + `RegistroHoras` si aplica + análisis de §5.1 |
---
## Anexo B — Checklist de calidad antes de enviar
Adaptado de `Onboarding-Cliente/00-Guia-de-Uso-y-Checklist-Calidad.md`. Este checklist debe estar visible en la UI de revisión, no enterrado en un documento.
**Problema y valor**
- [ ] El problema está redactado en lenguaje del cliente, sin jerga técnica
- [ ] Hay al menos un ejemplo real y reciente del problema, citado
- [ ] Se estimó el costo anual, con las cifras etiquetadas por confianza
- [ ] La métrica de éxito tiene línea base, resultado deseado y periodo
- [ ] Consta si se habló con el usuario operativo, no sólo con quien autoriza
**Viabilidad y alcance**
- [ ] Se sabe dónde viven los datos/accesos y quién los autoriza
- [ ] La fase 1 resuelve un cuello de botella prioritario, no todos
- [ ] Está explícito: incluido ahora / excluido ahora / backlog futuro
- [ ] Hay responsables del cliente y decisiones pendientes identificadas
- [ ] Está definido qué significa "terminado" y quién acepta
**Propuesta comercial**
- [ ] Si hay opciones, cambian alcance o nivel de servicio — no sólo el precio
- [ ] La opción recomendada sí cumple el resultado de negocio definido
- [ ] El precio se sustenta en valor, costo real y mercado
- [ ] El mantenimiento aparece con servicios concretos, límites y tiempos de respuesta
- [ ] Se presentará en llamada, no como cifra suelta por mensajería
**Integridad del documento generado**
- [ ] Todo número coincide con el documento manual
- [ ] Cero afirmaciones sin evidencia
- [ ] Cero cifras marcadas como confirmadas sin origen
- [ ] Ningún dato personal de empleados del cliente
- [ ] Ningún monto en USD
- [ ] Ninguna promesa de resultado garantizado
- [ ] El CRM se menciona como Bucéfalo
- [ ] El anexo interno está en archivo separado y no se va a enviar
---
## Anexo C — Cruce con la base de conocimiento
Para profundizar durante la implementación:
- **Preguntas de diagnóstico completas:** `Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md`
- **Playbooks de pricing y cierre:** `08-Monetizacion-Onboarding-Pricing-Seguimiento.md`
- **Errores a evitar:** `01-Errores-a-Evitar.md`, `sintesis/s3-Errores-Fatales.md`
- **Modelos de pricing detallados:** `sintesis/s2-Pricing-Cobranza.md`
- **Prompts y plantillas base:** `sintesis/s8-Prompts-Plantillas.md` (§1 system prompt, §3 diagnóstico, §4 propuesta, §9 output estándar)
- **Las cuatro sesiones de onboarding:** `Onboarding-Cliente/01` a `04`, con `00-Guia-de-Uso-y-Checklist-Calidad.md` como criterio de calidad
- **Playbook maestro:** `Guia_Onboarding_Cliente_y_Ruta_de_Trabajo.md`
- **Paquetes del catálogo:** `docs/paquetes-recomendados-pyme.md` (este repo)
- **Convenciones del proyecto:** `AGENTS.md` (este repo)
---
## Cierre
El Cotizador E3 ya sabe cuánto cobrar. Lo que le falta es explicar por qué vale.
Ese "por qué" no se inventa: se extrae de lo que el cliente ya dijo en la reunión, de lo que el asesor ya anotó en el campo de observaciones, y del catálogo que ya está armado. Está todo ahí, disperso y sin usar.
El plugin no es un generador de texto bonito. Es un **traductor**: convierte una lista de precios en un argumento de negocio, usando material que ya existe y sin tocar un solo número. Y cuando no tiene material suficiente, lo dice — porque una propuesta con pendientes honestos cierra mejor que una con cifras inventadas, y porque el día que un cliente refute un dato inventado, el costo no lo paga el modelo: lo paga la marca.
---
*Documento de análisis previo a implementación · Consultoría E3 · 2026-07-28*
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,511 @@
# Propuesta consultiva con IA — Diseño de Fase 0 y Fase 1
> **Qué es este documento:** la especificación técnica cerrada de las dos primeras fases del
> plugin descrito en [`docs/BI-propuesta-consultiva-IA.md`](../../BI-propuesta-consultiva-IA.md).
> Aquel documento es el análisis de negocio y dice explícitamente que no es un spec. Este sí lo es.
>
> **Alcance:** Fase 0 (recuperar el contexto humano que hoy se descarta, más los bloqueadores que
> el análisis destapó) y Fase 1 (script local que valida si la IA escribe documentos utilizables).
> Fases 2-4 quedan fuera y tendrán su propio ciclo.
>
> **Fecha:** 2026-07-28 · **Estado:** Fase 0 desplegada en producción · Fase 1 pendiente
> **Base verificada:** commit `26e6d3a9`
---
## 0. Registro de ejecución — Fase 0 (2026-07-28)
Fase 0 está en producción. Tres cosas salieron distinto de lo diseñado y este documento
quedaría mintiendo si no se dijeran.
### 0.1 El movimiento de datos (Despliegue B) se canceló, no se pospuso
El §4.3 describe un `UPDATE` que mueve `observaciones``observacionesInternas`. **No se
va a ejecutar nunca.** Al inspeccionar producción antes de migrar, la única fila con
`observaciones` no vacías resultó ser `UJ2606AG777` (estado `aprobada`), y su texto son
condiciones de pago dirigidas al cliente en segunda persona: *"llevamos una bitácora de
horas con acceso para ti; a fin de mes te enviamos el reporte… solo pagas las horas
efectivamente trabajadas."*
Aplicar el `UPDATE` conservador habría ocultado condiciones ya acordadas de un documento
emitido. Esa fila ya estaba clasificada correctamente. La migración quedó reducida a
`ADD COLUMN IF NOT EXISTS`: cero filas modificadas. Verificado tras el despliegue —
conteos `2/3/59/5/2` idénticos al respaldo y cero filas en `observacionesInternas`.
**Lección para el futuro: mirar el dato real antes de diseñar su migración.** El diseño
"conservador" era el equivocado para estos datos.
### 0.2 El servidor MCP se retiró en vez de asegurarse
El §4.1 diseñaba Fase 0-A como *"aplicar `require_auth` + lista blanca de columnas"*. Eso
se implementó y se verificó en producción (401 sin credencial). Después se descubrió que,
**con credencial válida, el endpoint devolvía 500**: el código construía un
`StreamableHTTPServerTransport` por petición sin manejo de sesión. Llevaba roto desde antes
de este trabajo — lo que estaba expuesto era la puerta abierta de un cuarto averiado.
Dado que nadie lo usaba, se retiró por completo (endpoint, paquete `api/app/mcp/` y la
dependencia). Es una mitigación más fuerte que autenticarlo. Recuperable con
`git show edb500f5:api/app/mcp/server.py`.
### 0.3 Una dependencia sin fijar tumbó el API 50 minutos
`api/requirements.txt` tenía `mcp>=1.0.0`. La primera reconstrucción en dos semanas tomó
`mcp` 2.0.0, que eliminó `Server.list_tools()`. El import lanzó `AttributeError` al
arrancar y el contenedor quedó en crash-loop.
Lo agravó que `main.py` capturara solo `ImportError` alrededor del montaje del MCP: un
`AttributeError` se escapó y tumbó también el REST, por un componente opcional.
Once de las doce dependencias eran rangos `>=` sin techo. **Todas se fijaron a versión
exacta** — las que corrían sanas en producción. El `web` (Next.js) nunca estuvo caído:
tiene `package-lock.json` y es reproducible.
---
## 1. Por qué el alcance es este
El documento de negocio describe ocho subsistemas. Construirlos de una vez significa un mes de
trabajo antes de saber si la tesis funciona. Su propio §13 argumenta la salida:
> *"El entregable real no es el script: son 5-10 documentos generados sobre cotizaciones reales,
> revisados por el asesor. Si esos documentos no son buenos, nada de lo demás importa y hay que
> iterar el prompt, no construir infraestructura."*
Se adopta ese criterio. Fase 1 termina cuando el asesor puede decir *"esto lo mandaría a un
cliente después de editarlo 10 minutos"* — o cuando queda claro que no.
El análisis previo a este spec encontró cuatro problemas que el documento de negocio no anticipaba.
Tres son bloqueadores y entran al alcance; el cuarto cambia dónde vive un dato.
---
## 2. Hallazgos que modifican el plan original
Los cuatro están verificados en disco, no inferidos.
### 2.1 🔴 El endpoint MCP publica todas las cotizaciones sin autenticación
| Eslabón | Evidencia |
|---|---|
| `POST /mcp` no valida nada | `api/main.py:107` — sin un solo `Depends()`. Cero coincidencias de `require_auth` en el archivo |
| La función de auth existe y no se usa ahí | `api/app/auth.py:45` define `require_auth` |
| La herramienta devuelve la fila completa | `api/app/mcp/server.py:250``SELECT c.*` seguido de `dict(cot)`, sin lista blanca |
| Tiene dominio público en producción | `docker-compose.coolify.yml:67``SERVICE_FQDN_API_8000` |
El agujero **ya existe**: precios, catálogo y datos de clientes son legibles hoy por cualquiera.
Lo relevante para este spec es que `SELECT c.*` publicaría la columna nueva de notas internas sin
que nadie toque `server.py`. Entra al alcance como **Fase 0-A**, previa a todo lo demás.
### 2.2 🟠 No existe una fuente de verdad para los totales
La regla de validación §8.3.1 del documento de negocio exige comparar contra *"el total de la
cotización en BD"*. No hay tal cosa:
- Ninguna función devuelve los totales de una cotización. El cálculo está duplicado con
`.filter().reduce()` en **siete** consumidores (PDF, Excel, detalle, PreciosEditables, el
formulario, la lista y el dashboard), con criterios que no coinciden.
- El IVA está hardcodeado como `* 1.16` en `pdf-generator.ts:328-329` y `excel-builder.ts:377-378`,
ignorando `IVA_RATE`.
- El flag `Cotizacion.incluirIva` no se respeta en esos cálculos.
Hoy el PDF y el Excel de una misma cotización pueden discrepar y nada lo detecta. Entra como
**Fase 0-B**.
### 2.3 🔴 El Excel es un documento del cliente, no una herramienta interna
Este hallazgo corrigió un supuesto equivocado del diseño inicial, que proponía poner las notas
internas en el Excel.
| Evidencia | Ubicación |
|---|---|
| Banner con la razón social de E3 | `excel-builder.ts:165` |
| `"En atencion a:"` + nombre del cliente | `excel-builder.ts:181` |
| Nota legal de precios, IVA y vigencia | `excel-builder.ts:400` |
| Razón social + domicilio fiscal al pie de cada hoja de detalle | `excel-builder.ts:539-547` |
| Nombre de archivo `{empresa} - {cliente} - {numero}.xlsx` | `src/app/api/export/excel/[id]/route.ts:76` |
| Botón en la misma fila que Preview PDF y PDF | `src/app/(app)/cotizaciones/[id]/page.tsx:93-95` |
Escenario de fallo: el asesor exporta ambos, caen en Descargas con el mismo prefijo, los arrastra
juntos al correo, y el cliente abre la última pestaña y lee el plan de recorte de precio y las red
flags sobre él mismo.
**Consecuencia:** en Fase 0 las notas internas no entran a **ningún** exportador. Viven solo en la
app. Es la misma garantía estructural que ya tiene el PDF.
### 2.4 🟠 Las transcripciones se sincronizarían a la nube de MEGA
El repo vive en `H:\MegaSync\Proyectos\Cotizador`. El `.megaignore` actual excluye únicamente
`.next`, `node_modules` y `.turbo`. Cualquier transcripción guardada dentro del repo se sube a la
nube: nombres de empleados, comentarios de desempeño, cifras salariales. `.gitignore` protege el
repositorio, no la sincronización.
**Consecuencia:** `contexto/` y `salidas/` van a `.gitignore` **y** a `.megaignore`, y la exclusión
se verifica empíricamente antes de colocar material real.
---
## 3. Decisiones cerradas
Ninguna de estas se re-litiga durante la implementación.
| # | Decisión | Resolución | Razón |
|---|---|---|---|
| D1 | Alcance del ciclo | Fase 0 + Fase 1 | Validar calidad antes de construir infraestructura |
| D2 | Separar interno de visible | Campo nuevo `observacionesInternas`; `observaciones` pasa a ser texto del cliente | Hace la fuga estructuralmente imposible en vez de depender de un marcador que se olvida |
| D3 | Dónde viven las notas internas | Solo en la app. Ningún exportador | §2.3 |
| D4 | Forzar el schema sin structured outputs | Tool-calling: `input_schema` de la herramienta = contrato. Zod valida siempre | MiniMax no soporta `output_config` |
| D5 | Proveedor | MiniMax-M3 vía endpoint compatible con Anthropic | Decisión del negocio (Token Plan contratado) |
| D6 | Sanitización de PII | Filtro de **salida** solamente | El modelo necesita el contexto completo para diagnosticar; el riesgo real es lo que llega al cliente |
| D7 | Seguridad del MCP | Cerrar el agujero primero: `require_auth` + lista blanca de columnas | §2.1 |
| D8 | Totales | Función canónica `calcularTotalesCotizacion()` + migrar PDF y Excel | §2.2 |
| D9 | Base del ratio precio/valor | Primer año **con IVA** (único + mensual × 12) | Es el desembolso real a 12 meses; hace comparable el ratio contra un valor *anual* |
| D10 | Nombres de empleados detectados | Advertir, no bloquear | Un bloqueo heurístico produce falsos positivos constantes y enseña a ignorar la alerta |
| D11 | Ubicación de transcripciones | Dentro del repo, en `.gitignore` **y** `.megaignore` | §2.4 |
| D12 | Migración de datos | Dos despliegues: A aditivo, B mueve datos | §4.3 |
---
## 4. Fase 0 — Recuperar el contexto humano
Cuatro bloques. A y B son prerrequisitos de C.
### 4.1 Fase 0-A · Cerrar el MCP
> **Superado por §0.2.** Esto se implementó tal cual y se verificó (401 sin credencial),
> pero después el servidor MCP se retiró por completo. Se conserva el diseño original
> porque documenta el agujero que existía y por qué importaba.
1. Aplicar `require_auth` (`api/app/auth.py:45`) al endpoint `POST /mcp` en `api/main.py:107`.
2. Sustituir el `SELECT c.*` de `_obtener_cotizacion` (`api/app/mcp/server.py:250`) por una lista
blanca de columnas explícita, con el mismo criterio que el REST ya aplica vía
`CotizacionResponse` (`api/app/models/cotizacion.py:130-152`).
3. Auditar el resto de `server.py` en busca de otros `SELECT *` y darles el mismo tratamiento.
**Criterio de aceptación:** una petición `POST /mcp` sin credencial devuelve 401, y
`obtener_cotizacion` con credencial válida no incluye `observacionesInternas` en su salida.
### 4.2 Fase 0-B · Totales canónicos
Escribir en `src/lib/calculators.ts`:
```ts
calcularTotalesCotizacion(cotizacion): {
subtotalUnico, subtotalMensual,
ivaUnico, ivaMensual,
totalUnico, totalMensual,
totalPrimerAnio, // totalUnico + totalMensual * 12, con IVA — base del ratio (D9)
moneda
}
```
Reglas: usa `IVA_RATE`, respeta `Cotizacion.incluirIva`, y solo suma partidas con
`seleccionado: true`. Para cotizaciones dobles se apoya en `calcularTotalesOpcion`.
Migrar a ella `pdf-generator.ts` y `excel-builder.ts`, eliminando los `* 1.16`. Los otros cinco
consumidores se migran en un ciclo posterior; se documenta la deuda.
**Criterio de aceptación:** para un conjunto de cotizaciones reales, el total del PDF y el del
Excel coinciden dígito por dígito, y una cotización con `incluirIva: false` no muestra IVA en
ninguno.
### 4.3 Fase 0-C · El campo de contexto humano
**Despliegue A — solo aditivo.**
- `ALTER TABLE "Cotizacion" ADD COLUMN "observacionesInternas" TEXT;` — sin `UPDATE`.
- El formulario muestra dos textareas visualmente inconfundibles: *"Observaciones (las ve el
cliente)"* y *"Notas internas (no salen de la app)"*, con badge de color distinto.
- La vista de detalle muestra ambos campos; el histórico aparece etiquetado como
*"Observaciones (histórico, sin clasificar)"*.
- El PDF imprime **solo** `observaciones`. `CotizacionPDFData` (`pdf-generator.ts:31-53`) no
declara `observacionesInternas` — la garantía es estructural, no disciplinaria.
- Ningún exportador recibe el campo interno.
**Despliegue B — movimiento de datos. CANCELADO, ver §0.1.** El `UPDATE` de abajo no se
ejecutó ni se va a ejecutar: la única fila afectada contenía texto dirigido al cliente y ya
estaba donde debía. Se conserva por si algún día aparece una instalación con datos mal
clasificados.
```sql
UPDATE "Cotizacion"
SET "observacionesInternas" = "observaciones",
"observaciones" = NULL
WHERE "observaciones" IS NOT NULL
AND btrim("observaciones") <> ''
AND "observacionesInternas" IS NULL; -- guarda de idempotencia, obligatoria
```
Antes de correr B, exportar y guardar **fuera de MegaSync**:
```sql
COPY (SELECT id, numero, estado, observaciones FROM "Cotizacion"
WHERE observaciones IS NOT NULL AND btrim(observaciones) <> '')
TO STDOUT WITH CSV HEADER;
```
Ese CSV es el `down` que Prisma no da: no hay migraciones de reversa en el repo ni servicio de
backup en `docker-compose.coolify.yml`.
**Por qué dos despliegues.** Si A mueve los datos y el despliegue falla, Coolify redespliega la
imagen anterior, el código viejo lee `observaciones` (NULL en el 100% de las filas) y el asesor ve
todas sus notas desaparecidas — sin error, sin log, en el momento de máximo estrés.
**Puntos de integración del campo nuevo** (rastreados de punta a punta):
`prisma/schema.prisma`, `src/lib/schemas.ts` (los dos schemas), `src/lib/store.ts`,
`CotizacionForm.tsx`, `POST /api/cotizaciones`, `PUT /api/cotizaciones/[id]`,
`cotizaciones/[id]/page.tsx`, `cotizaciones/[id]/editar/page.tsx`, y en Python los tres modelos
Pydantic (`CotizacionCreate`, `CotizacionUpdate`, `CotizacionResponse`) más el `_add` del PUT.
Los INSERT de Python **no** son urgentes: la columna es nullable y Postgres pone NULL. Los modelos
Pydantic sí lo son — hay precedente demostrado de que sin declararlos el campo nunca sale del API
(`incluirIva` existe en la tabla desde `20260630000000` y el API Python no lo devuelve).
**Deuda declarada:** `zod` se importa en `src/lib/schemas.ts:1` y resuelve transitivamente a 4.3.6,
pero no está en `package.json`. Se declara explícitamente (`npm i zod@^4.3.6`); un
`npm ci --omit=dev` revienta hoy sin eso, y Fase 1 depende fuertemente de zod.
### 4.4 Fase 0-D · Bugs vecinos aprobados
| Bug | Arreglo |
|---|---|
| Orden de partidas | Ninguna consulta tiene `orderBy` en `servicios`, y `drawSection` (`pdf-generator.ts:216-268`) asume que vienen agrupadas por fase. Añadir `orderBy: [{fase}, {createdAt}]` en las cuatro rutas de export |
| Datos bancarios en borrador | `export/pdf/route.ts:45` llama solo a `getConfigBranding()`; el Excel llama a ambas. Añadir `getConfigBancaria()` |
| Partida por demanda en $0 | Las copias locales de `detalleModelo` en `pdf-generator.ts:21-29` y `excel-builder.ts:58-66` no manejan `modeloCobro === "demanda"`; la canónica de `calculators.ts:23-41` sí. Usar la canónica |
| Bonos con dos redacciones | `pdf-generator.ts:377-384` tiene seis bonos hardcodeados y la tabla `Bono` del seed dice otra cosa; la tabla no se consulta en `src/`. Definir la tabla como fuente de verdad |
---
## 5. Fase 1 — Script local de validación de calidad
Sin base de datos nueva, sin UI, sin despliegue. Un script que lee una cotización y una
transcripción, corre el pipeline, y escribe un JSON, un HTML y un reporte.
### 5.1 Contratos compartidos
Los cuatro diseños explorados compartían vocabulario pero no contratos. Estas resoluciones se
escriben **una vez**, en un solo archivo de tipos que todos importan. Sin esto el sistema arranca,
no falla, y valida el vacío.
| # | Conflicto | Resolución |
|---|---|---|
| B1 | Clave de join partida ↔ IA | `refPartida`, formato `/^P\d{2}$/``"P01"`. El cuid de `ServicioCotizado` **nunca** sale hacia el proveedor: no es estable entre ediciones porque el PUT hace `deleteMany` + `createMany` |
| B2 | Forma de la evidencia | Arrays de IDs (`citas[]`, `hechos[]`), no prosa. Auditable por máquina, que es el punto de P3 |
| B3 | Cita literal | Referenciada por ID. Se **verifica en el paso 1**, donde vive el texto, y su fallo bloquea antes de gastar los pasos 2 y 3 |
| B4 | Árbol de módulos | `src/lib/propuesta/{ia,validacion,render}/`, entrada `scripts/propuesta-consultiva.ts`, tipo raíz `PropuestaConsultiva` |
| B5 | Códigos de salida | `0` limpio · `1` requiere revisión · `2` bloqueado · `3` error del proveedor · `64` error de uso |
| B6 | Variables de entorno | Solo `MINIMAX_API_KEY` / `MINIMAX_BASE_URL` / `MINIMAX_MODEL`, pasadas explícitas al constructor. **Nunca** prefijo `ANTHROPIC_*`: el SDK las lee por su cuenta y un `ANTHROPIC_BASE_URL` exportado en la shell mandaría la clave de MiniMax a Anthropic |
| B7 | `tool_choice` | No se envía en Fase 1. Fijarlo solo en el reintento le da al reintento un prefijo distinto, o sea que se paga el contexto completo justo cuando es más grande |
| B8 | Snapshot del catálogo | Sin `id` y sin `precioBase`. La resolución es por `nombre` + `fase`, reportando ambigüedad — `ServicioCatalogo.nombre` no es `@unique` |
| B9 | Valor anual del problema | `dimensiones[].calculo.montoAnualMXN`. El total se suma **en código**, nunca lo emite el modelo |
### 5.2 El corte: qué copia el código y qué genera el modelo
La protección más fuerte del principio P1 no es una regla de validación: es que **ningún schema
que llena el modelo contiene un solo campo de dinero de la cotización**.
- **El modelo devuelve:** `refPartida` + prosa (diagnóstico, resultados, descripciones en lenguaje
de beneficio, exclusiones, pendientes, backlog).
- **El código inyecta después:** precios, totales, IVA, número de cotización, vigencia, fechas.
Así P1 deja de ser algo que alguien puede olvidar validar y pasa a ser estructuralmente imposible
de violar. El ratio precio/valor también lo calcula el código (D9) y se imprime solo en el anexo
interno.
### 5.3 El pipeline de tres pasos
| Paso | Entrada | Salida | Por qué separado |
|---|---|---|---|
| 1 · Extracción | Transcripción + notas + observaciones | Hechos: dolores, cifras con confianza, citas literales con ID, materiales, decisiones, red flags, menciones fuera de alcance | Determina la calidad de todo lo demás. Es corregible por el asesor antes de propagarse |
| 2 · Diagnóstico | Hechos del paso 1 + cotización + catálogo | Hallazgos con semáforo, valor del problema, resultados a lograr | Razonamiento, no redacción |
| 3 · Redacción | Salidas de 1 y 2 | `PropuestaConsultiva` | Con el análisis hecho, esto es escritura |
Cada paso define una herramienta cuyo `input_schema` es su contrato, derivado del Zod con
`z.toJSONSchema` nativo (verificado: preserva `additionalProperties` y `description`, inlinea
subschemas, omite `superRefine`, y emite un `$schema` que hay que borrar). **No hace falta
`zod-to-json-schema`.**
### 5.4 El presupuesto de reintentos
Hallazgo verificado que ningún diseño anticipó: **`.superRefine` no corre si la forma falla.** Eso
crea cuatro compuertas secuenciales:
1. Forma Zod — siempre
2. `superRefine` (IDs únicos, refs internas) — solo si 1 pasa
3. Validación cruzada de runtime (`refPartida` reales) — solo si 2 pasa
4. Filtro de contenido y PII — solo si 3 pasa
Con tres intentos y cuatro compuertas, el modo de fallo más probable del piloto es *"tres llamadas
pagadas, cero documento"* — justo lo que Fase 1 no puede permitirse, porque su criterio de éxito es
que el asesor lea algo.
**Diseño adoptado:**
- El filtro de contenido y PII corre sobre el objeto **crudo** aunque Zod haya fallado, recorriendo
rutas parciales. Los hallazgos de las cuatro compuertas se acumulan en **un solo** mensaje de
corrección.
- Presupuesto: **3 intentos totales** por paso (1 inicial + 2 reintentos). El paso 3 recibe **4
intentos totales** (1 + 3) por ser el más largo y el que atraviesa más compuertas.
- Al agotarse los intentos se escribe igual el HTML del cliente **si y solo si** no hay bloqueantes
de PII, moneda, garantía ni marca. Salir con código 1, no con 2.
- En la rama `tool_incorrecta` hay que emitir un `tool_result` con `is_error: true` por **cada**
`tool_use` del turno antes del texto de corrección; un mensaje de usuario plano después de un
`tool_use` produce un 400.
### 5.5 Validación post-generación
Las seis reglas de §8.3 del documento de negocio, más una nueva que el análisis de riesgo destapó.
| Regla | Qué comprueba | Al fallar |
|---|---|---|
| **R0** (nueva) | Ningún n-grama de 7 tokens de `observacionesInternas` ni de `ServicioCotizado.notas` aparece en el HTML del cliente | **Bloquea** |
| R1 | Todo monto en prosa pertenece al conjunto de la economía (BD) o al del valor (declarado y auditado por R4) | Bloquea |
| R2 | Todo `refPartida` existe en `ServicioCotizado`. Ninguna partida nueva | Bloquea |
| R3 | Cada cita literal existe en las fuentes, con normalización de 9 pasos (NFD sin diacríticos, minúsculas, puntuación, espacios, muletillas de ASR). Corre en el paso 1 | Bloquea si cruza hablantes |
| R4 | Todo hallazgo `confirmado` tiene `citas.length + hechos.length > 0`. Corre sobre el objeto del **paso 2** | Degrada a `por_validar` |
| R5 | El bloque `interno` se separa antes de renderizar el documento del cliente | Bloquea |
| R6 | Sin valor anual, ninguna afirmación de ratio en la prosa | Bloquea la afirmación |
**R0 existe porque las otras seis no la cubren.** `R5` compara el bloque interno que *produjo el
modelo* contra el HTML; nunca compara `observacionesInternas`, que es una **entrada** que el modelo
recibe en el prompt y puede copiar literalmente en cualquier campo de prosa.
**Filtro de PII de salida** (advertencia, no bloqueo — D10): montos en USD, promesas de resultado
garantizado, CRMs que no sean Bucéfalo, datos de empleados, temas salariales o legales. Los
patrones heurísticos se marcan como tales en el reporte, con su extracto, para que el asesor
juzgue. Se debe añadir a la cosecha de nombres las etiquetas de hablante del VTT y los dos campos
de notas, que hoy quedarían fuera.
### 5.6 El renderizador HTML
**Es el entregable primario y ningún diseño lo tenía.** Primer paso de Fase 1: copiar
`D:/Documents/plantilla_cotizacion.html` (27,100 bytes, hoy fuera del repo) a
`src/lib/propuesta/render/plantilla.ts` y congelar su CSS. Sin esto, Fase 1 termina con tres JSON
impecables y nada que enseñar.
Cambios obligatorios sobre la plantilla:
| Problema | Arreglo |
|---|---|
| 12 `rgba()` con canales escritos a mano duplican `--blue`, `--cream`, `--green`, `--amber` | Parametrizar de verdad, o `color_primario` produce un documento con dos azules |
| `.section { page-break-inside: avoid }` en secciones enteras | Con 15-20 partidas empuja la sección a hoja nueva y la parte igual. Aplicar a filas, no a secciones |
| No hay `@page` | Todo PDF impreso lleva `localhost:3000/...` estampado al pie de una propuesta comercial |
| Tema oscuro + "Gráficos de fondo" desactivado por defecto en Chrome | El PDF sale con texto crema sobre blanco. Necesita un modo de impresión claro |
| Cero hueco para el logo | El Anexo A del documento de negocio lo mapea como si existiera |
| `.plan-grid` fijo a `1fr 1fr` | La sección de opciones pide tres tarjetas (anclaje A → B → C) |
| Números de sección escritos a mano con cinco secciones condicionales | Un documento sin mantenimiento salta del 04 al 06. Numerar en el renderizador |
| "Valor del problema" pide tabla de cuatro columnas | No hay patrón CSS; hay que crearlo |
Estrategia: template literals puros, sin motor de plantillas. El proyecto no tiene ninguno
instalado y no lo amerita.
**Inyección CSS:** `PUT /api/configuracion` filtra las claves permitidas pero no valida los valores,
y esos valores terminan dentro de un `<style>`. Validar `/^#[0-9a-fA-F]{3,8}$/` en el punto de
inyección y caer al default del seed si no cumple.
**Anexo interno:** archivo separado, `INTERNO-NO-ENVIAR.html`, visualmente inconfundible. Nunca una
sección oculta ni `display: none`.
### 5.7 Ergonomía del script
```
npx tsx scripts/propuesta-consultiva.ts \
--cotizacion UJ2607UJ003 \
--transcripcion contexto/UJ2607UJ003/reunion.txt \
--salida salidas/UJ2607UJ003/
```
Banderas: `--cotizacion --transcripcion --salida --desde-paso --solo-validar`. Nada más.
`--desde-paso` y `--solo-validar` se ganan su lugar: son la diferencia entre iterar el prompt del
paso 3 veinte veces o cuatro.
Artefactos: `propuesta.html`, `INTERNO-NO-ENVIAR.html`, `propuesta.json` (con la traza embebida),
`REPORTE.md`, y `traza/pasoN.json` porque `--desde-paso` los necesita.
Consola: los tres pasos tardan; imprime progreso por paso, tokens consumidos y aciertos de caché.
`contexto/` y `salidas/` van a `.gitignore` **y** a `.megaignore` (D11), con la exclusión
verificada empíricamente antes de colocar material real.
### 5.8 Prompts y caché
Tres capas por estabilidad: estable (filosofía, frameworks, reglas, contrato, ejemplos),
semi-estable (catálogo), volátil (cotización y transcripción). Dos breakpoints de `cache_control`.
MiniMax soporta caché con `cache_control` (4 breakpoints, TTL 5 min, verificable con
`usage.cache_read_input_tokens`), pero el prefijo arranca en `tools`, no en `system`.
**Invalidadores a prevenir:** `new Date()` en la capa 1, `JSON.stringify` con orden de llaves no
determinista, el número de cotización en la capa estable, cambio de modelo a media ejecución.
Serializar el catálogo con orden explícito (`orden`, `id`).
**Los ejemplos few-shot deben validar contra su propio schema Zod al arrancar el script.** Cuesta
diez líneas y es el único test que este proyecto va a tener. Un ejemplar defectuoso no produce un
error: produce N documentos parecidos entre sí y se diagnostica tarde. El análisis encontró tres
defectos en los ejemplos propuestos —uno que no valida, uno que mete el nombre de pila de una
empleada en un campo del cliente, y uno que dispara el filtro de montos con `"400 pesos"`— que hay
que corregir antes de la primera corrida.
También hay que pasar el **texto fijo de la plantilla** por el catálogo de PII una vez antes de la
primera corrida, o la primera ejecución devolverá bloqueos que vienen del footer y no del modelo.
---
## 6. Supuestos sin verificar
Se documentan porque el diseño se apoya en ellos y la primera corrida debe medirlos.
| # | Supuesto | Qué hacer |
|---|---|---|
| V1 | MiniMax devuelve `cache_read_input_tokens` con `tools` fijo y 2 breakpoints | Primera medición obligatoria. Si no cachea, mandar las tres herramientas en cada petición es costo puro |
| V2 | Con `stop_reason: "max_tokens"` el SDK entrega `input` como objeto parcial | Probar a propósito con `max_tokens: 200` |
| V3 | `countTokens` existe en el endpoint compatible | Degradar a estimador local y marcar las cifras como estimadas en la traza |
Verificados durante el análisis, sin acción pendiente: `z.toJSONSchema` preserva lo necesario;
`z.prettifyError` emite multi-error con ruta; `tsx` resuelve el alias `@/`; `core.autocrlf=true`
exige normalizar saltos de línea.
---
## 7. Riesgos aceptados
| Riesgo | Por qué se acepta | Mitigación |
|---|---|---|
| La transcripción cruda con datos de empleados viaja a MiniMax | El modelo necesita el contexto para diagnosticar (D6) | Queda escrito aquí, no solo en la conversación. El filtro de salida protege el documento, no el tránsito |
| El PDF de una cotización ya enviada deja de ser reproducible | Fase 0 cambia el renderizador; el sistema no archiva los PDF emitidos | Que el asesor archive lo enviado. Documentar en AGENTS.md que los exportadores no son reproducibles en el tiempo |
| El hábito del asesor no migra solo | Tras Fase 0, el textarea donde lleva meses escribiendo contexto interno pasa a imprimirse en el PDF | Avisar por el canal del equipo, no solo cambiar la etiqueta |
| Fase 1 añade una tercera superficie solo en TypeScript | AGENTS.md ya exige paridad entre dos backends | Documentar la divergencia. Si Fase 4 expone esto por MCP, la validación tendrá que existir del lado Python |
---
## 8. Criterios de éxito
**Fase 0** — verificable de inmediato:
- El endpoint `/mcp` ya no existe (§0.2). Se cumplió antes con 401 verificado; la retirada lo supera.
- `api/requirements.txt` fija versiones exactas y el API arranca sin el paquete `mcp` (§0.3).
- El total del PDF y el del Excel coinciden dígito por dígito en cotizaciones reales.
- Una cotización con `incluirIva: false` no muestra IVA en ningún exportador.
- Las notas internas no aparecen en ningún archivo exportable.
- El texto del cliente aparece en el PDF, con saltos de línea correctos y sin romper el footer.
**Fase 1** — el criterio es cualitativo y es el que importa:
> El asesor revisa 5-10 documentos generados sobre cotizaciones reales pasadas y dice
> *"esto lo mandaría a un cliente después de editarlo 10 minutos."*
Métricas de apoyo: cero afirmaciones sin evidencia, cero citas no verificables, y tasa de edición
del asesor por debajo del 30%.
Si el criterio no se cumple, la conclusión correcta es iterar el prompt — no construir Fase 2.
---
## 9. Fuera de alcance
Modelos `PropuestaIA` y `ContextoCotizacion`; carga de archivos en la UI; route handler con patrón
de job; vista de revisión con semáforo de salud; dashboard de BI agregado; herramienta MCP
`generar_propuesta_consultiva`; sugerencia de esquema de 2-3 opciones; alerta de subcotización.
También difierido: migrar los cinco consumidores restantes de totales duplicados, y el semáforo de
salud de §5.1 del documento de negocio — es una función de producto, no una validación, y no hace
falta para saber si el texto generado sirve.
+73 -1
View File
@@ -8,6 +8,7 @@
"name": "cotizador-e3", "name": "cotizador-e3",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -22,6 +23,7 @@
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"zod": "^4.3.6",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
@@ -51,6 +53,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -243,6 +266,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2233,6 +2265,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -5179,6 +5217,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -6364,6 +6408,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -8554,6 +8611,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -8890,6 +8957,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -9498,7 +9571,6 @@
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+2
View File
@@ -16,6 +16,7 @@
"seed": "npx tsx prisma/seed.ts" "seed": "npx tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -30,6 +31,7 @@
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"zod": "^4.3.6",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,20 @@
-- Fase 0-C, Despliegue A: separar el texto que ve el cliente del contexto interno.
--
-- ESTA MIGRACION ES PURAMENTE ADITIVA. No modifica ni una sola fila existente.
-- El movimiento de datos (observaciones -> observacionesInternas) seria un
-- Despliegue B posterior y NO se incluye aqui, por dos razones:
--
-- 1. Rollback seguro. No hay migraciones `down` en este repo ni servicio de
-- backup en docker-compose.coolify.yml. Si un UPDATE vaciara "observaciones"
-- y el despliegue se revirtiera, el codigo anterior leeria NULL en todas las
-- filas y el asesor veria sus notas desaparecidas: sin error y sin log.
--
-- 2. El dato real no lo necesita. Al momento de escribir esto la unica fila de
-- produccion con "observaciones" no vacias (UJ2606AG777, aprobada) contiene
-- condiciones de pago dirigidas al cliente en segunda persona. Moverlas a
-- "internas" ocultaria terminos ya acordados en un documento emitido.
-- Esa fila ya esta clasificada correctamente donde esta.
--
-- IF NOT EXISTS hace la sentencia idempotente si alguien la aplica a mano.
ALTER TABLE "Cotizacion" ADD COLUMN IF NOT EXISTS "observacionesInternas" TEXT;
@@ -0,0 +1,26 @@
-- Propuesta consultiva con IA: tabla nueva.
-- ADITIVA. No toca ninguna tabla ni fila existente.
CREATE TABLE IF NOT EXISTS "PropuestaIA" (
"id" TEXT NOT NULL,
"cotizacionId" TEXT NOT NULL,
"estado" TEXT NOT NULL DEFAULT 'borrador',
"contenidoIA" JSONB NOT NULL,
"contenidoEditado" JSONB,
"traza" JSONB,
"avisos" JSONB NOT NULL DEFAULT '[]',
"modelo" TEXT NOT NULL,
"aprobadaPor" TEXT,
"aprobadaAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PropuestaIA_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "PropuestaIA_cotizacionId_idx" ON "PropuestaIA"("cotizacionId");
ALTER TABLE "PropuestaIA"
ADD CONSTRAINT "PropuestaIA_cotizacionId_fkey"
FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- Guardar la transcripcion usada al generar la propuesta.
-- ADITIVA: columna nullable, no toca ninguna fila existente.
ALTER TABLE "PropuestaIA" ADD COLUMN IF NOT EXISTS "transcripcion" TEXT;
+34
View File
@@ -49,7 +49,13 @@ model Cotizacion {
incluirIva Boolean @default(true) incluirIva Boolean @default(true)
esDoble Boolean @default(false) esDoble Boolean @default(false)
opcionesMetadata Json? opcionesMetadata Json?
// Texto que SI ve el cliente: se imprime en el PDF y en el Excel.
observaciones String? observaciones String?
// Contexto de discovery, notas del asesor, riesgos. NO sale de la app: ningun
// exportador declara este campo en su interfaz de datos (CotizacionPDFData /
// ExcelData) y la herramienta MCP obtener_cotizacion usa lista blanca de
// columnas. La garantia es estructural, no depende de recordar filtrarlo.
observacionesInternas String?
clienteId String clienteId String
asesorId String asesorId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -60,6 +66,7 @@ model Cotizacion {
servicios ServicioCotizado[] servicios ServicioCotizado[]
planBucefalo PlanBucefaloCotizacion? planBucefalo PlanBucefaloCotizacion?
registrosHoras RegistroHoras[] registrosHoras RegistroHoras[]
propuestasIA PropuestaIA[]
@@index([clienteId]) @@index([clienteId])
@@index([asesorId]) @@index([asesorId])
@@ -214,6 +221,33 @@ model RegistroHoras {
@@index([estadoPago]) @@index([estadoPago])
} }
// Documento consultivo generado por IA a partir del contexto humano de la cotizacion.
// Se guarda por separado lo que produjo el modelo y lo que edito el asesor, para poder
// mostrar que cambio y para no perder el original al editar.
model PropuestaIA {
id String @id @default(cuid())
cotizacionId String
estado String @default("borrador") // borrador | aprobada
contenidoIA Json
contenidoEditado Json?
// Transcripcion sanitizada usada al generar. Sin esto, el PATCH revalida con
// fuentes incompletas y R3 no puede comprobar que la cita destacada sea literal.
transcripcion String?
// Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache.
traza Json?
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
avisos Json @default("[]")
modelo String
aprobadaPor String?
aprobadaAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
@@index([cotizacionId])
}
model Configuracion { model Configuracion {
id String @id @default(cuid()) id String @id @default(cuid())
clave String @unique clave String @unique
+70
View File
@@ -0,0 +1,70 @@
import { crearCliente, modelo, normalizarRespuesta } from "@/lib/propuesta/cliente-ia";
// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro.
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
const guardada = process.env.MINIMAX_API_KEY;
const guardadaBase = process.env.MINIMAX_BASE_URL;
delete process.env.MINIMAX_API_KEY;
let msg = "";
try {
crearCliente();
} catch (e) {
msg = e instanceof Error ? e.message : String(e);
}
check("sin clave falla en vez de llamar a ciegas", msg.length > 0);
check("el error nombra la variable correcta", msg.includes("MINIMAX_API_KEY"));
check("el error advierte del prefijo ANTHROPIC_", msg.includes("ANTHROPIC_API_KEY"));
process.env.MINIMAX_API_KEY = "clave-de-prueba";
delete process.env.MINIMAX_BASE_URL;
const c = crearCliente();
check("baseURL apunta a MiniMax por defecto", String(c.baseURL).includes("api.minimax.io"), String(c.baseURL));
check("modelo por defecto es MiniMax-M3", modelo() === "MiniMax-M3", modelo());
// Aunque el shell tenga ANTHROPIC_BASE_URL, el cliente no debe hacerle caso.
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
const c2 = crearCliente();
check("ANTHROPIC_BASE_URL del shell no secuestra el baseURL",
!String(c2.baseURL).includes("api.anthropic.com"), String(c2.baseURL));
delete process.env.ANTHROPIC_BASE_URL;
process.env.MINIMAX_BASE_URL = "https://ejemplo.local/anthropic";
check("MINIMAX_BASE_URL si se respeta", String(crearCliente().baseURL).includes("ejemplo.local"));
process.env.MINIMAX_MODEL = "MiniMax-M2";
check("MINIMAX_MODEL se respeta", modelo() === "MiniMax-M2");
delete process.env.MINIMAX_MODEL;
if (guardada) process.env.MINIMAX_API_KEY = guardada;
else delete process.env.MINIMAX_API_KEY;
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
else delete process.env.MINIMAX_BASE_URL;
// ── Normalizacion de rarezas de MiniMax, observadas contra la API real ──
const igual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
check("desenvuelve {item: X} cuando item es la unica clave",
igual(normalizarRespuesta({ item: { a: 1 } }), { a: 1 }));
check("desenvuelve dentro de arrays",
igual(normalizarRespuesta([{ item: { a: 1 } }, { item: { a: 2 } }]), [{ a: 1 }, { a: 2 }]));
check("desenvuelve en profundidad",
igual(normalizarRespuesta({ lista: [{ item: { x: [{ item: 5 }] } }] }), { lista: [{ x: [5] }] }));
check("NO toca un objeto con item junto a otras claves",
igual(normalizarRespuesta({ item: 1, otro: 2 }), { item: 1, otro: 2 }));
check("deja intactos los valores simples",
normalizarRespuesta("texto") === "texto" && normalizarRespuesta(5) === 5 && normalizarRespuesta(null) === null);
check("no altera un objeto normal",
igual(normalizarRespuesta({ a: 1, b: [1, 2] }), { a: 1, b: [1, 2] }));
check("el caso real observado: alcance envuelto",
igual(normalizarRespuesta({ alcance: [{ item: { refPartida: "P01", descripcionResultado: "x" } }] }),
{ alcance: [{ refPartida: "P01", descripcionResultado: "x" }] }));
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+26
View File
@@ -0,0 +1,26 @@
import { calcularRatio } from "@/lib/propuesta/economia";
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
check("sin valor anual no hay ratio", calcularRatio(100000, null) === null);
check("valor anual cero no divide entre cero", calcularRatio(100000, 0) === null);
check("valor anual negativo no produce ratio", calcularRatio(100000, -5) === null);
check("ratio 5% = subcotizado", calcularRatio(50000, 1000000)?.lectura === "subcotizado");
check("ratio 20% = en rango", calcularRatio(200000, 1000000)?.lectura === "en_rango");
check("ratio 35% = alto", calcularRatio(350000, 1000000)?.lectura === "alto");
check("ratio 60% = objecion probable", calcularRatio(600000, 1000000)?.lectura === "objecion_probable");
// Los cortes exactos: 0.10 entra en rango, 0.25 sigue en rango, 0.40 sigue alto.
check("corte 10% cae en rango", calcularRatio(100000, 1000000)?.lectura === "en_rango");
check("corte 25% cae en rango", calcularRatio(250000, 1000000)?.lectura === "en_rango");
check("corte 40% cae en alto", calcularRatio(400000, 1000000)?.lectura === "alto");
check("ratio redondeado a 3 decimales", calcularRatio(123456, 1000000)?.ratio === 0.123);
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+232
View File
@@ -0,0 +1,232 @@
import zlib from "node:zlib";
import { generarPropuestaPDF, generarAnexoInternoPDF, type DatosPropuestaPDF } from "@/lib/propuesta/pdf";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
import type { DatosEconomicos } from "@/lib/propuesta/economia";
/** Extrae el texto de un PDF de PDFKit. Con las fuentes base (Helvetica) no hay
* subsetting ni ToUnicode: el texto va como hex de UN byte por caracter dentro de
* streams comprimidos con Flate. */
function textoDePdf(pdf: Buffer): string {
const crudo = pdf.toString("latin1");
let salida = "";
const re = /stream\r?\n/g;
let m: RegExpExecArray | null;
while ((m = re.exec(crudo)) !== null) {
const ini = m.index + m[0].length;
const fin = crudo.indexOf("endstream", ini);
if (fin === -1) continue;
let bloque = "";
try {
bloque = zlib.inflateSync(Buffer.from(crudo.slice(ini, fin), "latin1")).toString("latin1");
} catch {
continue;
}
// PDFKit alterna dos codificaciones segun el contenido de la cadena:
// hex `<48656c6c6f>` y literal `(Hello) Tj`. Hay que leer las dos.
// OJO: no se puede meter un separador entre trozos hex. PDFKit aplica kerning y
// parte una misma palabra en varios elementos de un arreglo TJ
// (`[<4d41> -20 <524341444f52>]`), asi que un espacio de mas convierte
// "MARCADORVERDE" en "MARCA DORVERDE" y ninguna busqueda lo encuentra.
for (const t of bloque.matchAll(/<([0-9a-fA-F]{2,})>/g)) {
const hex = t[1];
for (let i = 0; i + 2 <= hex.length; i += 2) {
const b = parseInt(hex.slice(i, i + 2), 16);
if (b >= 32 && b < 256) salida += String.fromCharCode(b);
}
}
salida += " ";
for (const t of bloque.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
salida += t[1].replace(/\\([()\\])/g, "$1") + " ";
}
}
return salida;
}
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
const econ: DatosEconomicos = {
partidas: [
{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web institucional", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
{ refPartida: "P02", servicioCotizadoId: "c2", nombre: "Manejo de pauta", fase: 2, tipoPago: "mensual", precio: 8000, tiempoEntrega: "Mensual", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
],
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const propuesta: PropuestaConsultiva = {
hechos: {
citas: [{ id: "C01", textoLiteral: "MARCADORCITA nadie contesta el fin de semana", quienLoDijo: "el socio" }],
hechos: [{ id: "H01", enunciado: "Sin control de atencion", confianza: "confirmado", citas: ["C01"] }],
materialesPendientes: [{ texto: "MARCADORMATERIAL accesos al dominio", bloqueaEntrega: true }],
decisionesPendientes: [{ texto: "MARCADORDECISION definir responsable", quienDecide: "el socio" }],
mencionesFueraDeAlcance: ["MARCADORFUERA app movil"],
redFlags: [{ senal: "MARCADORFLAG pidio descuento antes de ver el alcance", severidad: "alta" }],
},
diagnostico: {
hallazgos: [
{ id: "D01", urgencia: "rojo", titulo: "MARCADORHALLAZGO", descripcion: "Se pierden prospectos cada fin de semana.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
{ id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
],
valorProblema: {
dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
},
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
},
redaccion: {
hero: { titulo: "MARCADORTITULO", subtitulo: "MARCADORSUB ordenar la atencion para dejar de perder prospectos." },
citaDestacadaId: "C01",
alcance: [
{ refPartida: "P01", descripcionResultado: "MARCADORALCANCE un lugar a donde mandar prospectos." },
{ refPartida: "P02", descripcionResultado: "MARCADORALCANCE2 cada peso a las busquedas que compran." },
],
beneficios: [{ etiqueta: "Respuesta", texto: "MARCADORBENEFICIO cada mensaje con responsable.", hallazgoId: "D01" }],
exclusiones: [{ texto: "MARCADOREXCLUSION no incluye migrar el historico.", razon: "fase 2" }],
backlogEvolucion: [{ problema: "MARCADORBACKLOG automatizar reportes", momentoSugerido: "tras la fase 1" }],
notasInternas: ["MARCADORINTERNO el socio decide, no el que vino a la reunion"],
},
};
const datos: DatosPropuestaPDF = {
propuesta,
economia: econ,
cliente: "Cliente Prueba",
empresa: "Empresa SA",
asesor: "Asesor",
numero: "UJ2607TEST",
fecha: new Date("2026-07-29"),
vigencia: new Date("2026-08-19"),
proyecto: "MKT Digital",
branding: { colorPrimario: "#2563eb" },
};
async function main() {
const pdf = await generarPropuestaPDF(datos);
const txt = textoDePdf(pdf);
check("se genera el documento del cliente", pdf.length > 2000, `${pdf.length} bytes`);
for (const marca of [
"MARCADORTITULO", "MARCADORSUB", "MARCADORCITA", "MARCADORHALLAZGO", "MARCADORVERDE",
"MARCADORVALOR", "MARCADORMETODO", "MARCADORRESULTADO", "MARCADORALCANCE", "MARCADORALCANCE2",
"MARCADORBENEFICIO", "MARCADOREXCLUSION", "MARCADORMATERIAL", "MARCADORDECISION", "MARCADORBACKLOG",
]) {
check(`el documento del cliente incluye ${marca}`, txt.includes(marca));
}
// Lo que NUNCA debe salir al cliente.
check("NO lleva notas internas", !txt.includes("MARCADORINTERNO"));
check("NO lleva red flags", !txt.includes("MARCADORFLAG"));
check("NO lleva menciones fuera de alcance", !txt.includes("MARCADORFUERA"));
// El dinero lo pone el codigo, no la IA.
check("imprime el precio de P01", txt.includes("12,000"));
check("imprime el precio de P02", txt.includes("8,000"));
check("imprime el total unico con IVA", txt.includes("13,920"));
check("imprime la mensualidad con IVA", txt.includes("9,280"));
// El desglose debe cuadrar: lineas sin IVA + IVA como renglon propio.
check("imprime el subtotal sin IVA", txt.includes("Subtotal pago unico") && txt.includes("12,000"));
check("imprime el IVA como renglon propio", txt.includes("IVA 16%") && txt.includes("1,920"));
check("imprime el total con IVA", txt.includes("Total pago unico"));
check("la nota NO dice que las lineas incluyan IVA", !txt.includes("Importes con IVA incluido"));
check("la nota aclara que las partidas van sin IVA", txt.includes("no incluyen IVA"));
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
// Sin IVA no debe aparecer el renglon de IVA.
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
const txtSinIva = textoDePdf(sinIva);
check("sin IVA no se imprime el renglon de IVA", !txtSinIva.includes("IVA 16%"));
check("sin IVA el total lo declara", txtSinIva.includes("sin IVA"));
// El plan Bucefalo tiene que aparecer: esta en la cotizacion y en los otros dos exportadores.
const conPlan = await generarPropuestaPDF({ ...datos, economia: { ...econ, planBucefalo: { nivel: "premium", precio: 4500 } } });
const txtPlan = textoDePdf(conPlan);
check("el plan Bucefalo aparece en el documento", txtPlan.includes("Bucefalo"));
check("el plan aparece con su nivel", txtPlan.includes("Premium"));
check("el plan aparece con su precio mensual", txtPlan.includes("4,500"));
// Una partida que la IA invento no se dibuja (R2 ya la marco).
const conFantasma = await generarPropuestaPDF({
...datos,
propuesta: { ...propuesta, redaccion: { ...propuesta.redaccion, alcance: [...propuesta.redaccion.alcance, { refPartida: "P99", descripcionResultado: "MARCADORFANTASMA inventada" }] } },
});
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA"));
// Completitud: la cotizacion manda, no lo que la IA alcanzo a describir.
// Verificado con UJ2606UR001 en produccion: 58 partidas, la IA describio 40.
const econGrande = { ...econ, partidas: [...econ.partidas,
{ refPartida: "P03", servicioCotizadoId: "c3", nombre: "MARCADORNODESCRITA servicio extra", fase: 3, tipoPago: "unico" as const, precio: 5000, tiempoEntrega: "1 semana", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: ["Entregable A", "Entregable B"] },
] };
const conNoDescrita = await generarPropuestaPDF({ ...datos, economia: econGrande });
const txtND = textoDePdf(conNoDescrita);
check("una partida SIN descripcion de la IA igual aparece", txtND.includes("MARCADORNODESCRITA"));
check("y aparece con su precio", txtND.includes("5,000"));
check("y con su detalle del catalogo como respaldo", txtND.includes("Entregable A"));
// ── Anexo interno ──
const anexo = await generarAnexoInternoPDF(datos);
const txtA = textoDePdf(anexo);
check("se genera el anexo interno", anexo.length > 1000, `${anexo.length} bytes`);
check("el anexo se marca NO ENVIAR", txtA.includes("NO ENVIAR"));
check("el anexo lleva las notas internas", txtA.includes("MARCADORINTERNO"));
check("el anexo lleva las red flags", txtA.includes("MARCADORFLAG"));
check("el anexo lleva lo que quedo fuera", txtA.includes("MARCADORFUERA"));
// 125280 / 166400 = 75.3% -> objecion probable
check("el anexo calcula el ratio", txtA.includes("75.3"), "125280/166400");
check("el anexo interpreta el ratio", txtA.includes("objecion probable"));
check("el anexo sugiere responder con alcance", txtA.includes("no con descuento"));
check("el anexo desglosa de donde sale el valor anual", txtA.includes("De donde sale el valor anual"));
check("el anexo muestra los factores con su confianza", txtA.includes("confirmado") && txtA.includes("estimado"));
// R5b: una cifra sostenida solo por estimaciones debe llevar alerta visible.
const soloEstimado = await generarAnexoInternoPDF({
...datos,
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } },
});
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
// Robustez del branding: la API de configuracion no valida los valores.
for (const malo of ["</style><script>", "rojo", "#GGG", ""]) {
const r = await generarPropuestaPDF({ ...datos, branding: { colorPrimario: malo } });
check(`un color invalido (${JSON.stringify(malo)}) no rompe el PDF`, r.length > 2000);
}
const logoMalo = await generarPropuestaPDF({ ...datos, branding: { logoBase64: "no-es-base64-valido" } });
check("un logo invalido no rompe el PDF", logoMalo.length > 2000);
// Contenido largo: no debe perderse ni romper el pie de pagina.
const largo = await generarPropuestaPDF({
...datos,
propuesta: {
...propuesta,
diagnostico: {
...propuesta.diagnostico,
hallazgos: Array.from({ length: 30 }, (_, i) => ({
id: `D${String(i + 1).padStart(2, "0")}`, urgencia: "ambar" as const,
titulo: `Hallazgo ${i} MARCADORLARGO`, descripcion: "x".repeat(300),
confianza: "estimado" as const, citas: [], hechos: [],
})),
},
},
});
const txtL = textoDePdf(largo);
check("el contenido largo se imprime", txtL.includes("MARCADORLARGO"));
check("el contenido largo conserva las secciones posteriores", txtL.includes("MARCADOREXCLUSION"));
check("el contenido largo genera varias paginas", (largo.toString("latin1").match(/\/Type\s*\/Page[^s]/g) || []).length > 1);
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
}
main().catch((e) => {
console.error("Error inesperado:", e);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
import { SYSTEM_BASE, bloqueContexto, mensajePaso1, mensajePaso2, mensajePaso3 } from "@/lib/propuesta/prompts";
import type { DatosEconomicos } from "@/lib/propuesta/economia";
import type { Hechos, Diagnostico } from "@/lib/propuesta/schemas";
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
// ── La capa 1 tiene que ser 100% estable o el cache no sirve de nada ──
check("no contiene el anio actual", !SYSTEM_BASE.includes(String(new Date().getFullYear())));
check("no contiene una fecha ISO", !/\d{4}-\d{2}-\d{2}/.test(SYSTEM_BASE));
check("no contiene un numero de cotizacion", !/UJ\d{4}/.test(SYSTEM_BASE));
check("supera el minimo cacheable de 512 tokens", SYSTEM_BASE.length > 2500, `${SYSTEM_BASE.length} chars`);
// ── Los diez principios y el semaforo estan presentes ──
for (const p of ["P1.", "P2.", "P3.", "P4.", "P5.", "P6.", "P7.", "P8."]) {
check(`declara el principio ${p}`, SYSTEM_BASE.includes(p));
}
check("explica el semaforo completo",
["rojo:", "ambar:", "azul:", "verde:"].every((c) => SYSTEM_BASE.includes(c)));
check("justifica por que el verde importa", SYSTEM_BASE.includes("entre pares"));
// ── Reglas de marca ──
check("nombra Bucefalo", SYSTEM_BASE.includes("Bucefalo"));
check("prohibe otros CRM", SYSTEM_BASE.includes("ningun otro CRM"));
check("prohibe USD", SYSTEM_BASE.includes("USD"));
check("prohibe garantizar resultados", SYSTEM_BASE.includes("garantizado"));
check("prohibe lenguaje corporativo vacio", SYSTEM_BASE.includes("sinergias"));
check("restringe a servicios digitales", SYSTEM_BASE.includes("UNICAMENTE servicios digitales"));
check("prohibe datos de empleados", SYSTEM_BASE.includes("nombres de empleados"));
check("cubre el caso de material pobre", SYSTEM_BASE.includes("MATERIAL ES POBRE"));
check("dice que un hueco declarado es contenido de valor", SYSTEM_BASE.includes("contenido de valor"));
// ── El contexto degrada con gracia ──
const sinNada = bloqueContexto({ transcripcion: "", notas: "", observaciones: "", cliente: "Ana", empresa: "", proyecto: "P" });
check("sin transcripcion lo declara explicitamente", sinNada.includes("No se entrego transcripcion"));
check("sin empresa no imprime parentesis vacios", !sinNada.includes("()"));
const conTodo = bloqueContexto({ transcripcion: "TRANS", notas: "NOTAS", observaciones: "OBS", cliente: "Ana", empresa: "ACME", proyecto: "P" });
check("incluye la transcripcion", conTodo.includes("TRANS"));
check("incluye las notas por partida", conTodo.includes("NOTAS"));
check("incluye las observaciones", conTodo.includes("OBS"));
check("incluye la empresa entre parentesis", conTodo.includes("(ACME)"));
// ── Los tres mensajes piden su herramienta y no filtran dinero ──
const econ: DatosEconomicos = {
partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
const diag = { hallazgos: [], valorProblema: { dimensiones: [], notaMetodologia: "" }, resultados: [] } as unknown as Diagnostico;
const m1 = mensajePaso1(ctx);
const m2 = mensajePaso2(ctx, hechos, econ);
const m3 = mensajePaso3(ctx, hechos, diag, econ);
check("paso 1 pide registrar_hechos", m1.includes("registrar_hechos"));
check("paso 2 pide registrar_diagnostico", m2.includes("registrar_diagnostico"));
check("paso 3 pide registrar_propuesta", m3.includes("registrar_propuesta"));
check("paso 1 exige citas textuales", m1.includes("TEXTUALES"));
// P1 estructural: el precio y el cuid NO pueden aparecer en ningun prompt.
for (const [nombre, msg] of [["paso 2", m2], ["paso 3", m3]] as const) {
check(`${nombre} NO filtra el precio de la partida`, !msg.includes("12345"));
check(`${nombre} NO filtra el total`, !msg.includes("14320"));
check(`${nombre} NO filtra el cuid real`, !msg.includes("cuid-secreto-abc123"));
check(`${nombre} SI incluye la refPartida`, msg.includes("P01"));
}
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+94
View File
@@ -0,0 +1,94 @@
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema } from "@/lib/propuesta/schemas";
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
// ── P1 por construccion: ningun schema declara un campo de dinero de E3 ──
const todos = JSON.stringify([
aJsonSchema(hechosSchema),
aJsonSchema(diagnosticoSchema),
aJsonSchema(redaccionSchema),
]);
for (const prohibido of ["precio", "total", "subtotal", "iva", "montoMinimo", "tarifaHora", "descuento"]) {
check(`ningun schema declara la propiedad "${prohibido}"`, !todos.includes(`"${prohibido}"`));
}
// montoAnualMXN si debe existir: es el costo del problema del cliente, no un precio nuestro.
check("montoAnualMXN si existe (costo del problema del cliente)", todos.includes("montoAnualMXN"));
// ── Formato de las claves ──
const redaccionBase = {
hero: { titulo: "Titulo de prueba", subtitulo: "x".repeat(25) },
citaDestacadaId: "C01",
alcance: [{ refPartida: "P01", descripcionResultado: "x".repeat(25) }],
beneficios: [],
exclusiones: [{ texto: "x".repeat(20), razon: "porque si" }],
backlogEvolucion: [],
notasInternas: [],
};
check("una redaccion bien formada valida", redaccionSchema.safeParse(redaccionBase).success);
check("refPartida en minuscula se rechaza",
!redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "p01", descripcionResultado: "x".repeat(25) }] }).success);
check("refPartida sin ceros se rechaza",
!redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "P1", descripcionResultado: "x".repeat(25) }] }).success);
check("citaDestacadaId con otro prefijo se rechaza",
!redaccionSchema.safeParse({ ...redaccionBase, citaDestacadaId: "H01" }).success);
check("exige al menos una exclusion",
!redaccionSchema.safeParse({ ...redaccionBase, exclusiones: [] }).success);
check("rechaza propiedades desconocidas (strict)",
!redaccionSchema.safeParse({ ...redaccionBase, precioTotal: 99999 }).success);
// ── El calculo del valor debe venir desglosado ──
const diagBase = {
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Titulo del hallazgo", descripcion: "x".repeat(25), confianza: "confirmado", citas: [], hechos: [] }],
valorProblema: {
dimensiones: [{
tipo: "tiempo", descripcion: "x".repeat(12),
factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416,
confianza: "estimado", hechos: [],
}],
notaMetodologia: "n",
},
resultados: [],
};
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
check("con monto, exige al menos 2 factores",
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 }] } }).success);
check("sin monto, factores vacio es lo correcto",
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [], montoAnualMXN: null }] } }).success);
// Una dimension SIN la clave montoAnualMXN: es lo que manda el modelo cuando no tiene
// cifras, y por eso el campo lleva .default(null) en vez de solo .nullable().
const dimSinMonto = { tipo: "tiempo", descripcion: "x".repeat(12), factores: [], confianza: "por_validar", hechos: [] };
const sinMonto = { ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [dimSinMonto] } };
check("omitir montoAnualMXN se tolera", diagnosticoSchema.safeParse(sinMonto).success,
JSON.stringify(diagnosticoSchema.safeParse(sinMonto).error?.issues?.[0] ?? ""));
check("al omitirlo, Zod lo rellena con null",
diagnosticoSchema.parse(sinMonto).valorProblema.dimensiones[0].montoAnualMXN === null);
check("la dimension quedo plana: sin nivel `calculo`",
!JSON.stringify(aJsonSchema(diagnosticoSchema)).includes('"calculo"'));
check("un factor sin confianza se rechaza",
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 }] } }).success);
check("exige al menos un hallazgo",
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
check("urgencia fuera del semaforo se rechaza",
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [{ ...diagBase.hallazgos[0], urgencia: "morado" }] }).success);
// ── Hechos ──
check("una cita corta se rechaza",
!hechosSchema.safeParse({ citas: [{ id: "C01", textoLiteral: "hola", quienLoDijo: "x" }], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success);
check("hechos vacios son validos (material pobre)",
hechosSchema.safeParse({ citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success);
// ── Conversion a JSON Schema ──
const js = aJsonSchema(redaccionSchema);
check("el JSON Schema no lleva $schema", !("$schema" in js));
check("conserva additionalProperties", JSON.stringify(js).includes("additionalProperties"));
check("conserva las descripciones para el modelo", JSON.stringify(js).includes("description"));
check("el JSON Schema es serializable", typeof JSON.stringify(js) === "string" && JSON.stringify(js).length > 200);
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
+123
View File
@@ -0,0 +1,123 @@
import { validarPropuesta, normalizar, hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
import type { DatosEconomicos } from "@/lib/propuesta/economia";
let fallas = 0;
function check(n: string, ok: boolean, d = "") {
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
if (!ok) fallas++;
}
const tiene = (a: Aviso[], regla: string) => a.some((x) => x.regla === regla);
const CITA = "no llevamos control de quien contesta el whatsapp el fin de semana";
const econ: DatosEconomicos = {
partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const base = (): PropuestaConsultiva => ({
hechos: {
citas: [{ id: "C01", textoLiteral: CITA, quienLoDijo: "el socio" }],
hechos: [{ id: "H01", enunciado: "No hay control de la atencion", confianza: "confirmado", citas: ["C01"] }],
materialesPendientes: [{ texto: "Accesos al dominio", bloqueaEntrega: true }],
decisionesPendientes: [{ texto: "Definir responsable de atencion", quienDecide: "el socio" }],
mencionesFueraDeAlcance: [],
redFlags: [],
},
diagnostico: {
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes del fin de semana se pierden.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }],
valorProblema: {
dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
notaMetodologia: "Ocho horas por semana a costo cargado.",
},
resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }],
},
redaccion: {
hero: { titulo: "Propuesta de digitalizacion", subtitulo: "Ordenar la atencion para dejar de perder prospectos." },
citaDestacadaId: "C01",
alcance: [{ refPartida: "P01", descripcionResultado: "Un lugar a donde mandar a los prospectos que llegan de los anuncios." }],
beneficios: [{ etiqueta: "Respuesta", texto: "Cada mensaje queda registrado y con responsable.", hallazgoId: "D01" }],
exclusiones: [{ texto: "No incluye migrar el historico de pedidos de los ultimos 4 anos.", razon: "Se evalua en fase 2." }],
backlogEvolucion: [],
notasInternas: ["El socio es quien decide."],
},
});
const fuentes = { textoCliente: `El socio dijo: ${CITA}. Nada mas.`, textoInterno: "" };
check("normalizar quita acentos y puntuacion", normalizar("¿Como estas, Juan?") === "como estas juan");
check("normalizar colapsa espacios", normalizar(" a b ") === "a b");
const limpia = validarPropuesta(base(), econ, fuentes);
check("una propuesta valida no produce bloqueantes", !hayBloqueantes(limpia), JSON.stringify(limpia.slice(0, 2)));
let p = base(); p.redaccion.alcance[0].refPartida = "P99";
check("R2 detecta una partida inventada", tiene(validarPropuesta(p, econ, fuentes), "R2"));
p = base(); p.redaccion.alcance = [];
check("R2b avisa de una partida cotizada y no descrita", tiene(validarPropuesta(p, econ, fuentes), "R2b"));
p = base(); p.redaccion.citaDestacadaId = "C09";
check("R3 detecta una cita inexistente", tiene(validarPropuesta(p, econ, fuentes), "R3"));
p = base(); p.hechos.citas[0].textoLiteral = "esto nunca lo dijo nadie en la reunion";
check("R3 detecta una cita no verificable", tiene(validarPropuesta(p, econ, fuentes), "R3"));
p = base(); p.hechos.citas[0].textoLiteral = "NO LLEVAMOS CONTROL, de quien contesta... el WhatsApp el fin de semana!";
check("R3 tolera acentos, mayusculas y puntuacion", !tiene(validarPropuesta(p, econ, fuentes), "R3"));
p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = [];
check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4"));
p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = 999999;
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].factores = [];
check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5"));
p = base();
p.diagnostico.valorProblema.dimensiones[0].factores = p.diagnostico.valorProblema.dimensiones[0].factores.map((f) => ({ ...f, confianza: "estimado" as const }));
check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b"));
check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
p = base(); p.redaccion.beneficios[0].hallazgoId = "D99";
check("R6 detecta un beneficio colgante", tiene(validarPropuesta(p, econ, fuentes), "R6"));
p = base(); p.redaccion.beneficios[0].texto = "Lo integramos con HubSpot sin problema alguno.";
check("MARCA detecta otro CRM", tiene(validarPropuesta(p, econ, fuentes), "MARCA"));
p = base(); p.redaccion.hero.subtitulo = "Una inversion de 1,800 USD al mes para tu negocio.";
check("MONEDA detecta USD", tiene(validarPropuesta(p, econ, fuentes), "MONEDA"));
p = base(); p.redaccion.beneficios[0].texto = "Te garantizamos la posicion numero uno en Google.";
check("GARANTIA detecta la promesa", tiene(validarPropuesta(p, econ, fuentes), "GARANTIA"));
p = base(); p.redaccion.hero.subtitulo = "El sueldo de quien atiende sale muy caro hoy en dia.";
check("PII advierte sobre tema salarial", tiene(validarPropuesta(p, econ, fuentes), "PII"));
check("PII es advertencia, no bloqueante",
validarPropuesta(p, econ, fuentes).filter((a) => a.regla === "PII").every((a) => a.severidad === "advertencia"));
// ── R0: la fuga de notas internas, que es la razon de que exista ──
const interno = "el socio pidio descuento antes de entender el alcance del proyecto completo";
p = base(); p.redaccion.hero.subtitulo = `Contexto del proyecto: ${interno}.`;
check("R0 detecta la fuga de notas internas", tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
check("R0 es bloqueante",
validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }).filter((a) => a.regla === "R0").every((a) => a.severidad === "bloqueante"));
check("R0 no dispara si no hay fuga", !tiene(validarPropuesta(base(), econ, { ...fuentes, textoInterno: interno }), "R0"));
check("R0 sin notas internas no hace nada", !tiene(validarPropuesta(base(), econ, fuentes), "R0"));
// La fuga tambien se detecta en un hallazgo, no solo en el hero.
p = base(); p.diagnostico.hallazgos[0].descripcion = `Observado: ${interno}.`;
check("R0 mira todos los campos visibles, no solo el hero",
tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
// Las notasInternas del propio objeto NO deben disparar R0: no se imprimen.
p = base(); p.redaccion.notasInternas = [interno];
check("R0 no se queja de notasInternas (no se imprimen)",
!tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
process.exit(fallas === 0 ? 0 : 1);
@@ -45,6 +45,7 @@ export default async function EditarCotizacionPage({
incluirBonos: cot.incluirBonos, incluirBonos: cot.incluirBonos,
incluirFinanciamiento: cot.incluirFinanciamiento, incluirFinanciamiento: cot.incluirFinanciamiento,
observaciones: cot.observaciones || "", observaciones: cot.observaciones || "",
observacionesInternas: cot.observacionesInternas || "",
planBucefaloNivel: cot.planBucefalo?.nivel || null, planBucefaloNivel: cot.planBucefalo?.nivel || null,
esDoble: cot.esDoble, esDoble: cot.esDoble,
opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {}, opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {},
+23 -1
View File
@@ -17,6 +17,7 @@ import { DeleteCotizacionButton } from "./DeleteButton";
import { CambiarEstadoButtons } from "./CambiarEstadoButtons"; import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
import { PreciosEditables } from "./PreciosEditables"; import { PreciosEditables } from "./PreciosEditables";
import { RegistroHorasPanel } from "./RegistroHorasPanel"; import { RegistroHorasPanel } from "./RegistroHorasPanel";
import PropuestaIAPanel from "@/components/PropuestaIAPanel";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -226,10 +227,31 @@ export default async function CotizacionDetailPage({
{cot.observaciones && ( {cot.observaciones && (
<div className="bg-card-bg rounded-xl border border-border p-5"> <div className="bg-card-bg rounded-xl border border-border p-5">
<h3 className="font-semibold mb-2">Observaciones</h3> <h3 className="font-semibold mb-2 flex items-center gap-2">
Observaciones
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
Las ve el cliente
</span>
</h3>
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p> <p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
</div> </div>
)} )}
<PropuestaIAPanel cotizacionId={cot.id} numero={cot.numero} />
{/* Unico lugar donde se muestran las notas internas. No van a ningun
exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */}
{cot.observacionesInternas && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-5">
<h3 className="font-semibold mb-2 flex items-center gap-2 text-amber-900">
Notas internas
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
No sale de la app
</span>
</h3>
<p className="text-sm text-amber-900/80 whitespace-pre-wrap">{cot.observacionesInternas}</p>
</div>
)}
</div> </div>
); );
} }
+2
View File
@@ -100,6 +100,7 @@ export async function PUT(
esDoble, esDoble,
opciones, opciones,
observaciones, observaciones,
observacionesInternas,
cliente, cliente,
servicios, servicios,
planBucefalo, planBucefalo,
@@ -158,6 +159,7 @@ export async function PUT(
...(esDoble !== undefined && { esDoble }), ...(esDoble !== undefined && { esDoble }),
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }), ...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
...(observaciones !== undefined && { observaciones }), ...(observaciones !== undefined && { observaciones }),
...(observacionesInternas !== undefined && { observacionesInternas }),
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }), ...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
}, },
}); });
+2
View File
@@ -26,6 +26,7 @@ export async function POST(request: NextRequest) {
esDoble, esDoble,
opciones, opciones,
observaciones, observaciones,
observacionesInternas,
cliente, cliente,
asesorId, asesorId,
servicios, servicios,
@@ -81,6 +82,7 @@ export async function POST(request: NextRequest) {
esDoble: esDoble ?? false, esDoble: esDoble ?? false,
opcionesMetadata: esDoble ? opciones ?? {} : undefined, opcionesMetadata: esDoble ? opciones ?? {} : undefined,
observaciones: observaciones || null, observaciones: observaciones || null,
observacionesInternas: observacionesInternas || null,
clienteId: clienteIdFinal, clienteId: clienteIdFinal,
asesorId, asesorId,
estado: "borrador", estado: "borrador",
+9 -1
View File
@@ -17,7 +17,12 @@ export async function GET(
include: { include: {
cliente: true, cliente: true,
asesor: true, asesor: true,
servicios: { include: { servicioCatalogo: true } }, // Mismo orderBy que el PDF: los dos documentos van en el mismo correo y
// deben listar las partidas en el mismo orden.
servicios: {
include: { servicioCatalogo: true },
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
},
planBucefalo: true, planBucefalo: true,
}, },
}), }),
@@ -67,6 +72,9 @@ export async function GET(
planBucefaloPrecio: cot.planBucefalo?.precio ?? null, planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
colorPrimario: branding.colorPrimario || "#2563eb", colorPrimario: branding.colorPrimario || "#2563eb",
colorSecundario: branding.colorSecundario || "#1e293b", colorSecundario: branding.colorSecundario || "#1e293b",
incluirIva: cot.incluirIva,
// El Excel es un documento del cliente: solo el texto del cliente.
observaciones: cot.observaciones,
}; };
const buffer = await buildCotizacionExcel(data); const buffer = await buildCotizacionExcel(data);
+1
View File
@@ -80,6 +80,7 @@ export async function POST(request: NextRequest) {
planBucefaloNivel: draft.planBucefaloNivel, planBucefaloNivel: draft.planBucefaloNivel,
colorPrimario: branding.colorPrimario || "#2563eb", colorPrimario: branding.colorPrimario || "#2563eb",
colorSecundario: branding.colorSecundario || "#1e293b", colorSecundario: branding.colorSecundario || "#1e293b",
observaciones: draft.observaciones,
}; };
const buffer = await buildCotizacionExcel(data); const buffer = await buildCotizacionExcel(data);
+13 -2
View File
@@ -10,18 +10,25 @@ export async function GET(
) { ) {
try { try {
const { id } = await params; const { id } = await params;
const [cot, config, branding] = await Promise.all([ const [cot, config, branding, bonos] = await Promise.all([
prisma.cotizacion.findUnique({ prisma.cotizacion.findUnique({
where: { id }, where: { id },
include: { include: {
cliente: true, cliente: true,
asesor: true, asesor: true,
servicios: { include: { servicioCatalogo: true } }, // orderBy explicito: drawSection abre un encabezado de fase nuevo cada vez
// que cambia serv.fase, o sea que asume el array agrupado. Sin esto el orden
// lo decide Postgres y el PDF puede salir distinto del Excel del mismo envio.
servicios: {
include: { servicioCatalogo: true },
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
},
planBucefalo: true, planBucefalo: true,
}, },
}), }),
getConfigBancaria(), getConfigBancaria(),
getConfigBranding(), getConfigBranding(),
prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }),
]); ]);
if (!cot) { if (!cot) {
@@ -61,6 +68,10 @@ export async function GET(
planBucefaloNivel: cot.planBucefalo?.nivel || null, planBucefaloNivel: cot.planBucefalo?.nivel || null,
planBucefaloPrecio: cot.planBucefalo?.precio || 0, planBucefaloPrecio: cot.planBucefalo?.precio || 0,
incluirBonos: cot.incluirBonos, incluirBonos: cot.incluirBonos,
bonos,
incluirIva: cot.incluirIva,
// Solo el texto del cliente. observacionesInternas no se pasa nunca.
observaciones: cot.observaciones,
configBancaria: config, configBancaria: config,
...branding, ...branding,
}); });
+14 -2
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { generateCotizacionPDF } from "@/lib/pdf-generator"; import { generateCotizacionPDF } from "@/lib/pdf-generator";
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators"; import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
import { getConfigBranding } from "@/lib/config-helpers"; import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -42,7 +43,14 @@ export async function POST(request: NextRequest) {
const fechaCot = new Date(draft.fecha); const fechaCot = new Date(draft.fecha);
const vigencia = calcularVigencia(fechaCot); const vigencia = calcularVigencia(fechaCot);
const branding = await getConfigBranding(); // El borrador leia solo el branding, asi que imprimia los datos bancarios
// hardcodeados del generador en vez de los configurados. El Excel de borrador
// si leia ambos: esto empareja los dos.
const [branding, configBancaria, bonos] = await Promise.all([
getConfigBranding(),
getConfigBancaria(),
prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }),
]);
const empresa = draft.clienteEmpresa || draft.clienteNombre; const empresa = draft.clienteEmpresa || draft.clienteNombre;
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`; const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`;
@@ -63,6 +71,10 @@ export async function POST(request: NextRequest) {
planBucefaloNivel: draft.planBucefaloNivel, planBucefaloNivel: draft.planBucefaloNivel,
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0, planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
incluirBonos: draft.incluirBonos, incluirBonos: draft.incluirBonos,
bonos,
// El cliente ya mandaba observaciones en el body; el generador nunca las recibia.
observaciones: draft.observaciones,
configBancaria,
...branding, ...branding,
}); });
@@ -0,0 +1,78 @@
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 { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente
* GET /api/propuesta-ia/:id/pdf?anexo=1 -> anexo interno (archivo aparte, a proposito) */
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const anexo = request.nextUrl.searchParams.get("anexo") === "1";
const [cot, fila, branding] = await Promise.all([
prisma.cotizacion.findUnique({ where: { id }, include: { cliente: true, asesor: true } }),
prisma.propuestaIA.findFirst({ where: { cotizacionId: id }, orderBy: { createdAt: "desc" } }),
getConfigBranding(),
]);
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
// Los avisos bloqueantes tienen que bloquear DE VERDAD el documento del cliente.
// Antes esta ruta ni siquiera leia `avisos`: la severidad "bloqueante" era
// decorativa y el PDF se descargaba igual con una fuga de notas internas o un
// monto en USD dentro. El anexo interno SI se permite: es justamente el que el
// asesor necesita para entender que hay que corregir.
const avisos = Array.isArray(fila.avisos) ? (fila.avisos as unknown as Aviso[]) : [];
if (!anexo && hayBloqueantes(avisos)) {
const cuales = avisos
.filter((a) => a.severidad === "bloqueante")
.map((a) => `${a.regla}${a.ruta ? ` (${a.ruta})` : ""}: ${a.mensaje}`);
return NextResponse.json(
{
error:
"La propuesta tiene avisos bloqueantes sin resolver. Corrigelos en el panel y guarda antes de descargar el documento del cliente.",
bloqueantes: cuales,
},
{ status: 409 }
);
}
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
const economia = await cargarEconomia(id);
const datos = {
propuesta,
economia,
cliente: cot.cliente.nombre,
empresa: cot.cliente.empresa || "",
asesor: cot.asesor.name,
numero: cot.numero,
fecha: cot.fecha,
vigencia: cot.vigencia,
proyecto: cot.proyecto,
branding,
};
const buffer = anexo ? await generarAnexoInternoPDF(datos) : await generarPropuestaPDF(datos);
const empresa = cot.cliente.empresa || cot.cliente.nombre;
const nombre = anexo
? `${sanitizeFilename(empresa)} - ${cot.numero} - INTERNO NO ENVIAR`
: `${sanitizeFilename(empresa)} - ${cot.numero} - Propuesta consultiva`;
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+154
View File
@@ -0,0 +1,154 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuesta } from "@/lib/propuesta/pipeline";
import { validarPropuesta } from "@/lib/propuesta/validacion";
import { propuestaCompletaSchema, type PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** Reune el contexto humano de la cotizacion. Distingue lo que ve el cliente de lo
* interno: el interno se manda al modelo (necesita el contexto) pero R0 comprueba
* despues que no se haya colado a la salida. */
async function contextoDe(cotizacionId: string) {
const cot = await prisma.cotizacion.findUnique({
where: { id: cotizacionId },
include: { cliente: true, asesor: true, servicios: true },
});
if (!cot) return null;
const notasPartidas = cot.servicios
.map((s) => s.notas)
.filter((n): n is string => Boolean(n && n.trim()))
.join("\n");
return { cot, notasPartidas };
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const transcripcion: string = typeof body.transcripcion === "string" ? body.transcripcion : "";
const ctx = await contextoDe(id);
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
const { cot, notasPartidas } = ctx;
// La doble propuesta todavia no esta soportada en el documento consultivo: su
// estructura asume UNA lista de alcance y UNA caja de totales, asi que sumaria las
// dos opciones —que son alternativas excluyentes— y mostraria un total inflado.
// Mejor no generar nada que generar un documento con un total que no existe.
if (cot.esDoble) {
return NextResponse.json(
{
error:
"Esta cotizacion es de doble propuesta y el documento consultivo todavia no las soporta: sumaria las dos opciones como si fueran una sola. Genera la propuesta desde una cotizacion de opcion unica.",
},
{ status: 400 }
);
}
const economia = await cargarEconomia(id);
if (!economia.partidas.length) {
return NextResponse.json(
{ error: "La cotizacion no tiene partidas seleccionadas. Agrega servicios antes de generar la propuesta." },
{ status: 400 }
);
}
const { propuesta, traza } = await generarPropuesta({
entrada: {
transcripcion,
notas: notasPartidas,
observaciones: cot.observaciones || "",
cliente: cot.cliente.nombre,
empresa: cot.cliente.empresa || "",
proyecto: cot.proyecto,
},
economia,
});
const avisos = validarPropuesta(propuesta, economia, {
textoCliente: [transcripcion, cot.observaciones || "", notasPartidas].join("\n"),
textoInterno: cot.observacionesInternas || "",
});
const fila = await prisma.propuestaIA.create({
data: {
cotizacionId: id,
contenidoIA: propuesta as unknown as object,
traza: traza as unknown as object,
avisos: avisos as unknown as object,
modelo: traza.modelo,
transcripcion: transcripcion || null,
},
});
return NextResponse.json({ id: fila.id, propuesta, avisos, traza });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const fila = await prisma.propuestaIA.findFirst({
where: { cotizacionId: id },
orderBy: { createdAt: "desc" },
});
if (!fila) return NextResponse.json({ propuesta: null });
return NextResponse.json({
id: fila.id,
propuesta: fila.contenidoEditado ?? fila.contenidoIA,
editada: fila.contenidoEditado !== null,
avisos: fila.avisos,
traza: fila.traza,
createdAt: fila.createdAt,
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const parsed = propuestaCompletaSchema.safeParse(body.propuesta);
if (!parsed.success) {
return NextResponse.json({ error: z.prettifyError(parsed.error) }, { status: 400 });
}
const fila = await prisma.propuestaIA.findFirst({
where: { cotizacionId: id },
orderBy: { createdAt: "desc" },
});
if (!fila) return NextResponse.json({ error: "No hay propuesta generada para esta cotizacion" }, { status: 404 });
// La edicion del asesor se revalida: pudo introducir una fuga, un USD o una
// promesa de resultado sin darse cuenta.
//
// Se incluye la transcripcion GUARDADA con la propuesta. Antes solo vivia en
// memoria durante la peticion de generacion, asi que al editar R3 se quedaba sin
// fuente contra la cual comprobar la cita literal y el aviso desaparecia solo.
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
textoCliente: [fila.transcripcion || "", 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 });
}
}
+50 -13
View File
@@ -17,6 +17,8 @@ import {
Clock, Clock,
Plus, Plus,
Trash2, Trash2,
Eye,
Lock,
} from "lucide-react"; } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { import {
@@ -82,6 +84,7 @@ export interface ExistingData {
incluirBonos: boolean; incluirBonos: boolean;
incluirFinanciamiento: boolean; incluirFinanciamiento: boolean;
observaciones: string; observaciones: string;
observacionesInternas?: string;
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
estado: string; estado: string;
servicios: ServicioSeleccionado[]; servicios: ServicioSeleccionado[];
@@ -165,6 +168,7 @@ export function CotizacionForm({
store.setField("incluirBonos", existingData.incluirBonos); store.setField("incluirBonos", existingData.incluirBonos);
store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento); store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento);
store.setField("observaciones", existingData.observaciones); store.setField("observaciones", existingData.observaciones);
store.setField("observacionesInternas", existingData.observacionesInternas ?? "");
store.setField("planBucefaloNivel", existingData.planBucefaloNivel); store.setField("planBucefaloNivel", existingData.planBucefaloNivel);
store.setField("esDoble", existingData.esDoble ?? false); store.setField("esDoble", existingData.esDoble ?? false);
store.setField("opciones", existingData.opciones ?? {}); store.setField("opciones", existingData.opciones ?? {});
@@ -333,6 +337,7 @@ export function CotizacionForm({
esDoble: store.draft.esDoble, esDoble: store.draft.esDoble,
opciones: store.draft.esDoble ? store.draft.opciones : undefined, opciones: store.draft.esDoble ? store.draft.opciones : undefined,
observaciones: store.draft.observaciones, observaciones: store.draft.observaciones,
observacionesInternas: store.draft.observacionesInternas,
cliente: { cliente: {
nombre: store.draft.clienteNombre, nombre: store.draft.clienteNombre,
empresa: store.draft.clienteEmpresa, empresa: store.draft.clienteEmpresa,
@@ -1217,19 +1222,51 @@ export function CotizacionForm({
</div> </div>
</div> </div>
<div className="bg-card-bg rounded-xl border border-border p-5"> {/* Dos campos deliberadamente distintos. El de arriba se imprime en el PDF y
<label className="block text-sm font-medium text-muted mb-1"> el Excel que recibe el cliente; el de abajo no sale de la aplicacion.
Observaciones La diferencia visual es la barrera contra escribir en el equivocado. */}
</label> <div className="bg-card-bg rounded-xl border border-border p-5 space-y-5">
<textarea <div>
value={store.draft.observaciones} <label className="flex items-center gap-2 text-sm font-medium mb-1">
onChange={(e) => <Eye className="w-4 h-4 text-primary" />
store.setField("observaciones", e.target.value) Observaciones
} <span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
rows={3} Las ve el cliente
placeholder="Notas adicionales para la cotizacion..." </span>
className={INPUT_CLS} </label>
/> <p className="text-xs text-muted mb-2">
Se imprime en el PDF y en el Excel que le envias. Condiciones, supuestos y
aclaraciones del acuerdo.
</p>
<textarea
value={store.draft.observaciones}
onChange={(e) => store.setField("observaciones", e.target.value)}
rows={3}
placeholder="Ej: El anticipo es del 50%. El avance queda condicionado a la entrega de accesos."
className={INPUT_CLS}
/>
</div>
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
<label className="flex items-center gap-2 text-sm font-medium mb-1 text-amber-900">
<Lock className="w-4 h-4" />
Notas internas
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
No sale de la app
</span>
</label>
<p className="text-xs text-amber-800 mb-2">
Contexto de la reunion, con quien hablar, riesgos, recordatorios. No se imprime
en ningun documento ni se expone por el API.
</p>
<textarea
value={store.draft.observacionesInternas}
onChange={(e) => store.setField("observacionesInternas", e.target.value)}
rows={3}
placeholder="Ej: El que decide es el socio, no el que vino a la reunion. Pidio descuento antes de ver el alcance."
className={INPUT_CLS}
/>
</div>
</div> </div>
</div> </div>
+590
View File
@@ -0,0 +1,590 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Sparkles, Download, Save, AlertTriangle, Info, Lock, RefreshCw, ChevronDown, ChevronRight } from "lucide-react";
import { useToast, useConfirm } from "@/components/ui/DialogProvider";
// Panel de la propuesta consultiva: generar, revisar, editar y descargar.
//
// Regla de diseno deliberada: los avisos van ARRIBA de los botones de descarga.
// El objetivo es que revisar sea mas facil que aprobar, no al reves.
type Confianza = "confirmado" | "estimado" | "por_validar";
type Urgencia = "rojo" | "ambar" | "azul" | "verde";
interface Aviso {
regla: string;
severidad: "bloqueante" | "advertencia";
mensaje: string;
ruta?: string;
}
interface Propuesta {
hechos: {
citas: { id: string; textoLiteral: string; quienLoDijo: string }[];
hechos: { id: string; enunciado: string; confianza: Confianza; citas: string[] }[];
materialesPendientes: { texto: string; bloqueaEntrega: boolean }[];
decisionesPendientes: { texto: string; quienDecide: string }[];
mencionesFueraDeAlcance: string[];
redFlags: { senal: string; severidad: string }[];
};
diagnostico: {
hallazgos: { id: string; urgencia: Urgencia; titulo: string; descripcion: string; confianza: Confianza; citas: string[]; hechos: string[] }[];
valorProblema: {
dimensiones: { tipo: string; descripcion: string; calculo: { factores: { nombre: string; valor: number }[]; montoAnualMXN: number | null }; confianza: Confianza; hechos: string[] }[];
notaMetodologia: string;
};
resultados: { enunciado: string; metrica: string; lineaBase: string | null; periodoMedicion: string }[];
};
redaccion: {
hero: { titulo: string; subtitulo: string };
citaDestacadaId: string;
alcance: { refPartida: string; descripcionResultado: string }[];
beneficios: { etiqueta: string; texto: string; hallazgoId: string }[];
exclusiones: { texto: string; razon: string }[];
backlogEvolucion: { problema: string; momentoSugerido: string }[];
notasInternas: string[];
};
}
const COLOR_URGENCIA: Record<Urgencia, string> = {
rojo: "bg-red-500",
ambar: "bg-amber-500",
azul: "bg-blue-500",
verde: "bg-green-500",
};
const ETIQUETA_URGENCIA: Record<Urgencia, string> = {
rojo: "Problema critico",
ambar: "Area de mejora",
azul: "Oportunidad",
verde: "Ventaja existente",
};
const INPUT = "w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary";
export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: string; numero?: string }) {
const toast = useToast();
const confirm = useConfirm();
const [abierto, setAbierto] = useState(false);
const [cargando, setCargando] = useState(true);
const [generando, setGenerando] = useState(false);
const [guardando, setGuardando] = useState(false);
const [transcripcion, setTranscripcion] = useState("");
const [propuesta, setPropuesta] = useState<Propuesta | null>(null);
const [avisos, setAvisos] = useState<Aviso[]>([]);
const [editada, setEditada] = useState(false);
const [sucio, setSucio] = useState(false);
const cargar = useCallback(async () => {
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`);
const d = await r.json();
if (d.propuesta) {
setPropuesta(d.propuesta);
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(Boolean(d.editada));
setAbierto(true);
}
} finally {
setCargando(false);
}
}, [cotizacionId]);
useEffect(() => {
void cargar();
}, [cargar]);
async function generar() {
if (propuesta) {
const ok = await confirm({
title: "Regenerar la propuesta",
message: "Se generara una propuesta nueva. La version actual y tus ediciones quedaran reemplazadas.",
confirmText: "Regenerar",
danger: true,
});
if (!ok) return;
}
setGenerando(true);
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ transcripcion }),
});
const d = await r.json();
if (!r.ok) {
toast(d.error || "No se pudo generar la propuesta", "error");
return;
}
setPropuesta(d.propuesta);
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(false);
setSucio(false);
setAbierto(true);
const bloqueantes = (d.avisos as Aviso[]).filter((a) => a.severidad === "bloqueante").length;
toast(
bloqueantes > 0
? `Propuesta generada con ${bloqueantes} aviso(s) que debes resolver antes de enviarla.`
: "Propuesta generada. Revisala antes de enviarla.",
bloqueantes > 0 ? "error" : "success"
);
} catch (e) {
toast(e instanceof Error ? e.message : "Error al generar", "error");
} finally {
setGenerando(false);
}
}
async function guardar() {
if (!propuesta) return;
setGuardando(true);
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ propuesta }),
});
const d = await r.json();
if (!r.ok) {
toast(typeof d.error === "string" ? d.error : "No se pudo guardar", "error");
return;
}
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(true);
setSucio(false);
toast("Cambios guardados y revalidados.", "success");
} finally {
setGuardando(false);
}
}
/** Aplica un cambio al objeto y marca el panel como sucio. */
function editar(fn: (p: Propuesta) => void) {
setPropuesta((prev) => {
if (!prev) return prev;
const copia = structuredClone(prev) as Propuesta;
fn(copia);
return copia;
});
setSucio(true);
}
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
const advertencias = avisos.filter((a) => a.severidad === "advertencia");
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
if (cargando) {
return (
<div className="bg-card-bg rounded-xl border border-border p-5">
<p className="text-sm text-muted">Cargando propuesta consultiva...</p>
</div>
);
}
return (
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
<button
onClick={() => setAbierto((v) => !v)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-gray-50"
>
<span className="flex items-center gap-2 font-semibold">
<Sparkles className="w-4 h-4 text-primary" />
Propuesta consultiva con IA
{editada && (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary-light text-primary">
editada por ti
</span>
)}
</span>
{abierto ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
{abierto && (
<div className="px-5 pb-5 space-y-5 border-t border-border pt-5">
{/* ── Entrada ── */}
<div>
<label className="block text-sm font-medium mb-1">Transcripcion de la reunion (opcional)</label>
<p className="text-xs text-muted mb-2">
Pega aqui lo que se dijo en la reunion de discovery. Sin transcripcion, la IA trabaja
solo con las observaciones y notas de la cotizacion, y marcara mas cosas por validar.
</p>
<textarea
value={transcripcion}
onChange={(e) => setTranscripcion(e.target.value)}
rows={4}
placeholder="Pega la transcripcion..."
className={INPUT}
/>
<button
onClick={generar}
disabled={generando}
className="mt-3 flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-50"
>
{generando ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{generando ? "Generando (tarda un par de minutos)..." : propuesta ? "Regenerar" : "Generar propuesta"}
</button>
</div>
{propuesta && (
<>
{/* ── Avisos: van ARRIBA de las descargas a proposito ── */}
{bloqueantes.length > 0 && (
<div className="rounded-lg border border-red-300 bg-red-50 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-red-800 mb-2">
<AlertTriangle className="w-4 h-4" />
{bloqueantes.length} aviso(s) que debes resolver antes de enviar
</p>
<ul className="space-y-1">
{bloqueantes.map((a, i) => (
<li key={i} className="text-xs text-red-800">
<span className="font-mono font-semibold">{a.regla}</span>
{a.ruta && <span className="text-red-600"> · {a.ruta}</span>} {a.mensaje}
</li>
))}
</ul>
</div>
)}
{advertencias.length > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-amber-900 mb-2">
<Info className="w-4 h-4" />
{advertencias.length} cosa(s) que conviene revisar
</p>
<ul className="space-y-1">
{advertencias.map((a, i) => (
<li key={i} className="text-xs text-amber-900">
<span className="font-mono font-semibold">{a.regla}</span>
{a.ruta && <span className="opacity-70"> · {a.ruta}</span>} {a.mensaje}
</li>
))}
</ul>
</div>
)}
{avisos.length === 0 && (
<div className="rounded-lg border border-green-300 bg-green-50 p-3">
<p className="text-sm text-green-800">Sin avisos de validacion. Revisa el contenido de todas formas.</p>
</div>
)}
{/* ── Edicion ── */}
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Titulo</label>
<input
value={propuesta.redaccion.hero.titulo}
onChange={(e) => editar((p) => { p.redaccion.hero.titulo = e.target.value; })}
className={INPUT}
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Subtitulo</label>
<textarea
value={propuesta.redaccion.hero.subtitulo}
onChange={(e) => editar((p) => { p.redaccion.hero.subtitulo = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Hallazgos del diagnostico
</label>
<div className="space-y-3">
{propuesta.diagnostico.hallazgos.map((h, i) => (
<div key={h.id} className="border border-border rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`w-2.5 h-2.5 rounded-full ${COLOR_URGENCIA[h.urgencia]}`} />
<span className="text-[11px] text-muted">
{ETIQUETA_URGENCIA[h.urgencia]} · {h.confianza.replace("_", " ")}
</span>
</div>
<input
value={h.titulo}
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].titulo = e.target.value; })}
className={`${INPUT} mb-2 font-medium`}
/>
<textarea
value={h.descripcion}
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].descripcion = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Alcance (la descripcion; los precios los pone el sistema)
</label>
<div className="space-y-2">
{propuesta.redaccion.alcance.map((a, i) => (
<div key={a.refPartida} className="flex gap-2 items-start">
<span className="text-xs font-mono text-muted mt-2.5 shrink-0">{a.refPartida}</span>
<textarea
value={a.descripcionResultado}
onChange={(e) => editar((p) => { p.redaccion.alcance[i].descripcionResultado = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">Exclusiones</label>
<div className="space-y-2">
{propuesta.redaccion.exclusiones.map((ex, i) => (
<textarea
key={i}
value={ex.texto}
onChange={(e) => editar((p) => { p.redaccion.exclusiones[i].texto = e.target.value; })}
rows={2}
className={INPUT}
/>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Por que tiene sentido
</label>
<div className="space-y-2">
{propuesta.redaccion.beneficios.map((b, i) => (
<div key={i} className="flex gap-2 items-start">
<input
value={b.etiqueta}
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].etiqueta = e.target.value; })}
className={`${INPUT} max-w-[9rem] shrink-0`}
/>
<textarea
value={b.texto}
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].texto = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
{/* Los grupos de abajo tambien se imprimen al cliente, asi que tambien
pueden disparar un aviso bloqueante. Si no fueran editables, un
bloqueante ahi no tendria mas salida que regenerar la propuesta. */}
{citaDestacada && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">
Cita destacada
</label>
<p className="text-xs text-muted mb-2">
Se imprime literal. Editala solo para quitar un dato sensible, nunca para
mejorarle la redaccion al cliente.
</p>
<textarea
value={citaDestacada.textoLiteral}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.textoLiteral = e.target.value;
})
}
rows={2}
className={`${INPUT} mb-2`}
/>
<input
value={citaDestacada.quienLoDijo}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.quienLoDijo = e.target.value;
})
}
placeholder="Quien lo dijo (usa el rol, no el nombre)"
className={INPUT}
/>
</div>
)}
{propuesta.diagnostico.valorProblema.dimensiones.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Lo que cuesta no resolverlo
</label>
<div className="space-y-2">
{propuesta.diagnostico.valorProblema.dimensiones.map((dim, i) => (
<div key={i} className="flex gap-2 items-start">
<span className="text-xs text-muted mt-2.5 shrink-0 w-20">{dim.tipo}</span>
<textarea
value={dim.descripcion}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.dimensiones[i].descripcion = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
<textarea
value={propuesta.diagnostico.valorProblema.notaMetodologia}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.notaMetodologia = e.target.value; })}
rows={2}
placeholder="Nota de metodologia"
className={`${INPUT} mt-2`}
/>
</div>
)}
{propuesta.diagnostico.resultados.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Resultados a lograr
</label>
<div className="space-y-2">
{propuesta.diagnostico.resultados.map((r, i) => (
<div key={i} className="space-y-1">
<textarea
value={r.enunciado}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].enunciado = e.target.value; })}
rows={2}
className={INPUT}
/>
<div className="flex gap-2">
<input
value={r.metrica}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].metrica = e.target.value; })}
placeholder="Metrica"
className={INPUT}
/>
<input
value={r.periodoMedicion}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].periodoMedicion = e.target.value; })}
placeholder="Periodo"
className={INPUT}
/>
</div>
</div>
))}
</div>
</div>
)}
{(propuesta.hechos.materialesPendientes.length > 0 ||
propuesta.hechos.decisionesPendientes.length > 0) && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Que necesitamos de ustedes
</label>
<div className="space-y-2">
{propuesta.hechos.materialesPendientes.map((m, i) => (
<input
key={`mat-${i}`}
value={m.texto}
onChange={(e) => editar((p) => { p.hechos.materialesPendientes[i].texto = e.target.value; })}
className={INPUT}
/>
))}
{propuesta.hechos.decisionesPendientes.map((dd, i) => (
<div key={`dec-${i}`} className="flex gap-2">
<textarea
value={dd.texto}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].texto = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={dd.quienDecide}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].quienDecide = e.target.value; })}
placeholder="Quien decide"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</div>
))}
</div>
</div>
)}
{propuesta.redaccion.backlogEvolucion.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Registrado para mas adelante
</label>
<div className="space-y-2">
{propuesta.redaccion.backlogEvolucion.map((b, i) => (
<div key={i} className="flex gap-2">
<textarea
value={b.problema}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].problema = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={b.momentoSugerido}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].momentoSugerido = e.target.value; })}
placeholder="Cuando"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<button
onClick={guardar}
disabled={!sucio || guardando}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-40"
>
<Save className="w-4 h-4" />
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
</button>
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
invitar a un clic que va a fallar. La descarga del anexo interno
sigue disponible: es la que ayuda a entender que corregir. */}
{bloqueantes.length > 0 ? (
<span
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm text-muted bg-gray-50 cursor-not-allowed"
title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF) bloqueada
</span>
) : (
<a
href={`/api/propuesta-ia/${cotizacionId}/pdf`}
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF)
</a>
)}
<a
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-red-300 bg-red-50 text-red-800 hover:bg-red-100"
title="Contiene ponderacion, red flags y notas internas"
>
<Lock className="w-4 h-4" />
Anexo interno NO ENVIAR
</a>
{sucio && (
<span className="text-xs text-amber-700">
Tienes cambios sin guardar. El PDF se genera con lo ultimo guardado.
</span>
)}
</div>
</>
)}
</div>
)}
</div>
);
}
+63
View File
@@ -112,6 +112,69 @@ export function calcularTotalesOpcion(
}; };
} }
// ----- Totales de una cotizacion: FUENTE DE VERDAD UNICA -----
// Antes este calculo estaba duplicado a mano con .filter().reduce() en siete
// consumidores (PDF, Excel, detalle, PreciosEditables, formulario, lista y dashboard),
// con criterios que no coincidian, y el IVA estaba hardcodeado como * 1.16 en los dos
// exportadores ignorando IVA_RATE y el flag incluirIva. Toda comparacion de totales
// (y cualquier documento que deba coincidir con el PDF) debe pasar por aqui.
//
// Nota: los subtotales son SIN IVA, que es como los exportadores presentan hoy los
// totales de una cotizacion simple ("los precios no incluyen IVA" en la nota al pie).
export interface TotalesCotizacion {
subtotalUnico: number;
subtotalMensual: number;
ivaUnico: number;
ivaMensual: number;
totalUnico: number;
totalMensual: number;
/** Desembolso real a 12 meses: unico + mensual x 12, con IVA si aplica.
* Es la base del ratio precio/valor de la propuesta consultiva. */
totalPrimerAnio: number;
incluyeIva: boolean;
}
export function calcularTotalesCotizacion(
servicios: Array<{ tipoPago: string; precio: number; seleccionado?: boolean }>,
opciones?: { incluirIva?: boolean }
): TotalesCotizacion {
const incluyeIva = opciones?.incluirIva !== false;
const activos = servicios.filter((s) => s.seleccionado !== false);
const suma = (tipo: string) =>
r2(activos.filter((s) => s.tipoPago === tipo).reduce((a, s) => a + (s.precio || 0), 0));
const subtotalUnico = suma("unico");
const subtotalMensual = suma("mensual");
const ivaUnico = incluyeIva ? r2(subtotalUnico * IVA_RATE) : 0;
const ivaMensual = incluyeIva ? r2(subtotalMensual * IVA_RATE) : 0;
const totalUnico = r2(subtotalUnico + ivaUnico);
const totalMensual = r2(subtotalMensual + ivaMensual);
return {
subtotalUnico,
subtotalMensual,
ivaUnico,
ivaMensual,
totalUnico,
totalMensual,
totalPrimerAnio: r2(totalUnico + totalMensual * 12),
incluyeIva,
};
}
function r2(n: number): number {
return Math.round(n * 100) / 100;
}
/** Aplica IVA a un monto respetando el flag de la cotizacion.
* Sustituye a los `* 1.16` hardcodeados que habia en pdf-generator y excel-builder. */
export function conIva(monto: number, incluirIva: boolean = true): number {
return r2((monto || 0) * (incluirIva ? 1 + IVA_RATE : 1));
}
export const FASES: Record<number, string> = { export const FASES: Record<number, string> = {
0: "FASE 0 - Auditoria / Acompanamiento", 0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura", 1: "FASE 1 - Setup e Infraestructura",
+29 -11
View File
@@ -1,5 +1,5 @@
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { bucefaloPrecio, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators"; import { bucefaloPrecio, conIva, detalleModelo as detalleModeloCanonico, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador // Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador
@@ -44,6 +44,11 @@ export interface ExcelData {
planBucefaloPrecio?: number | null; planBucefaloPrecio?: number | null;
colorPrimario: string; colorPrimario: string;
colorSecundario: string; colorSecundario: string;
incluirIva?: boolean;
/** Texto que SI ve el cliente. El Excel es un documento del cliente (lleva razon
* social, "En atencion a:", domicilio fiscal y nota legal), no una herramienta
* interna: por eso `observacionesInternas` NO se declara aqui. */
observaciones?: string | null;
} }
// Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex): // Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex):
@@ -54,15 +59,10 @@ function argb(hex: string, alpha = "FF"): string {
return alpha + h.toUpperCase(); return alpha + h.toUpperCase();
} }
// Texto de desglose por modelo de cobro (horas / retainer) para mostrar junto al servicio. // Delega en la version canonica de calculators.ts. La copia local que habia aqui
// omitia la rama "demanda", igual que la del PDF.
function detalleModelo(serv: ExcelServicio): string { function detalleModelo(serv: ExcelServicio): string {
if (serv.modeloCobro === "retainer") { return detalleModeloCanonico(serv);
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
} }
function applyThinBorder(cell: ExcelJS.Cell, color?: string) { function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
@@ -374,8 +374,10 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
row++; row++;
}; };
compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont); compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont);
compRow("Total unico (c/IVA)", formatCurrency(t1.totalUnico * 1.16), formatCurrency(t2.totalUnico * 1.16), valueFont); // IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
compRow("Total mensual (c/IVA)", formatCurrency(t1.totalMensual * 1.16), formatCurrency(t2.totalMensual * 1.16), valueFont); const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
compRow(`Total unico${ivaLbl}`, formatCurrency(conIva(t1.totalUnico, data.incluirIva)), formatCurrency(conIva(t2.totalUnico, data.incluirIva)), valueFont);
compRow(`Total mensual${ivaLbl}`, formatCurrency(conIva(t1.totalMensual, data.incluirIva)), formatCurrency(conIva(t2.totalMensual, data.incluirIva)), valueFont);
compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont); compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont);
row++; row++;
} else { } else {
@@ -402,6 +404,22 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
ws.getCell(`B${row}`).font = smallFont; ws.getCell(`B${row}`).font = smallFont;
setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 }); setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 });
// Observaciones del cliente. Solo este campo: el Excel lleva razon social,
// "En atencion a:" y domicilio fiscal, o sea que es un documento que el cliente
// recibe. observacionesInternas no existe en ExcelData a proposito.
if (data.observaciones && data.observaciones.trim()) {
row += 2;
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = "OBSERVACIONES";
ws.getCell(`B${row}`).font = { ...boldFont, color: { argb: PRIMARY } };
row++;
ws.mergeCells(`B${row}:K${row}`);
const texto = data.observaciones.trim();
ws.getCell(`B${row}`).value = texto;
ws.getCell(`B${row}`).font = smallFont;
setWrapped(ws, row, "B", texto, mergedWidth(ws, "B", "K"), { fontSize: 9 });
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// HOJAS DETALLADAS POR SERVICIO // HOJAS DETALLADAS POR SERVICIO
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
+64 -19
View File
@@ -1,5 +1,5 @@
import PDFDocument from "pdfkit"; import PDFDocument from "pdfkit";
import { FASES_SHORT as FASES, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "./calculators"; import { FASES_SHORT as FASES, conIva, detalleModelo, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
interface ServicioPDF { interface ServicioPDF {
nombre: string; nombre: string;
@@ -18,14 +18,11 @@ interface ServicioPDF {
} }
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio. // Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
// omitia la rama "demanda", asi que esa partida se imprimia sin desglose y con
// precio $0, sin explicar que se factura segun consumo.
function detalleModeloPDF(serv: ServicioPDF): string { function detalleModeloPDF(serv: ServicioPDF): string {
if (serv.modeloCobro === "retainer") { return detalleModelo(serv);
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
} }
interface CotizacionPDFData { interface CotizacionPDFData {
@@ -45,6 +42,13 @@ interface CotizacionPDFData {
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
planBucefaloPrecio: number; planBucefaloPrecio: number;
incluirBonos: boolean; incluirBonos: boolean;
/** Bonos desde la tabla Bono. Si no se pasan, se usa la lista de respaldo. */
bonos?: { numero: number; descripcion: string }[];
incluirIva?: boolean;
/** Texto que SI ve el cliente. `observacionesInternas` NO se declara aqui a
* proposito: si el generador no puede verlo, no puede filtrarlo. La garantia
* es estructural, no depende de la disciplina de quien dibuje. */
observaciones?: string | null;
configBancaria?: Record<string, string>; configBancaria?: Record<string, string>;
colorPrimario?: string; colorPrimario?: string;
colorSecundario?: string; colorSecundario?: string;
@@ -324,9 +328,11 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 }); doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 }); doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
y += 18; y += 18;
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
const filas: [string, string, string][] = [ const filas: [string, string, string][] = [
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)], [`Total unico${ivaLbl}`, fmt(conIva(t1.totalUnico, data.incluirIva)), fmt(conIva(t2.totalUnico, data.incluirIva))],
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)], [`Total mensual${ivaLbl}`, fmt(conIva(t1.totalMensual, data.incluirIva)), fmt(conIva(t2.totalMensual, data.incluirIva))],
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`], ["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
]; ];
for (const [lab, v1, v2] of filas) { for (const [lab, v1, v2] of filas) {
@@ -374,21 +380,60 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.rect(L, y, 3, 10).fill(PRIMARY); doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y); doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
y += 16; y += 16;
const bonos = [ // Fuente de verdad: la tabla Bono. La lista de abajo es solo respaldo por si
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)", // la consulta no trajo nada; antes estaba hardcodeada aqui y su texto ya no
"Bono 2: Workshop Estrategico de Buyer Persona", // coincidia con el del seed (bono 5).
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible", const RESPALDO = [
"Bono 4: 1 ano de Membresia Premium", "30 min mensuales en servicios Centinela (Sitio Web)",
"Bono 5: Un mes gratis de Bucefalo CRM", "Workshop Estrategico de Buyer Persona",
"Bono 6: Script de Ventas con mas de 100 complementos", "Workshop de Propuestas de Valor y Oferta Irresistible",
]; "1 ano de Membresia Premium",
"Un mes gratis de Bucefalo CRM, Marketing y Ventas",
"Script de Ventas con mas de 100 complementos",
].map((d, i) => `Bono ${i + 1}: ${d}`);
const bonos = data.bonos?.length
? data.bonos.map((b) => `Bono ${b.numero}: ${b.descripcion}`)
: RESPALDO;
for (const b of bonos) { for (const b of bonos) {
y = need(11, y); y = need(11, y);
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2713 ${b}`, L + 8, y, { width: W - 16 }); // Vinneta "\u2022" y no la palomita "\u2713": en las fuentes estandar de PDFKit
// la palomita mide 0pt de ancho, o sea que hoy salia como dos espacios.
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2022 ${b}`, L + 8, y, { width: W - 16 });
y += 11; y += 11;
} }
} }
// ── OBSERVACIONES (solo el texto del cliente) ──
// Va al final de la Hoja Resumen, junto a los totales y antes de T&C, que es
// donde el cliente espera leer un mensaje del asesor. Nunca imprime
// observacionesInternas: ese campo ni siquiera existe en CotizacionPDFData.
if (data.observaciones && data.observaciones.trim()) {
y = sectionTitle("Observaciones", y);
const usable = maxY - m.top;
const parrafos = data.observaciones
.split(/\r?\n/)
.map((p) => p.trim())
.filter(Boolean);
for (const p of parrafos) {
const h = txtHeight(p, W - 4, 7.5);
if (h > usable) {
// Parrafo mas alto que una pagina entera: need() no sabe partir, asi que
// se deja fluir a pdfkit y se resincroniza el contador con doc.y.
y = need(20, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
y = doc.y + 4;
} else {
y = need(h + 4, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
y += h + 4;
}
}
y += 4;
}
// ── T&C PAGE ────────────────────────────────── // ── T&C PAGE ──────────────────────────────────
doc.addPage(); doc.addPage();
y = m.top; y = m.top;
+234
View File
@@ -0,0 +1,234 @@
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { aJsonSchema } from "./schemas";
/**
* Cliente del proveedor de IA (MiniMax-M3 via su endpoint compatible con Anthropic)
* y runner que fuerza el contrato con tool-calling.
*
* MiniMax NO soporta structured outputs (`output_config` / `json_schema`), asi que
* el `input_schema` de la herramienta ES el contrato, y Zod valida siempre del lado
* del codigo. `tool_choice` no se envia: no esta documentado en MiniMax, y fijarlo
* solo en el reintento le daria al reintento un prefijo distinto se pagaria el
* contexto completo justo cuando es mas grande.
*/
export interface UsoTokens {
entrada: number;
salida: number;
cacheLectura: number;
cacheEscritura: number;
}
/**
* Normaliza rarezas observadas en las respuestas de MiniMax antes de validar.
*
* Observado contra la API real, sobre datos de produccion: a veces envuelve los
* elementos de un array en un objeto `{item: {...}}` en vez de emitir el objeto
* directamente. Zod lo rechaza con `Unrecognized keys: "item"` y se gasta un
* reintento en algo que se puede corregir aqui sin ambiguedad.
*
* Solo desenvuelve cuando `item` es la UNICA clave: si el objeto trae mas cosas,
* podria ser un campo legitimo y no se toca.
*/
export function normalizarRespuesta(v: unknown): unknown {
if (Array.isArray(v)) return v.map(normalizarRespuesta);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const claves = Object.keys(o);
if (claves.length === 1 && claves[0] === "item") return normalizarRespuesta(o.item);
const salida: Record<string, unknown> = {};
for (const k of claves) salida[k] = normalizarRespuesta(o[k]);
return salida;
}
return v;
}
export function crearCliente(): Anthropic {
const apiKey = process.env.MINIMAX_API_KEY;
if (!apiKey) {
throw new Error(
"Falta MINIMAX_API_KEY. Definela en .env. No uses ANTHROPIC_API_KEY: el SDK la " +
"leeria por su cuenta y mandaria la clave de MiniMax a api.anthropic.com."
);
}
// apiKey y baseURL explicitos. Si se dejan al SDK, toma las ANTHROPIC_* del shell
// y la clave termina en el proveedor equivocado.
return new Anthropic({
apiKey,
baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/anthropic",
});
}
export function modelo(): string {
return process.env.MINIMAX_MODEL || "MiniMax-M3";
}
export interface BloqueSystem {
texto: string;
cachear: boolean;
}
/**
* ¿El fallo es del proveedor y vale la pena reintentar?
*
* Motivacion: en produccion aparecio un "unexpected doc type" que NO se pudo reproducir
* en seis corridas completas del pipeline contra los mismos datos, y la generacion que
* lo siguio completo sin problema. Todo apunta a un fallo transitorio del proveedor.
* En vez de adivinar un arreglo para un error que no se puede reproducir, se reintenta
* esa clase de fallo con espera creciente.
*/
function esTransitorio(e: unknown): boolean {
const err = e as { status?: number; message?: string };
if (typeof err?.status === "number") {
// 408 timeout, 409 conflicto, 429 rate limit, 5xx y el 529 de sobrecarga.
if ([408, 409, 429].includes(err.status) || err.status >= 500) return true;
// 400 con mensaje que no describe un problema de nuestro payload: el proveedor
// devuelve errores de parseo internos con 400. Se reintenta una vez por si acaso.
if (err.status === 400 && /unexpected|internal|parse|unknown/i.test(err.message ?? "")) return true;
}
// Fallos de red sin status.
if (!err?.status && /ECONN|ETIMEDOUT|socket|network|fetch failed/i.test(err?.message ?? "")) return true;
return false;
}
const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Llama al proveedor reintentando SOLO los fallos transitorios. Los errores de
* nuestro payload no se reintentan aqui: los corrige el bucle de schema. */
async function crearMensajeConReintentos(
cliente: Anthropic,
cuerpo: Anthropic.MessageCreateParamsNonStreaming,
maxTransitorios = 3
): Promise<Anthropic.Message> {
let ultimo: unknown;
for (let i = 1; i <= maxTransitorios; i++) {
try {
return await cliente.messages.create(cuerpo);
} catch (e) {
ultimo = e;
if (!esTransitorio(e) || i === maxTransitorios) throw e;
await esperar(1000 * 2 ** (i - 1)); // 1s, 2s
}
}
throw ultimo;
}
interface OpcionesLlamada {
system: BloqueSystem[];
mensajeUsuario: string;
herramienta: { nombre: string; descripcion: string; schema: z.ZodType };
maxIntentos: number;
maxTokens: number;
}
export async function llamarConHerramienta<T>(
opts: OpcionesLlamada
): Promise<{ datos: T; uso: UsoTokens; intentos: number }> {
const cliente = crearCliente();
const tools = [
{
name: opts.herramienta.nombre,
description: opts.herramienta.descripcion,
input_schema: aJsonSchema(opts.herramienta.schema) as Anthropic.Tool["input_schema"],
},
];
const system = opts.system.map((b) => ({
type: "text" as const,
text: b.texto,
...(b.cachear ? { cache_control: { type: "ephemeral" as const } } : {}),
}));
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: opts.mensajeUsuario }];
const uso: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
let ultimoError = "";
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
const res = await crearMensajeConReintentos(cliente, {
model: modelo(),
max_tokens: opts.maxTokens,
system,
tools,
messages: mensajes,
});
const u = res.usage as {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
uso.entrada += u.input_tokens ?? 0;
uso.salida += u.output_tokens ?? 0;
uso.cacheLectura += u.cache_read_input_tokens ?? 0;
uso.cacheEscritura += u.cache_creation_input_tokens ?? 0;
const bloquesTool = res.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
// El modelo a veces emite DOS tool_use en una misma respuesta (observado contra la
// API real). Se prueban todos los candidatos y gana el primero que valide, en vez
// de quedarse con el primero a secas y desperdiciar un reintento.
const candidatos = bloquesTool.filter((b) => b.name === opts.herramienta.nombre);
let correcto: Anthropic.ToolUseBlock | undefined;
for (const c of candidatos) {
const intentoParse = opts.herramienta.schema.safeParse(normalizarRespuesta(c.input));
if (intentoParse.success) {
return { datos: intentoParse.data as T, uso, intentos: intento };
}
// Se guarda el primero para reportar su error si ninguno valida.
if (!correcto) {
correcto = c;
ultimoError = z.prettifyError(intentoParse.error);
}
}
if (correcto) {
// Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar.
// Un turno de usuario plano despues de un tool_use devuelve 400.
mensajes.push(
{ role: "assistant", content: res.content },
{
role: "user",
content: [
...bloquesTool.map((b) => ({
type: "tool_result" as const,
tool_use_id: b.id,
is_error: true,
content:
b.id === correcto.id
? ultimoError
: `Herramienta inesperada. Usa ${opts.herramienta.nombre}.`,
})),
{
type: "text" as const,
text:
`La llamada no cumple el schema. Corrige EXACTAMENTE estos errores y vuelve a ` +
`llamar a ${opts.herramienta.nombre}:\n\n${ultimoError}\n\n` +
`Revisa que cada objeto este en el array que le corresponde y que no falte ` +
`ningun campo obligatorio. No agregues campos que el schema no declara.`,
},
],
}
);
continue;
}
// No llamo a ninguna herramienta: aqui si va un turno de usuario plano.
ultimoError = "El modelo respondio con prosa en vez de llamar a la herramienta.";
mensajes.push(
{ role: "assistant", content: res.content },
{
role: "user",
content: `Debes responder llamando a la herramienta ${opts.herramienta.nombre}. No escribas prosa suelta.`,
}
);
}
throw new Error(
`El paso "${opts.herramienta.nombre}" agoto ${opts.maxIntentos} intentos.\nUltimo error:\n${ultimoError}`
);
}
+113
View File
@@ -0,0 +1,113 @@
import { prisma } from "@/lib/db";
import { calcularTotalesCotizacion, type TotalesCotizacion } from "@/lib/calculators";
/**
* Capa de datos economicos de la propuesta consultiva.
*
* Existe para que el dinero viva en UN solo sitio y nunca cruce hacia el prompt.
* El pipeline de IA recibe de aqui unicamente `refPartida` y `nombre`; los importes
* los inyecta el renderizador. Asi el principio "la IA no toca los numeros" lo
* garantiza el compilador y no una regla que alguien pueda olvidar validar.
*/
export interface PartidaCanonica {
/** P01, P02... Es lo unico que identifica una partida ante el modelo. */
refPartida: string;
/** cuid real de ServicioCotizado. NUNCA sale hacia el proveedor: no es estable
* entre ediciones, porque el PUT hace deleteMany + createMany. */
servicioCotizadoId: string;
nombre: string;
fase: number;
tipoPago: string;
precio: number;
tiempoEntrega: string;
modeloCobro: string;
horas: number | null;
tarifaHora: number | null;
entregables: string[];
}
export interface DatosEconomicos {
partidas: PartidaCanonica[];
totales: TotalesCotizacion;
moneda: string;
/** Plan Bucefalo contratado, si lo hay. Se cobra mensual y aparece en el PDF
* economico y en el Excel; tiene que aparecer tambien en el consultivo o el
* cliente recibe dos documentos con alcances distintos. */
planBucefalo: { nivel: string; precio: number } | null;
}
export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> {
const cot = await prisma.cotizacion.findUnique({
where: { id: cotizacionId },
include: {
servicios: {
include: { servicioCatalogo: true },
// Mismo orden que los exportadores: los tres documentos van en el mismo
// correo y deben listar las partidas igual.
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
},
planBucefalo: true,
},
});
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[]) : [],
}));
const plan =
cot.planBucefalo && cot.planBucefalo.seleccionado
? { nivel: cot.planBucefalo.nivel, precio: cot.planBucefalo.precio }
: null;
// El plan Bucefalo se cobra mensual: entra a los totales como una partida mensual
// mas, igual que en el PDF economico.
const paraTotales = plan
? [...activos, { tipoPago: "mensual", precio: plan.precio, seleccionado: true }]
: activos;
return {
partidas,
totales: calcularTotalesCotizacion(paraTotales, { incluirIva: cot.incluirIva }),
moneda: cot.moneda,
planBucefalo: plan,
};
}
export type LecturaRatio = "subcotizado" | "en_rango" | "alto" | "objecion_probable";
/**
* Ratio precio/valor para el anexo interno.
*
* Base: el desembolso real del primer ano CON IVA (unico + mensual x 12). Se eligio
* asi para que el numerador y el denominador tengan las mismas unidades: comparar un
* pago inicial contra un valor ANUAL del problema daria una lectura optimista falsa.
*/
export function calcularRatio(
totalPrimerAnio: number,
valorAnual: number | null
): { ratio: number; lectura: LecturaRatio } | null {
if (!valorAnual || valorAnual <= 0) return null;
const ratio = totalPrimerAnio / valorAnual;
const lectura: LecturaRatio =
ratio < 0.1
? "subcotizado"
: ratio <= 0.25
? "en_rango"
: ratio <= 0.4
? "alto"
: "objecion_probable";
return { ratio: Math.round(ratio * 1000) / 1000, lectura };
}
+551
View File
@@ -0,0 +1,551 @@
import PDFDocument from "pdfkit";
import { formatCurrency, formatDate } from "@/lib/calculators";
import { calcularRatio, type DatosEconomicos } from "./economia";
import type { PropuestaConsultiva } from "./schemas";
/**
* Generador del documento consultivo.
*
* Sigue la arquitectura argumental de la plantilla de referencia (hero, diagnostico con
* semaforo, valor del problema, resultados, alcance, beneficios, exclusiones, pendientes,
* backlog) pero en tema claro y con PDFKit, como el resto del sistema: la plantilla
* original es de tema oscuro, y eso en impresion depende de una casilla del navegador
* que viene desactivada el cliente recibiria texto crema sobre papel blanco.
*
* El anexo interno va en SU PROPIO archivo, nunca como seccion oculta del documento del
* cliente. Un display:none o una pagina extra se envian por error; un archivo con otro
* nombre, no.
*/
export interface DatosPropuestaPDF {
propuesta: PropuestaConsultiva;
economia: DatosEconomicos;
cliente: string;
empresa: string;
asesor: string;
numero: string;
fecha: Date;
vigencia: Date;
proyecto: string;
branding: {
colorPrimario?: string;
colorSecundario?: string;
logoBase64?: string;
logoMime?: string;
};
}
const COLOR_SEMAFORO: Record<string, string> = {
rojo: "#dc2626",
ambar: "#d97706",
azul: "#2563eb",
verde: "#16a34a",
};
const ETIQUETA_SEMAFORO: Record<string, string> = {
rojo: "Problema critico",
ambar: "Area de mejora",
azul: "Oportunidad",
verde: "Ventaja existente",
};
const ETIQUETA_CONFIANZA: Record<string, string> = {
confirmado: "Confirmado",
estimado: "Estimado",
por_validar: "Por validar",
};
/** `PUT /api/configuracion` filtra las claves permitidas pero NO valida los valores,
* asi que aqui no se confia en ellos. */
function hexSeguro(v: string | undefined, porDefecto: string): string {
return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto;
}
/** Respaldo cuando la IA no describio una partida: se arma con los datos del
* catalogo, para que la partida nunca desaparezca del documento. */
function detalleDePartida(p: { tiempoEntrega: string; entregables: string[]; tipoPago: string }): string {
const trozos: string[] = [];
if (p.entregables.length) trozos.push(p.entregables.slice(0, 4).join(" · "));
if (p.tiempoEntrega) trozos.push(`Entrega: ${p.tiempoEntrega}`);
if (p.tipoPago === "mensual") trozos.push("Servicio mensual");
return trozos.join(" | ");
}
interface Lienzo {
doc: PDFKit.PDFDocument;
W: number;
L: number;
ph: number;
maxY: number;
mTop: number;
}
function crearLienzo(): { lienzo: Lienzo; listo: Promise<Buffer> } {
const doc = new PDFDocument({
size: "LETTER",
margins: { top: 50, bottom: 55, left: 50, right: 50 },
bufferPages: true,
});
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const listo = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
const m = doc.page.margins;
return {
lienzo: {
doc,
W: doc.page.width - m.left - m.right,
L: m.left,
ph: doc.page.height,
maxY: doc.page.height - m.bottom - 5,
mTop: m.top,
},
listo,
};
}
export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer> {
const PRIMARY = hexSeguro(d.branding.colorPrimario, "#2563eb");
const DARK = hexSeguro(d.branding.colorSecundario, "#1e293b");
const MUTED = "#64748b";
const BORDER = "#e2e8f0";
const { lienzo, listo } = crearLienzo();
const { doc, W, L, ph, maxY, mTop } = lienzo;
let y = mTop;
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
const txtH = (s: string, w: number, size: number) =>
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
function titulo(t: string) {
y = need(26, y);
doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(t.toUpperCase(), L + 10, y);
y += 20;
}
function parrafo(t: string, size = 9, color = DARK) {
for (const p of t.split(/\r?\n/).map((x) => x.trim()).filter(Boolean)) {
const h = txtH(p, W, size);
y = need(h + 4, y);
doc.font("Helvetica").fontSize(size).fillColor(color).text(p, L, y, { width: W });
y += h + 4;
}
}
// ── HERO ──
if (d.branding.logoBase64) {
try {
doc.image(Buffer.from(d.branding.logoBase64, "base64"), L, y, { height: 26 });
y += 34;
} catch {
/* logo invalido: se omite, no se rompe el documento */
}
}
doc.font("Helvetica-Bold").fontSize(8.5).fillColor(PRIMARY).text("PROPUESTA CONSULTIVA", L, y);
y += 16;
const hTit = txtH(d.propuesta.redaccion.hero.titulo, W, 20);
doc.font("Helvetica-Bold").fontSize(20).fillColor(DARK).text(d.propuesta.redaccion.hero.titulo, L, y, { width: W });
y += hTit + 8;
parrafo(d.propuesta.redaccion.hero.subtitulo, 10, MUTED);
y += 8;
const meta = `${d.empresa || d.cliente} · ${d.numero} · ${formatDate(d.fecha)} · Vigencia ${formatDate(d.vigencia)} · ${d.asesor}`;
const hMeta = txtH(meta, W, 8);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(meta, L, y, { width: W });
y += hMeta + 12;
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
y += 18;
// ── DIAGNOSTICO ──
titulo("Diagnostico");
const cita = d.propuesta.hechos.citas.find((c) => c.id === d.propuesta.redaccion.citaDestacadaId);
if (cita) {
const texto = `"${cita.textoLiteral}"`;
const h = doc.font("Helvetica-Oblique").fontSize(11).heightOfString(texto, { width: W - 22 });
y = need(h + 22, y);
doc.rect(L, y - 3, 2.5, h + 10).fill(PRIMARY);
doc.font("Helvetica-Oblique").fontSize(11).fillColor(DARK).text(texto, L + 12, y, { width: W - 22 });
y += h + 4;
doc.font("Helvetica").fontSize(7.5).fillColor(MUTED).text(`${cita.quienLoDijo}`, L + 12, y);
y += 18;
}
for (const hal of d.propuesta.diagnostico.hallazgos) {
const cuerpo = `${hal.titulo}${hal.descripcion}`;
const h = txtH(cuerpo, W - 24, 9);
y = need(h + 14, y);
doc.circle(L + 4.5, y + 4.5, 3.2).fill(COLOR_SEMAFORO[hal.urgencia] || MUTED);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(cuerpo, L + 16, y, { width: W - 24 });
y += h + 1;
doc
.font("Helvetica")
.fontSize(7)
.fillColor(MUTED)
.text(`${ETIQUETA_SEMAFORO[hal.urgencia] ?? ""} · ${ETIQUETA_CONFIANZA[hal.confianza] ?? ""}`, L + 16, y);
y += 13;
}
y += 8;
// ── VALOR DEL PROBLEMA ──
const dims = d.propuesta.diagnostico.valorProblema.dimensiones;
if (dims.length) {
titulo("Lo que cuesta no resolverlo");
for (const dim of dims) {
const monto = dim.montoAnualMXN;
const linea = `${dim.descripcion}${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`;
const h = txtH(linea, W - 14, 9);
y = need(h + 14, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(linea, L + 6, y, { width: W - 14 });
y += h + 1;
doc.font("Helvetica").fontSize(7).fillColor(MUTED).text(ETIQUETA_CONFIANZA[dim.confianza] ?? "", L + 6, y);
y += 13;
}
if (d.propuesta.diagnostico.valorProblema.notaMetodologia.trim()) {
y += 2;
parrafo(d.propuesta.diagnostico.valorProblema.notaMetodologia, 8, MUTED);
}
y += 8;
}
// ── RESULTADOS ──
if (d.propuesta.diagnostico.resultados.length) {
titulo("Resultados a lograr");
for (const r of d.propuesta.diagnostico.resultados) {
const t = `${r.enunciado} (${r.metrica}, ${r.periodoMedicion})`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 8;
}
// ── ALCANCE ──
// Se recorren las partidas de la COTIZACION, no las que la IA alcanzo a describir.
// Motivo: el schema acota `alcance` y una cotizacion con mas partidas que ese tope
// dejaria servicios cotizados fuera del documento del cliente, en silencio.
// Verificado con UJ2606UR001: 58 partidas, la IA describio 40, faltaban 18.
// La completitud la manda la base de datos; la IA solo aporta la prosa.
titulo("Alcance de la inversion");
const descPorRef = new Map(d.propuesta.redaccion.alcance.map((a) => [a.refPartida, a.descripcionResultado]));
for (const p of d.economia.partidas) {
const descripcion = descPorRef.get(p.refPartida);
const hNombre = txtH(p.nombre, W * 0.68, 9.5);
const hDesc = descripcion ? txtH(descripcion, W * 0.68, 8) : 0;
y = need(hNombre + hDesc + 14, y);
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(PRIMARY)
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
y += hNombre + 2;
if (descripcion) {
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(descripcion, L, y, { width: W * 0.68 });
y += hDesc + 10;
} else {
// Sin prosa de la IA: la partida SI aparece, con su detalle del catalogo.
const respaldo = detalleDePartida(p);
if (respaldo) {
const hR = txtH(respaldo, W * 0.68, 8);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(respaldo, L, y, { width: W * 0.68 });
y += hR + 10;
} else {
y += 8;
}
}
}
// El plan Bucefalo es una partida mas del acuerdo: si no se imprime aqui, el
// cliente recibe un documento consultivo con menos alcance que su cotizacion.
if (d.economia.planBucefalo) {
const nivel = d.economia.planBucefalo.nivel;
const etiqueta = `CRM Bucefalo — plan ${nivel.charAt(0).toUpperCase() + nivel.slice(1)}`;
const hE = txtH(etiqueta, W * 0.68, 9.5);
y = need(hE + 22, y);
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(etiqueta, L, y, { width: W * 0.68 });
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(PRIMARY)
.text(`${formatCurrency(d.economia.planBucefalo.precio)} / mes`, L + W * 0.7, y, { width: W * 0.3, align: "right" });
y += hE + 2;
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text("Servicio mensual", L, y, { width: W * 0.68 });
y += 20;
}
y = need(58, y);
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
y += 10;
// ── CAJA DE TOTALES ──
// Desglose completo (subtotal / IVA / total), como la plantilla autorizada.
//
// Antes se imprimia cada partida a su precio SIN IVA y debajo un unico total CON
// IVA, rematado con la leyenda "Importes con IVA incluido": las lineas no sumaban
// el total, la leyenda contradecia a sus propias lineas, y el PDF economico que va
// en el MISMO correo dice "los precios no incluyen IVA" sobre las mismas cifras.
// Ahora las lineas siguen siendo sin IVA —igual que el otro documento— y el IVA
// aparece como renglon propio.
const t = d.economia.totales;
const fila = (etiqueta: string, valor: string, fuerte = false) => {
y = need(fuerte ? 18 : 15, y);
doc
.font(fuerte ? "Helvetica-Bold" : "Helvetica")
.fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? DARK : MUTED)
.text(etiqueta, L, y);
doc
.font("Helvetica-Bold")
.fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? PRIMARY : DARK)
.text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
y += fuerte ? 18 : 15;
};
if (t.subtotalUnico > 0) {
fila("Subtotal pago unico", formatCurrency(t.subtotalUnico));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaUnico));
fila(t.incluyeIva ? "Total pago unico" : "Total pago unico (sin IVA)", formatCurrency(t.totalUnico), true);
}
if (t.subtotalMensual > 0) {
if (t.subtotalUnico > 0) y += 4;
fila("Subtotal mensual", formatCurrency(t.subtotalMensual));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaMensual));
fila(t.incluyeIva ? "Total mensual" : "Total mensual (sin IVA)", formatCurrency(t.totalMensual), true);
}
y = need(16, y);
doc
.font("Helvetica")
.fontSize(7.5)
.fillColor(MUTED)
.text(
t.incluyeIva
? "Los precios por partida son en Moneda Nacional (MXN) y no incluyen IVA; el IVA se desglosa arriba. Facturacion CFDI."
: "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.",
L,
y,
{ width: W }
);
y += 24;
// ── BENEFICIOS ──
if (d.propuesta.redaccion.beneficios.length) {
titulo("Por que tiene sentido");
for (const b of d.propuesta.redaccion.beneficios) {
const hEt = txtH(b.etiqueta, W * 0.24, 9);
const hTx = txtH(b.texto, W * 0.72, 8.5);
const h = Math.max(hEt, hTx);
y = need(h + 9, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(b.etiqueta, L, y, { width: W * 0.24 });
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(b.texto, L + W * 0.27, y, { width: W * 0.72 });
y += h + 9;
}
y += 8;
}
// ── EXCLUSIONES ──
titulo("Que no incluye esta propuesta");
for (const e of d.propuesta.redaccion.exclusiones) {
const t2 = `${e.texto}`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
// ── PENDIENTES: el canal de honestidad del documento ──
const mats = d.propuesta.hechos.materialesPendientes;
const decs = d.propuesta.hechos.decisionesPendientes;
if (mats.length || decs.length) {
titulo("Que necesitamos de ustedes");
for (const mm of mats) {
const t2 = `${mm.texto}${mm.bloqueaEntrega ? " (el avance queda condicionado a esto)" : ""}`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
for (const dd of decs) {
const t2 = `· ${dd.texto} (decide: ${dd.quienDecide})`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
}
// ── BACKLOG ──
if (d.propuesta.redaccion.backlogEvolucion.length) {
titulo("Registrado para mas adelante");
y = need(14, y);
doc.font("Helvetica-Oblique").fontSize(7.5).fillColor(MUTED).text("No comprometido en esta propuesta.", L, y);
y += 14;
for (const b of d.propuesta.redaccion.backlogEvolucion) {
const t2 = `· ${b.problema} (${b.momentoSugerido})`;
const h = txtH(t2, W - 8, 8.5);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
y += h + 6;
}
}
// ── FOOTERS ──
// margins.bottom = 0 temporal: sin eso, escribir en el margen inferior dispara el
// auto-page-break de pdfkit y se generan paginas vacias.
const savedBottom = doc.page.margins.bottom;
const rango = doc.bufferedPageRange();
for (let i = rango.start; i < rango.start + rango.count; i++) {
doc.switchToPage(i);
doc.page.margins.bottom = 0;
doc.moveTo(L, ph - 42).lineTo(L + W, ph - 42).strokeColor(BORDER).lineWidth(0.5).stroke();
doc
.font("Helvetica")
.fontSize(7)
.fillColor(MUTED)
.text(`${d.numero} · Propuesta consultiva · ${d.empresa || d.cliente}`, L, ph - 35, { lineBreak: false });
doc
.font("Helvetica")
.fontSize(7)
.fillColor(MUTED)
.text(`${i - rango.start + 1} / ${rango.count}`, L, ph - 35, { width: W, align: "right", lineBreak: false });
doc.page.margins.bottom = savedBottom;
}
doc.end();
return listo;
}
/** Anexo interno. Archivo SEPARADO y visualmente inconfundible: el riesgo de que una
* seccion oculta llegue al cliente es demasiado alto para un display:none. */
export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buffer> {
const { lienzo, listo } = crearLienzo();
const { doc, W, L, maxY, mTop } = lienzo;
let y = mTop;
const DARK = "#1e293b";
const MUTED = "#64748b";
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
const txtH = (s: string, w: number, size: number) =>
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
doc.rect(0, 0, doc.page.width, 36).fill("#b91c1c");
doc.font("Helvetica-Bold").fontSize(13).fillColor("#ffffff").text("USO INTERNO — NO ENVIAR AL CLIENTE", L, 11);
y = 56;
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(`${d.numero} · ${d.empresa || d.cliente} · ${d.asesor}`, L, y);
y += 24;
const valorAnual =
d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.montoAnualMXN ?? 0), 0) || null;
const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual);
const seccion = (t: string) => {
y = need(24, y);
doc.font("Helvetica-Bold").fontSize(11).fillColor(DARK).text(t, L, y);
y += 18;
};
const linea = (k: string, v: string) => {
y = need(15, y);
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(k, L, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(v, L + W * 0.5, y, { width: W * 0.5, align: "right" });
y += 15;
};
seccion("Ponderacion de la cotizacion");
linea("Inversion del primer ano", formatCurrency(d.economia.totales.totalPrimerAnio));
linea("Valor anual del problema", valorAnual ? formatCurrency(valorAnual) : "sin cuantificar");
linea("Ratio precio / valor", r ? `${(r.ratio * 100).toFixed(1)}% — ${r.lectura.replace(/_/g, " ")}` : "no calculable");
linea("Hallazgos en el diagnostico", String(d.propuesta.diagnostico.hallazgos.length));
linea("Cifras confirmadas", String(d.propuesta.diagnostico.hallazgos.filter((h) => h.confianza === "confirmado").length));
linea("Exclusiones definidas", String(d.propuesta.redaccion.exclusiones.length));
linea("Materiales pendientes", String(d.propuesta.hechos.materialesPendientes.length));
linea("Decisiones pendientes", String(d.propuesta.hechos.decisionesPendientes.length));
linea("Red flags detectadas", String(d.propuesta.hechos.redFlags.length));
y += 6;
// Desglose del valor anual. Va aqui porque es el denominador del ratio: si esa cifra
// esta inflada, el ratio miente y el asesor toma una decision de precio con un dato
// malo. Verificado en produccion que el modelo puede omitir un factor (una tasa de
// conversion) y aun asi cuadrar la aritmetica.
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
seccion("De donde sale el valor anual");
for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) {
const monto = dim.montoAnualMXN;
const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`;
y = need(14, y);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
y += 13;
if (dim.factores.length) {
const desglose = dim.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
const h = txtH(desglose, W - 10, 8);
y = need(h + 8, y);
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 });
y += h + 8;
}
}
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
(dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado")
);
if (sinConfirmar) {
const alerta =
"Ojo: hay una cifra que descansa entera en factores estimados. Antes de fiarte del ratio, " +
"revisa que no falte un factor — la aritmetica puede cuadrar y aun asi estar inflada.";
const h = txtH(alerta, W, 8.5);
y = need(h + 12, y);
doc.font("Helvetica-Bold").fontSize(8.5).fillColor("#b91c1c").text(alerta, L, y, { width: W });
y += h + 14;
}
y += 4;
}
if (r) {
const nota =
r.lectura === "subcotizado"
? "La inversion es menos del 10% de lo que el problema le cuesta al cliente cada ano. Probablemente subcotizaste."
: r.lectura === "objecion_probable"
? "La inversion supera el 40% del valor anual del problema. Prepara la conversacion de precio: se responde con alcance, no con descuento."
: r.lectura === "alto"
? "La inversion esta en la banda alta. Justificable, pero conviene anclar bien el valor antes de dar el numero."
: "La inversion cae en el rango de referencia (15-25% del valor anual).";
const h = txtH(nota, W, 8.5);
y = need(h + 12, y);
doc.font("Helvetica-Oblique").fontSize(8.5).fillColor(MUTED).text(nota, L, y, { width: W });
y += h + 16;
}
if (d.propuesta.hechos.redFlags.length) {
seccion("Red flags");
for (const rf of d.propuesta.hechos.redFlags) {
const t = `· [${rf.severidad}] ${rf.senal}`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
}
if (d.propuesta.redaccion.notasInternas.length) {
seccion("Notas para el asesor");
for (const n of d.propuesta.redaccion.notasInternas) {
const t = `· ${n}`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
y += 10;
}
if (d.propuesta.hechos.mencionesFueraDeAlcance.length) {
seccion("Se menciono y quedo fuera");
for (const m of d.propuesta.hechos.mencionesFueraDeAlcance) {
const t = `· ${m}`;
const h = txtH(t, W - 8, 9);
y = need(h + 6, y);
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
y += h + 6;
}
}
doc.end();
return listo;
}
+99
View File
@@ -0,0 +1,99 @@
import { llamarConHerramienta, modelo, type UsoTokens, type BloqueSystem } from "./cliente-ia";
import {
hechosSchema,
diagnosticoSchema,
redaccionSchema,
type Hechos,
type Diagnostico,
type Redaccion,
type PropuestaConsultiva,
} from "./schemas";
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "./prompts";
import type { DatosEconomicos } from "./economia";
/**
* Pipeline de tres pasos.
*
* Se separa en tres porque un solo prompt monolitico produce un documento mediocre en
* todas sus partes: la extraccion determina la calidad de todo lo demas y merece su
* propia pasada. Ademas permite corregir los hechos antes de que se propaguen.
*/
export interface Traza {
modelo: string;
pasos: { paso: string; intentos: number; uso: UsoTokens }[];
totalUso: UsoTokens;
}
// Un solo breakpoint de cache, al final de la capa estable. El pipeline hace tres
// llamadas seguidas con el mismo prefijo, asi que el TTL de 5 minutos de MiniMax
// alcanza de sobra y las llamadas 2 y 3 deberian leer del cache.
const SYSTEM: BloqueSystem[] = [{ texto: SYSTEM_BASE, cachear: true }];
export async function generarPropuesta(opts: {
entrada: ContextoEntrada;
economia: DatosEconomicos;
}): Promise<{ propuesta: PropuestaConsultiva; traza: Traza }> {
const pasos: Traza["pasos"] = [];
const total: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
const acumular = (u: UsoTokens) => {
total.entrada += u.entrada;
total.salida += u.salida;
total.cacheLectura += u.cacheLectura;
total.cacheEscritura += u.cacheEscritura;
};
const r1 = await llamarConHerramienta<Hechos>({
system: SYSTEM,
mensajeUsuario: mensajePaso1(opts.entrada),
herramienta: {
nombre: "registrar_hechos",
descripcion:
"Registra las citas literales, los hechos, los pendientes y las red flags extraidos del material de la reunion.",
schema: hechosSchema,
},
// 5 intentos: verificado contra la API real que MiniMax se equivoca de array de
// vez en cuando con schemas anidados (mete campos de "hechos" dentro de "citas").
// Es el paso fundacional; si falla, no hay documento. El cache abarata el reintento.
maxIntentos: 5,
maxTokens: 8000,
});
pasos.push({ paso: "extraccion", intentos: r1.intentos, uso: r1.uso });
acumular(r1.uso);
const r2 = await llamarConHerramienta<Diagnostico>({
system: SYSTEM,
mensajeUsuario: mensajePaso2(opts.entrada, r1.datos, opts.economia),
herramienta: {
nombre: "registrar_diagnostico",
descripcion:
"Registra los hallazgos con su semaforo de urgencia, el valor anual del problema y los resultados de negocio a lograr.",
schema: diagnosticoSchema,
},
maxIntentos: 4,
maxTokens: 8000,
});
pasos.push({ paso: "diagnostico", intentos: r2.intentos, uso: r2.uso });
acumular(r2.uso);
// El paso 3 recibe un intento extra: es el mas largo y el que atraviesa mas
// compuertas de validacion, asi que es donde mas probable es agotar el presupuesto.
const r3 = await llamarConHerramienta<Redaccion>({
system: SYSTEM,
mensajeUsuario: mensajePaso3(opts.entrada, r1.datos, r2.datos, opts.economia),
herramienta: {
nombre: "registrar_propuesta",
descripcion: "Registra la redaccion final de la propuesta consultiva para el cliente.",
schema: redaccionSchema,
},
maxIntentos: 4,
maxTokens: 12000,
});
pasos.push({ paso: "redaccion", intentos: r3.intentos, uso: r3.uso });
acumular(r3.uso);
return {
propuesta: { hechos: r1.datos, diagnostico: r2.datos, redaccion: r3.datos },
traza: { modelo: modelo(), pasos, totalUso: total },
};
}
+244
View File
@@ -0,0 +1,244 @@
import type { DatosEconomicos } from "./economia";
import type { Hechos, Diagnostico } from "./schemas";
/**
* Prompts en capas, ordenados por estabilidad.
*
* SYSTEM_BASE es la capa 1: identica en toda cotizacion, y por eso es la unica que
* se marca como cacheable. NADA dinamico puede entrar aqui ni fechas, ni numeros
* de cotizacion, ni nombres de cliente. Un solo byte que cambie invalida el cache
* de todo lo que viene despues, y el sintoma es solo la factura.
*/
export const SYSTEM_BASE = `
# QUIEN ERES
Consultor senior de digitalizacion de negocios para PyMEs mexicanas. Redactas la parte
narrativa de las propuestas de Consultoria E3 (Queretaro, Mexico). No eres vendedor: eres
diagnosticador. Tu trabajo es explicar por que la inversion tiene sentido, nunca decidir
cuanto cuesta.
El documento que produces acompana a otro que ya existe: la cotizacion economica que el
asesor armo a mano. Ese documento manda en todo lo que sea dinero. El tuyo manda en el
porque.
# LOS PRINCIPIOS QUE NO SE NEGOCIAN
P1. NO TOCAS LOS NUMEROS. No calculas, propones ni mencionas precios, totales, IVA ni
descuentos de E3. Ni siquiera los ves. Para referirte a una partida usa su
refPartida (P01, P02...). El sistema inyecta los importes despues de ti.
Si escribes una cifra en pesos, solo puede ser el costo del problema DEL CLIENTE:
lo que le cuesta hoy no resolverlo. Nunca un precio nuestro.
P2. TODA AFIRMACION ES TRAZABLE. Cada cosa que digas sobre el cliente apunta a una cita
(C01...) o a un hecho (H01...). Si no tiene origen, no se escribe. No hay excepciones
y no importa lo razonable que suene.
P3. ETIQUETAS LA CERTEZA, y la diferencia importa:
- confirmado: el cliente lo dijo textualmente, o hay un dato duro.
- estimado: lo derivaste con un calculo explicito que puedes mostrar.
- por_validar: es hipotesis tuya y nadie la ha confirmado.
Presentar un estimado como confirmado es la forma mas rapida de perder una reunion:
basta que el cliente refute una cifra para que dude de todo el documento.
P4. LAS PALABRAS DEL CLIENTE SON SAGRADAS. Las citas van textuales, con sus muletillas si
hace falta. No las pulas ni les arregles la gramatica.
Bien: "se nos van los clientes porque nadie contesta el WhatsApp el fin de semana"
Mal: "oportunidades de mejora en la gestion omnicanal de la comunicacion"
P5. VENDES RESULTADO, NO HERRAMIENTA. Traduce toda capacidad tecnica a dinero, tiempo,
riesgo evitado o tranquilidad operativa.
Mal: "Configuracion de Google Ads con estructura SKAG y scripts de puja."
Bien: "Cada peso de pauta se dirige a las busquedas que si compran, en lugar de
repartirse entre terminos que solo generan clics."
P6. LO QUE NO INCLUYE VALE TANTO COMO LO QUE SI. Las exclusiones son especificas de ESTE
proyecto, sacadas de lo que se menciono en la reunion.
Mal: "No incluye servicios no mencionados."
Bien: "No incluye migrar el historico de 4 anos de pedidos que mencionaron; eso se
evalua como fase 2 cuando el sistema base este operando."
P7. LA INCERTIDUMBRE SE DECLARA, NO SE RELLENA. Si falta un dato, va a pendientes con su
etiqueta. Jamas lo inventas. Una propuesta con ocho pendientes honestos cierra mejor
que una con ocho cifras inventadas: el dia que el cliente refute un dato inventado, el
costo no lo paga el modelo, lo paga la marca.
P8. NO PROPONES SERVICIOS QUE EL ASESOR NO ELIGIO. Si detectas una necesidad que nuestro
catalogo cubre y no esta cotizada, va a notasInternas. Nunca al documento del cliente.
# EL SEMAFORO DEL DIAGNOSTICO
Cada hallazgo se clasifica por urgencia, y la clasificacion es parte del mensaje:
- rojo: problema critico. Le esta costando dinero hoy.
- ambar: area de mejora. Funciona, pero por debajo de lo que podria.
- azul: oportunidad. Algo que no esta haciendo y podria.
- verde: ventaja existente. Algo que el cliente YA hace bien.
El verde no es relleno ni cortesia: reconocer lo que el cliente hizo bien es lo que
convierte una propuesta en una conversacion entre pares en lugar de un regano. Buscalo
de verdad. Pero si no hay evidencia de alguno de los cuatro, omitelo antes que inventarlo.
# REGLAS DE CONTENIDO Y DE MARCA
1. Espanol de Mexico. Tono cercano y profesional. Tutea al cliente.
2. Prohibido el lenguaje corporativo vacio: sinergias, holistico, stakeholders,
ecosistema, disruptivo, robusto, potenciar, empoderar, solucion integral.
3. El CRM se llama Bucefalo. No menciones ningun otro CRM, por ningun motivo, aunque el
cliente haya nombrado uno en la reunion.
4. E3 ofrece UNICAMENTE servicios digitales. Si en la reunion pidieron marketing
tradicional, impresos o diseno para imprenta, va a exclusiones aclarando que no es un
servicio de E3.
5. Moneda MXN, IVA 16%, facturacion CFDI. Jamas montos en dolares ni en USD.
6. No prometas resultados garantizados de mercado: ni ventas, ni posicion numero uno en
Google, ni cantidad de prospectos. Prometes entregables, procesos y metricas de
seguimiento. La palabra "garantizado" no aparece sobre resultados de mercado.
7. No prometas soporte ilimitado ni mantenimiento gratuito indefinido.
8. No incluyas nombres de empleados del cliente, sueldos, ni temas legales, laborales o de
salud. Si la reunion los toco, se omiten del documento; si el asesor debe saberlo, va a
notasInternas.
# QUE HACES CUANDO EL MATERIAL ES POBRE
Este es el caso mas importante y el que peor se suele resolver.
Si la transcripcion es corta, vaga, o no tiene una sola cifra: NO inventas para rellenar.
Produces un documento honesto pocos hallazgos, varios marcados por_validar, montos en
null, y una lista larga y util de materiales y decisiones pendientes.
Ese documento es un exito, no un fracaso. Le dice al cliente exactamente que hace falta
para avanzar, y transfiere la responsabilidad del retraso a donde corresponde. Un hueco de
informacion declarado con claridad deja de ser un defecto y se vuelve contenido de valor.
Lo que NUNCA haces con material pobre: inventar una cifra plausible, atribuir al cliente
una frase que no dijo, o describir un dolor generico de la industria como si fuera suyo.
`.trim();
export interface ContextoEntrada {
transcripcion: string;
notas: string;
observaciones: string;
cliente: string;
empresa: string;
proyecto: string;
}
/** Capa 3: volatil. Cambia por cotizacion, nunca se cachea. */
export function bloqueContexto(e: ContextoEntrada): string {
const partes = [
`Cliente: ${e.cliente}${e.empresa ? ` (${e.empresa})` : ""}`,
`Proyecto: ${e.proyecto}`,
];
if (e.observaciones.trim()) {
partes.push(`\n## Observaciones que el asesor escribio para el cliente\n${e.observaciones.trim()}`);
}
if (e.notas.trim()) {
partes.push(`\n## Notas por partida\n${e.notas.trim()}`);
}
if (e.transcripcion.trim()) {
partes.push(`\n## Transcripcion de la reunion\n${e.transcripcion.trim()}`);
} else {
partes.push(
`\n## Transcripcion de la reunion\n(No se entrego transcripcion. Trabaja solo con lo de arriba, se generoso marcando por_validar, y puebla bien los pendientes.)`
);
}
return partes.join("\n");
}
export function mensajePaso1(e: ContextoEntrada): string {
return `${bloqueContexto(e)}
---
Extrae los hechos de este material y llama a registrar_hechos.
Reglas de este paso:
- Las citas van TEXTUALES. Copialas del material, no las reescribas. Si no hay nada
citable, devuelve el array vacio en vez de inventar una cita.
- Numera las citas C01, C02... y los hechos H01, H02...
- Cada hecho apunta a las citas que lo sostienen. Un hecho sin ninguna cita solo puede
ser "por_validar".
- En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre
propio, salvo que sea el interlocutor comercial.
- Si de alguna de estas listas no hay NADA que decir, devuelvela VACIA. Un array vacio
es la respuesta correcta; no metas "N/A", "ninguno" ni un guion para rellenar.
- En redFlags anota lo que deberia preocuparle al asesor: que pidan descuento antes de
entender el alcance, que no este presente quien decide, que no haya ninguna cifra del
problema, o un "hagan todo y luego vemos".`;
}
export function mensajePaso2(e: ContextoEntrada, hechos: Hechos, econ: DatosEconomicos): string {
const partidas = econ.partidas
.map((p) => `- ${p.refPartida}: ${p.nombre} (fase ${p.fase}, pago ${p.tipoPago})`)
.join("\n");
return `${bloqueContexto(e)}
## Hechos extraidos en el paso anterior
${JSON.stringify(hechos, null, 2)}
## Partidas que el asesor ya eligio
${partidas || "(ninguna)"}
(Las partidas van sin precio a proposito: tu no los necesitas y no debes mencionarlos.)
---
Diagnostica y llama a registrar_diagnostico.
Reglas de este paso:
- Reformula al problema de NEGOCIO. El cliente describe sintomas ("quiero una pagina
web"); tu nombras la enfermedad ("pierden prospectos porque no tienen a donde mandarlos
desde los anuncios").
- Intenta cubrir el semaforo completo, incluido al menos un verde. Si no hay evidencia de
algun color, omitelo: es mejor un semaforo incompleto que un hallazgo inventado.
- En valorProblema, si declaras un montoAnualMXN tienes que mostrar al menos dos
factores que lo expliquen, y la aritmetica debe cuadrar: el monto es el producto o la
suma de sus factores. Cada factor lleva su propia confianza si uno de ellos te lo
estas inventando (una tasa de conversion tipica, por ejemplo), marcalo por_validar
aunque los demas sean confirmados. Un solo factor inventado puede sostener toda la
cifra, y el asesor necesita saber cual es.
- Si NO tienes cifras para una dimension, OMITE montoAnualMXN y deja factores vacio.
No inventes factores de relleno para llenar el hueco: un array vacio dice la verdad,
"herramienta_actual=0 x canal=1" no dice nada.
- Recuerda P1: montoAnualMXN es lo que el problema le cuesta AL CLIENTE cada ano. No es
un precio de E3 ni tiene relacion con lo que cotizamos.
- Los resultados a lograr son de negocio y medibles. "Mejorar la presencia digital" no es
un resultado; "ningun mensaje sin respuesta en mas de 24 horas" si lo es.`;
}
export function mensajePaso3(
e: ContextoEntrada,
hechos: Hechos,
diag: Diagnostico,
econ: DatosEconomicos
): string {
const partidas = econ.partidas
.map(
(p) => `- ${p.refPartida}: ${p.nombre} (fase ${p.fase}, pago ${p.tipoPago}, entrega ${p.tiempoEntrega})`
)
.join("\n");
return `## Hechos
${JSON.stringify(hechos, null, 2)}
## Diagnostico
${JSON.stringify(diag, null, 2)}
## Partidas a describir
${partidas || "(ninguna)"}
---
Redacta la propuesta y llama a registrar_propuesta.
Reglas de este paso:
- citaDestacadaId debe ser el ID de una cita que YA exista en los hechos de arriba. No
escribas una cita nueva ni le corrijas la gramatica: se imprime literal.
- En alcance, una entrada por cada refPartida de la lista, traducida a resultado de
negocio. No menciones importes ni plazos de pago: el sistema los inyecta.
- Cada beneficio apunta al hallazgoId que resuelve. Si un beneficio no responde a ningun
hallazgo del diagnostico, borralo: es relleno.
- Minimo una exclusion, especifica de este proyecto y sacada de lo que se menciono.
- El backlog es lo que quedo fuera y merece registrarse. Va con la etiqueta de que NO
esta comprometido en esta propuesta.
- notasInternas es lo unico que el cliente no vera. Mete ahi las red flags, lo que el
asesor deberia confirmar antes de presentar, y cualquier servicio de nuestro catalogo
que creas que aplica y no este cotizado.`;
}
+231
View File
@@ -0,0 +1,231 @@
import { z } from "zod";
/**
* Contrato con el modelo. MiniMax no soporta structured outputs, asi que estos
* schemas viajan como `input_schema` de una herramienta y Zod valida la respuesta.
*
* REGLA ESTRUCTURAL: ningun schema de aqui contiene un campo de dinero DE E3.
* El modelo devuelve `refPartida` y prosa; los precios, totales e IVA los inyecta
* el codigo al renderizar. `montoAnualMXN` si existe, pero es lo que el problema
* le cuesta AL CLIENTE cada ano no un precio nuestro.
*/
const REF_PARTIDA = /^P\d{2}$/;
const ID_CITA = /^C\d{2}$/;
const ID_HECHO = /^H\d{2}$/;
const ID_HALLAZGO = /^D\d{2}$/;
export const CONFIANZA = ["confirmado", "estimado", "por_validar"] as const;
export const URGENCIA = ["rojo", "ambar", "azul", "verde"] as const;
export const DIMENSION_COSTO = ["dinero", "tiempo", "oportunidad", "error"] as const;
// ───────────────────── Paso 1: extraccion de hechos ─────────────────────
export const hechosSchema = z
.object({
citas: z
.array(
z.object({
id: z.string().regex(ID_CITA, "El id de cita debe ser C01, C02..."),
textoLiteral: z
.string()
.min(10)
.describe("Cita TEXTUAL del cliente, palabra por palabra. Nunca parafraseada ni corregida."),
quienLoDijo: z
.string()
.describe("Rol del interlocutor comercial ('el socio', 'quien atiende el WhatsApp'). No nombres de empleados."),
})
)
.max(30),
hechos: z
.array(
z.object({
id: z.string().regex(ID_HECHO, "El id de hecho debe ser H01, H02..."),
enunciado: z.string().min(10),
confianza: z.enum(CONFIANZA),
citas: z.array(z.string().regex(ID_CITA)).describe("IDs de las citas que sostienen este hecho."),
})
)
.max(40),
materialesPendientes: z
.array(z.object({ texto: z.string().min(3), bloqueaEntrega: z.boolean() }))
.max(15),
decisionesPendientes: z
.array(z.object({ texto: z.string().min(3), quienDecide: z.string() }))
.max(15),
mencionesFueraDeAlcance: z.array(z.string().min(3)).max(15),
redFlags: z
.array(z.object({ senal: z.string().min(3), severidad: z.enum(["baja", "media", "alta"]) }))
.max(10),
})
.strict();
// ─────────────── Paso 2: diagnostico y valoracion del problema ───────────────
export const diagnosticoSchema = z
.object({
hallazgos: z
.array(
z.object({
id: z.string().regex(ID_HALLAZGO, "El id de hallazgo debe ser D01, D02..."),
urgencia: z
.enum(URGENCIA)
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
// 140 y no 80: con 80 el modelo se pasaba y gastaba un reintento. Observado
// en 2 de 6 corridas contra datos reales.
titulo: z.string().min(3).max(140),
descripcion: z.string().min(20),
confianza: z.enum(CONFIANZA),
citas: z.array(z.string().regex(ID_CITA)),
hechos: z.array(z.string().regex(ID_HECHO)),
})
)
.min(1)
.max(12),
valorProblema: z.object({
dimensiones: z
.array(
z
.object({
tipo: z.enum(DIMENSION_COSTO),
descripcion: z.string().min(10),
// Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
// nivel extra no aportaba nada semantico y era donde el modelo se perdia:
// devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Observado
// en 3 de 6 corridas contra datos reales.
//
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
// modelo a inventar relleno. La exigencia se aplica solo si hay monto.
factores: z
.array(
z.object({
nombre: z.string(),
valor: z.number(),
// Sin este campo el modelo metia la incertidumbre dentro del nombre
// ("tasa_conversion (por_validar)=0.1"). Mejor dato que convencion.
confianza: z.enum(CONFIANZA),
})
)
.max(6),
// .default(null) y no solo .nullable(): verificado contra la API real que
// el modelo OMITE el campo en vez de mandar null, que es lo natural para
// un LLM. Exigirlo presente quemaba los tres intentos.
montoAnualMXN: z
.number()
.nullable()
.default(null)
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
confianza: z.enum(CONFIANZA),
hechos: z.array(z.string().regex(ID_HECHO)),
})
.superRefine((d, ctx) => {
if (d.montoAnualMXN !== null && d.factores.length < 2) {
ctx.addIssue({
code: "custom",
path: ["factores"],
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
});
}
})
)
.max(4),
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
}),
resultados: z
.array(
z.object({
enunciado: z.string().min(15),
metrica: z.string(),
lineaBase: z.string().nullable(),
periodoMedicion: z.string(),
})
)
.max(5),
})
.strict();
// ───────────────────────── Paso 3: redaccion final ─────────────────────────
export const redaccionSchema = z
.object({
hero: z.object({
titulo: z.string().min(5).max(90),
subtitulo: z.string().min(20).max(400),
}),
citaDestacadaId: z
.string()
.regex(ID_CITA)
.describe("ID de una cita YA extraida en el paso 1. Se imprime literal; no la reescribas."),
alcance: z
.array(
z.object({
refPartida: z.string().regex(REF_PARTIDA, "Debe ser P01, P02..."),
descripcionResultado: z
.string()
.min(20)
.describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."),
})
)
// 120 y no 40: una cotizacion real (UJ2606UR001) tiene 58 partidas, asi que el
// tope de 40 garantizaba un rechazo de Zod y un reintento desperdiciado en cada
// corrida. Observado en 6 de 6. El tope solo esta para acotar una respuesta
// desbocada; la completitud del documento ya no depende de este array, porque el
// generador recorre las partidas de la cotizacion.
.max(120),
beneficios: z
.array(
z.object({
// 70 y no 40: se pasaba en 2 de 3 vueltas contra datos reales.
etiqueta: z.string().min(3).max(70),
texto: z.string().min(20),
hallazgoId: z
.string()
.regex(ID_HALLAZGO)
.describe("El hallazgo que este beneficio resuelve. Si no responde a ninguno, no lo incluyas."),
})
)
.max(8),
exclusiones: z
.array(
z.object({
texto: z.string().min(15).describe("Especifica de ESTE proyecto, nunca generica."),
razon: z.string().min(5),
})
)
.min(1)
.max(10),
backlogEvolucion: z
.array(z.object({ problema: z.string().min(10), momentoSugerido: z.string() }))
.max(8),
notasInternas: z
.array(z.string().min(5))
.max(10)
.describe("SOLO para el asesor. Nunca se imprime en el documento del cliente."),
})
.strict();
export type Hechos = z.infer<typeof hechosSchema>;
export type Diagnostico = z.infer<typeof diagnosticoSchema>;
export type Redaccion = z.infer<typeof redaccionSchema>;
/** El objeto completo que se persiste, se edita y se renderiza. */
export interface PropuestaConsultiva {
hechos: Hechos;
diagnostico: Diagnostico;
redaccion: Redaccion;
}
/** Schema del objeto completo, para validar lo que el asesor edita a mano. */
export const propuestaCompletaSchema = z.object({
hechos: hechosSchema,
diagnostico: diagnosticoSchema,
redaccion: redaccionSchema,
});
/**
* Zod -> JSON Schema para el `input_schema` de la herramienta.
* zod 4.3.6 trae `z.toJSONSchema` nativo: no hace falta `zod-to-json-schema`.
* Se borra `$schema` porque el endpoint no lo espera.
*/
export function aJsonSchema(schema: z.ZodType): Record<string, unknown> {
const js = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
delete js.$schema;
return js;
}
+252
View File
@@ -0,0 +1,252 @@
import type { PropuestaConsultiva } from "./schemas";
import type { DatosEconomicos } from "./economia";
/**
* Validacion post-generacion. Corre en codigo, nunca en la IA.
*
* R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son una
* ENTRADA que el modelo recibe en el prompt y puede copiar literalmente en cualquier
* campo de prosa. Comparar el bloque interno que produjo el modelo contra la salida
* no detecta eso.
*/
export interface Aviso {
regla: string;
severidad: "bloqueante" | "advertencia";
mensaje: string;
ruta?: string;
}
/** Normaliza para comparar citas contra las fuentes: sin acentos, en minusculas, sin
* puntuacion y con espacios colapsados. Laxa a proposito, para sobrevivir al ruido de
* transcripcion automatica sin volverse inutil. */
export function normalizar(s: string): string {
return s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
const OTRO_CRM = /\b(hubspot|salesforce|pipedrive|zoho|go\s*high\s*level|gohighlevel|clientify|kommo|monday\s*crm)\b/i;
const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(ventas|resultados|posici)/i;
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i;
const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i;
/**
* TODO el texto que termina impreso en el documento del cliente.
*
* Esta lista tiene que cubrir exactamente lo que dibuja `generarPropuestaPDF`. Es la
* unica fuente de los filtros de marca, moneda, garantias y PII: un campo que se
* imprime y no esta aqui llega al cliente sin que nadie lo mire.
*
* Faltaban seis, todos impresos: el texto y el autor de la cita destacada, la metrica
* y el periodo de cada resultado, quien decide cada pendiente, y el momento sugerido
* del backlog. Si agregas algo al PDF, agregalo aqui en el mismo cambio.
*/
export function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
const out: { ruta: string; texto: string }[] = [
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
];
// La cita destacada se imprime literal, con su autor (pdf.ts, seccion Diagnostico).
const destacada = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
if (destacada) {
out.push({ ruta: `citas.${destacada.id}.textoLiteral`, texto: destacada.textoLiteral });
out.push({ ruta: `citas.${destacada.id}.quienLoDijo`, texto: destacada.quienLoDijo });
}
p.diagnostico.hallazgos.forEach((h, i) => {
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
});
p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
);
p.diagnostico.resultados.forEach((r, i) => {
out.push({ ruta: `resultados[${i}].enunciado`, texto: r.enunciado });
out.push({ ruta: `resultados[${i}].metrica`, texto: r.metrica });
out.push({ ruta: `resultados[${i}].periodoMedicion`, texto: r.periodoMedicion });
});
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}].problema`, texto: b.problema });
out.push({ ruta: `backlog[${i}].momentoSugerido`, texto: b.momentoSugerido });
});
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto }));
p.hechos.decisionesPendientes.forEach((d, i) => {
out.push({ ruta: `decisiones[${i}].texto`, texto: d.texto });
out.push({ ruta: `decisiones[${i}].quienDecide`, texto: d.quienDecide });
});
return out;
}
function ngramas(texto: string, n: number): Set<string> {
const palabras = normalizar(texto).split(" ").filter(Boolean);
const out = new Set<string>();
for (let i = 0; i + n <= palabras.length; i++) out.add(palabras.slice(i, i + n).join(" "));
return out;
}
export function validarPropuesta(
p: PropuestaConsultiva,
econ: DatosEconomicos,
fuentes: { textoCliente: string; textoInterno: string }
): Aviso[] {
const avisos: Aviso[] = [];
const visible = textoVisible(p);
// ── R0 (bloqueante): el texto interno del asesor no puede aparecer en el documento
// del cliente. Es entrada, no salida; ninguna otra regla lo mira.
if (fuentes.textoInterno.trim()) {
const internos = ngramas(fuentes.textoInterno, 7);
if (internos.size) {
for (const { ruta, texto } of visible) {
for (const g of ngramas(texto, 7)) {
if (internos.has(g)) {
avisos.push({
regla: "R0",
severidad: "bloqueante",
ruta,
mensaje: `Texto de las notas internas aparece en el documento del cliente: "${g}"`,
});
break;
}
}
}
}
}
// ── R2 (bloqueante): ninguna partida inventada.
const refsReales = new Set(econ.partidas.map((x) => x.refPartida));
for (const a of p.redaccion.alcance) {
if (!refsReales.has(a.refPartida)) {
avisos.push({
regla: "R2",
severidad: "bloqueante",
ruta: `alcance ${a.refPartida}`,
mensaje: `La partida ${a.refPartida} no existe en la cotizacion.`,
});
}
}
// Advertencia si el modelo se salto partidas reales.
const refsUsadas = new Set(p.redaccion.alcance.map((a) => a.refPartida));
for (const real of econ.partidas) {
if (!refsUsadas.has(real.refPartida)) {
avisos.push({
regla: "R2b",
severidad: "advertencia",
ruta: real.refPartida,
mensaje: `La partida "${real.nombre}" (${real.refPartida}) esta cotizada pero no aparece descrita.`,
});
}
}
// ── R3 (bloqueante): la cita destacada existe y es literal.
const cita = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
if (!cita) {
avisos.push({
regla: "R3",
severidad: "bloqueante",
mensaje: `citaDestacadaId ${p.redaccion.citaDestacadaId} no corresponde a ninguna cita extraida.`,
});
} else if (fuentes.textoCliente.trim()) {
if (!normalizar(fuentes.textoCliente).includes(normalizar(cita.textoLiteral))) {
avisos.push({
regla: "R3",
severidad: "bloqueante",
ruta: cita.id,
mensaje: `La cita destacada no aparece literal en las fuentes: "${cita.textoLiteral.slice(0, 70)}..."`,
});
}
}
// ── R4 (advertencia): un hallazgo "confirmado" necesita respaldo.
p.diagnostico.hallazgos.forEach((h, i) => {
if (h.confianza === "confirmado" && h.citas.length + h.hechos.length === 0) {
avisos.push({
regla: "R4",
severidad: "advertencia",
ruta: `hallazgos[${i}]`,
mensaje: `"${h.titulo}" se declara confirmado sin citas ni hechos. Deberia ser por_validar.`,
});
}
});
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
const m = d.montoAnualMXN;
if (m === null) return;
const producto = d.factores.reduce((a, f) => a * f.valor, 1);
const suma = d.factores.reduce((a, f) => a + f.valor, 0);
const base = Math.max(Math.abs(m), 1);
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
if (!cuadra) {
avisos.push({
regla: "R5",
severidad: "bloqueante",
ruta: `valorProblema.dimensiones[${i}]`,
mensaje: `montoAnualMXN ${m} no cuadra con sus factores (producto ${producto}, suma ${suma}).`,
});
}
});
// ── R5b (advertencia): el valor anual manda sobre el ratio precio/valor, y ese ratio
// es el que te dice si subcotizaste. Una cifra que descansa solo en factores
// estimados puede estar inflada un orden de magnitud sin que R5 lo note: R5 verifica
// que los factores multipliquen al monto, no que no FALTE un factor.
// Observado contra la API real: el modelo produjo 3,120,000 omitiendo la tasa de
// conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la
// aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10.
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
if (d.montoAnualMXN === null) return;
const confirmados = d.factores.filter((f) => f.confianza === "confirmado").length;
if (confirmados === 0) {
avisos.push({
regla: "R5b",
severidad: "advertencia",
ruta: `valorProblema.dimensiones[${i}]`,
mensaje:
`La cifra de ${d.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` +
`Revisa el desglose antes de fiarte del ratio precio/valor.`,
});
}
});
// ── R6 (advertencia): referencias colgantes entre pasos.
const idsHallazgo = new Set(p.diagnostico.hallazgos.map((h) => h.id));
p.redaccion.beneficios.forEach((b, i) => {
if (!idsHallazgo.has(b.hallazgoId)) {
avisos.push({
regla: "R6",
severidad: "advertencia",
ruta: `beneficios[${i}]`,
mensaje: `El beneficio apunta al hallazgo ${b.hallazgoId}, que no existe.`,
});
}
});
// ── Filtro de contenido sobre lo visible ──
for (const { ruta, texto } of visible) {
if (OTRO_CRM.test(texto))
avisos.push({ regla: "MARCA", severidad: "bloqueante", ruta, mensaje: "Menciona un CRM que no es Bucefalo." });
if (MONTO_USD.test(texto))
avisos.push({ regla: "MONEDA", severidad: "bloqueante", ruta, mensaje: "Contiene un monto o referencia en USD." });
if (PROMESA_GARANTIA.test(texto))
avisos.push({ regla: "GARANTIA", severidad: "bloqueante", ruta, mensaje: "Promete un resultado garantizado." });
if (TEMA_SENSIBLE.test(texto))
avisos.push({ regla: "PII", severidad: "advertencia", ruta, mensaje: "Menciona un tema laboral, salarial o de salud. Revisalo antes de enviar." });
}
return avisos;
}
export function hayBloqueantes(avisos: Aviso[]): boolean {
return avisos.some((a) => a.severidad === "bloqueante");
}
+2
View File
@@ -43,6 +43,7 @@ export const cotizacionPostSchema = z.object({
esDoble: z.boolean().optional(), esDoble: z.boolean().optional(),
opciones: opcionesSchema, opciones: opcionesSchema,
observaciones: z.string(), observaciones: z.string(),
observacionesInternas: z.string().optional(),
asesorId: z.string().min(1), asesorId: z.string().min(1),
cliente: z.object({ cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"), nombre: z.string().min(1, "Cliente nombre es requerido"),
@@ -73,6 +74,7 @@ export const cotizacionPutSchema = z.object({
esDoble: z.boolean().optional(), esDoble: z.boolean().optional(),
opciones: opcionesSchema, opciones: opcionesSchema,
observaciones: z.string(), observaciones: z.string(),
observacionesInternas: z.string().optional(),
cliente: z.object({ cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"), nombre: z.string().min(1, "Cliente nombre es requerido"),
empresa: z.string(), empresa: z.string(),
+2
View File
@@ -42,6 +42,7 @@ export interface CotizacionDraft {
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
servicios: ServicioSeleccionado[]; servicios: ServicioSeleccionado[];
observaciones: string; observaciones: string;
observacionesInternas: string;
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion. // Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
esDoble: boolean; esDoble: boolean;
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion }; opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
@@ -79,6 +80,7 @@ const initialDraft: CotizacionDraft = {
planBucefaloNivel: null, planBucefaloNivel: null,
servicios: [], servicios: [],
observaciones: "", observaciones: "",
observacionesInternas: "",
esDoble: false, esDoble: false,
opciones: {}, opciones: {},
}; };