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]>
This commit is contained in:
urieljareth
2026-07-28 14:20:03 -06:00
co-authored by Claude Opus 5
parent 26e6d3a9bf
commit 0ff783fd65
3 changed files with 1357 additions and 11 deletions
@@ -0,0 +1,454 @@
# 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:** aprobado para planeación
> **Base verificada:** commit `26e6d3a9`
---
## 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
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.** Solo cuando A lleve días estable:
```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:
- `POST /mcp` sin credencial devuelve 401; con credencial, `obtener_cotizacion` no expone el campo interno.
- 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.