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]>
512 lines
29 KiB
Markdown
512 lines
29 KiB
Markdown
# 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.
|