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

512 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Propuesta consultiva con IA — 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.