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]>
29 KiB
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. 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.16enpdf-generator.ts:328-329yexcel-builder.ts:377-378, ignorandoIVA_RATE. - El flag
Cotizacion.incluirIvano 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.
- Aplicar
require_auth(api/app/auth.py:45) al endpointPOST /mcpenapi/main.py:107. - 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íaCotizacionResponse(api/app/models/cotizacion.py:130-152). - Auditar el resto de
server.pyen busca de otrosSELECT *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:
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;— sinUPDATE.- 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 declaraobservacionesInternas— 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.
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:
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:
- Forma Zod — siempre
superRefine(IDs únicos, refs internas) — solo si 1 pasa- Validación cruzada de runtime (
refPartidareales) — solo si 2 pasa - 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_incorrectahay que emitir untool_resultconis_error: truepor cadatool_usedel turno antes del texto de corrección; un mensaje de usuario plano después de untool_useproduce 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
/mcpya no existe (§0.2). Se cumplió antes con 401 verificado; la retirada lo supera. api/requirements.txtfija versiones exactas y el API arranca sin el paquetemcp(§0.3).- El total del PDF y el del Excel coinciden dígito por dígito en cotizaciones reales.
- Una cotización con
incluirIva: falseno 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.