Compare commits
19
Commits
26e6d3a9bf
...
main
@@ -28,3 +28,11 @@ SEED_ADMIN_NAME=Administrador
|
|||||||
SEED_ASESOR_EMAIL=
|
SEED_ASESOR_EMAIL=
|
||||||
SEED_ASESOR_PASSWORD=
|
SEED_ASESOR_PASSWORD=
|
||||||
SEED_ASESOR_NAME=
|
SEED_ASESOR_NAME=
|
||||||
|
|
||||||
|
# --- Propuesta consultiva con IA (MiniMax) ---
|
||||||
|
# Clave del panel de MiniMax. NUNCA se commitea: .env esta en .gitignore.
|
||||||
|
# Ojo con el prefijo: NO uses ANTHROPIC_API_KEY. El SDK la lee por su cuenta y
|
||||||
|
# mandaria la clave de MiniMax a api.anthropic.com.
|
||||||
|
MINIMAX_API_KEY=
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||||
|
MINIMAX_MODEL=MiniMax-M3
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
| `npm run dev` | Start Next.js dev server on port 3000 |
|
| `npm run dev` | Start Next.js dev server on port 3000 |
|
||||||
| `npm run build` | Production build (runs TypeScript check) |
|
| `npm run build` | Production build (runs TypeScript check) |
|
||||||
| `npm run lint` | ESLint (flat config, eslint-config-next) |
|
| `npm run lint` | ESLint (flat config, eslint-config-next) |
|
||||||
| `npx tsx prisma/seed.ts` | Run seed (upserts all data, idempotent) |
|
| `npm run db:seed` | Run seed (upserts all data, idempotent) |
|
||||||
| `npx prisma migrate dev` | Create/apply migration |
|
| `npm run db:migrate` | Create/apply migration (`prisma migrate dev`) |
|
||||||
| `npx prisma generate` | Regenerate Prisma client |
|
| `npm run db:generate` | Regenerate Prisma client |
|
||||||
| `npx prisma studio` | Prisma Studio GUI |
|
| `npm run db:studio` | Prisma Studio GUI |
|
||||||
|
|
||||||
|
**There is no test suite** — no runner, no test files, in either backend. Verification is `npm run build` (typechecks) + `npm run lint`. Don't claim a change is verified on the strength of a build alone; exercise the affected route or page.
|
||||||
|
|
||||||
**Windows environment.** Use `start.bat` / `stop.bat` to manage Docker PostgreSQL + Next.js together. PowerShell is the shell. Paths with brackets (e.g. `[id]`) require `-LiteralPath` in PowerShell, not `-Path`.
|
**Windows environment.** Use `start.bat` / `stop.bat` to manage Docker PostgreSQL + Next.js together. PowerShell is the shell. Paths with brackets (e.g. `[id]`) require `-LiteralPath` in PowerShell, not `-Path`.
|
||||||
|
|
||||||
**Two backends, one database.** The Next.js app (`src/`, port 3000) and a standalone Python FastAPI service (`api/`, port 8000) both talk to the same PostgreSQL DB. Prisma owns the schema/migrations; the Python API reads/writes the same tables independently. `docker-compose.yml` runs `postgres` + the `api` service; Next.js is run separately via `npm run dev` / `start.bat`.
|
**Two backends, one database.** The Next.js app (`src/`, port 3000) and a standalone Python FastAPI service (`api/`, port 8000) both talk to the same PostgreSQL DB. Prisma owns the schema/migrations; the Python API reads/writes the same tables independently. `docker-compose.yml` runs `postgres` + the `api` service; Next.js is run separately via `npm run dev` / `start.bat`.
|
||||||
|
|
||||||
|
**Three compose files, and two of them are the same file.** `docker-compose.yaml` is a byte-identical copy of `docker-compose.coolify.yml` — it exists only so Coolify's default detection finds the production stack. Local dev is `docker-compose.yml` (postgres with port 5432 published + api). Because `.yml` and `.yaml` both exist, a bare `docker compose` warns about ambiguity before resolving to `docker-compose.yml`; pass `-f docker-compose.yml` explicitly, as `start.bat` does. **When you edit the Coolify stack, edit both `docker-compose.coolify.yml` and `docker-compose.yaml`** or Coolify deploys the stale copy.
|
||||||
|
|
||||||
## Prisma 7 — Critical Gotchas
|
## Prisma 7 — Critical Gotchas
|
||||||
|
|
||||||
- **Prisma client is NOT at `@prisma/client`.** It's generated to `src/generated/prisma/` and imported as `@/generated/prisma/client`.
|
- **Prisma client is NOT at `@prisma/client`.** It's generated to `src/generated/prisma/` and imported as `@/generated/prisma/client`.
|
||||||
@@ -46,6 +50,25 @@
|
|||||||
- **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly).
|
- **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly).
|
||||||
- **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical.
|
- **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical.
|
||||||
|
|
||||||
|
### Charge models (`ServicioCotizado.modeloCobro`)
|
||||||
|
|
||||||
|
A quoted line item is `fijo` (default), `horas`, `retainer`, or `demanda` — see `MODELOS_COBRO` in `calculators.ts`. The time-based three are `MODELOS_COBRO_TIEMPO`. Supporting fields: `esPersonalizado`, `horas`, `tarifaHora`, `montoMinimo`, `horasIncluidas`. `TARIFA_HORA_DEFAULT = 700`.
|
||||||
|
|
||||||
|
**`precio` is always the authoritative total.** For retainer and demanda lines, `horas × tarifaHora` is display/comparison metadata only — never re-derive a total from it. `calcularTotalesOpcion` and the PDF/Excel builders all sum `precio`.
|
||||||
|
|
||||||
|
### Doble propuesta (two-option quotes)
|
||||||
|
|
||||||
|
`Cotizacion.esDoble` turns one quote into two comparable proposals. Each `ServicioCotizado.opcion` is `"1"`, `"2"`, or `"ambas"` (shared by both). `opcionesMetadata` (Json) holds per-option `titulo` / `descripcion` / `noIncluye` (`MetaOpcion`). `calcularTotalesOpcion(servicios, "1" | "2")` sums lines matching that option **plus** all `"ambas"` lines. Anything that renders or totals a quote must handle both the single and double shape.
|
||||||
|
|
||||||
|
### Registro de horas (hours log → payment notes)
|
||||||
|
|
||||||
|
`RegistroHoras` tracks worked time against a quote so the client can be billed for it. Only offered when `esCotizacionPorTiempo(servicios)` is true (i.e. some line uses a time-based `modeloCobro`).
|
||||||
|
|
||||||
|
- `estadoPago` is `"por_pagar"` (default) or `"pagada"`, with `fechaPago` stamped on transition. The two buckets must stay separated in totals — pending is what goes on the payment note, paid is history. `resumenPagoHoras` / `esPagada` in `calculators.ts` are the only place that logic should live.
|
||||||
|
- Routes: `GET|POST /api/cotizaciones/[id]/horas` (GET takes `?from=&to=` day filters), `PATCH|DELETE .../horas/[registroId]`, and `POST .../nota-horas` which renders the PDF **server-side** via `src/lib/nota-horas-pdf.ts` (no browser URL).
|
||||||
|
- Hours are entered as `horaInicio`/`horaFin` strings and converted by `calcularHorasRango`. Dates come in as `YYYY-MM-DD` and must go through `fechaRegistroDesdeISO` to avoid UTC off-by-one — don't `new Date(iso)` directly.
|
||||||
|
- Grouping for display/PDF: `ModoAgrupacion` = `detalle | dia | semana | mes` via `periodoAgrupacion`.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
- **JWT sessions** (`src/lib/auth.ts`) signed with `jose` (HS256, 7-day expiry), stored in the `cotizador-session` httpOnly cookie. `JWT_SECRET` env var is **required** (throws at startup if missing).
|
- **JWT sessions** (`src/lib/auth.ts`) signed with `jose` (HS256, 7-day expiry), stored in the `cotizador-session` httpOnly cookie. `JWT_SECRET` env var is **required** (throws at startup if missing).
|
||||||
@@ -66,27 +89,39 @@
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
app/
|
app/
|
||||||
(app)/ # Authed route group: dashboard, cotizaciones, clientes, catalogo, configuracion (has its own layout.tsx + Sidebar)
|
(app)/ # Authed route group: dashboard, cotizaciones, clientes, catalogo, configuracion (has its own layout.tsx + Sidebar + DialogProvider)
|
||||||
api/ # Next.js route handlers (REST): auth, catalogo, categorias, cotizaciones, configuracion, paquetes, export, import
|
api/ # Next.js route handlers (REST): auth, catalogo, categorias, cotizaciones (+ horas, nota-horas, precio),
|
||||||
|
# configuracion, paquetes, export, import, health
|
||||||
login/ # Public login page
|
login/ # Public login page
|
||||||
components/ # CotizacionForm, ExportButtons, EstadoBadge, layout/Sidebar
|
components/ # CotizacionForm (~1.4k lines), ExportButtons, EstadoBadge, layout/Sidebar, ui/DialogProvider
|
||||||
lib/ # auth, db, store, calculators, pdf-generator, schemas, config-helpers
|
lib/ # auth, db, store, calculators, schemas, config-helpers,
|
||||||
|
# pdf-generator (quote PDF), nota-horas-pdf (hours-note PDF), excel-builder
|
||||||
generated/prisma/ # Prisma client output (gitignored)
|
generated/prisma/ # Prisma client output (gitignored)
|
||||||
prisma/
|
prisma/
|
||||||
schema.prisma # 13 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete,
|
schema.prisma # 14 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete,
|
||||||
# ServicioCatalogo, ServicioPaquete, ServicioCotizado, PlanBucefaloCotizacion,
|
# ServicioCatalogo, ServicioPaquete, ServicioCotizado, PlanBucefaloCotizacion,
|
||||||
# Configuracion, Bono, FinanciamientoPlan)
|
# RegistroHoras, Configuracion, Bono, FinanciamientoPlan)
|
||||||
seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts
|
seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts
|
||||||
migrations/ # 3 migrations
|
migrations/ # 10 migrations
|
||||||
api/ # Standalone Python FastAPI + MCP server (see below)
|
api/ # Standalone Python FastAPI service (see below)
|
||||||
|
docs/ # Business/product notes (Spanish), not code docs
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Zustand store** (`src/lib/store.ts`) holds the cotización draft. Used by both the new (`cotizaciones/nueva`) and edit (`cotizaciones/[id]/editar`) pages, both of which render `CotizacionForm.tsx`.
|
- **Zustand store** (`src/lib/store.ts`) holds the cotización draft. Used by both the new (`cotizaciones/nueva`) and edit (`cotizaciones/[id]/editar`) pages, both of which render `CotizacionForm.tsx`.
|
||||||
- **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body).
|
- **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body).
|
||||||
|
- **Business logic belongs in `calculators.ts`**, not in components or route handlers. It's the shared source of truth for totals, charge models, hours, phases, and formatting — and the file the Python `calculators.py` mirrors.
|
||||||
|
|
||||||
|
## UI Conventions
|
||||||
|
|
||||||
|
- **Never use the browser's native `confirm()` / `alert()` / `prompt()`.** They render as "«domain» dice…" and break the brand. Use the platform's own dialogs: `useConfirm()`, `usePrompt()`, `useToast()` from `@/components/ui/DialogProvider`, mounted once in `src/app/(app)/layout.tsx`. `confirm` and `prompt` return promises (`boolean` / `string | null`); pass `danger: true` for destructive actions.
|
||||||
|
- Everything user-facing is in **Spanish**. Code identifiers are Spanish too (`cotizacion`, `servicio`, `horas`) — match the surrounding naming rather than introducing English terms.
|
||||||
|
- Icons come from `lucide-react`. Colors use the CSS custom properties from `globals.css` (`bg-card-bg`, `border-border`, `text-primary`, `text-muted`), not hardcoded Tailwind palette values.
|
||||||
|
|
||||||
## Python API (`api/`) — Optional Second Backend
|
## Python API (`api/`) — Optional Second Backend
|
||||||
|
|
||||||
- **FastAPI app** (`api/main.py`) exposing the same domain as REST, **plus an MCP server at `/mcp`** for AI agents (n8n, Claude, ChatGPT). Tools/resources defined in `api/app/mcp/`.
|
- **FastAPI app** (`api/main.py`) exposing the same domain as REST, for n8n and other integrations.
|
||||||
|
- **The MCP server was removed on 2026-07-28.** It was unused, its transport had been broken for some time (it built a `StreamableHTTPServerTransport` per request with no session handling and returned 500 even with valid credentials), and its unpinned SDK took the whole API down in production when `mcp` jumped to 2.0.0. To revive it: `git show edb500f5:api/app/mcp/server.py`.
|
||||||
|
- **`api/requirements.txt` pins exact versions on purpose.** That outage is why. Bump a dependency deliberately — never by accident on a redeploy.
|
||||||
- Auth: **API key** (`X-API-Key` or `Authorization: Bearer`) for agents; JWT for human login. Routers in `api/app/routers/`, business logic in `api/app/services/` (its own `calculators.py`, `pdf_generator.py`, `excel_generator.py` — mirror the TS versions).
|
- Auth: **API key** (`X-API-Key` or `Authorization: Bearer`) for agents; JWT for human login. Routers in `api/app/routers/`, business logic in `api/app/services/` (its own `calculators.py`, `pdf_generator.py`, `excel_generator.py` — mirror the TS versions).
|
||||||
- Run: `cd api && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`. Swagger at `/docs`. Full endpoint reference in `api/COTIZADOR_API_SKILL.md`.
|
- Run: `cd api && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`. Swagger at `/docs`. Full endpoint reference in `api/COTIZADOR_API_SKILL.md`.
|
||||||
- It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma).
|
- It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ y financiamiento opcional. Exporta a PDF y Excel.
|
|||||||
- **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`)
|
- **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`)
|
||||||
- **Base de datos:** PostgreSQL 16 (vía Docker)
|
- **Base de datos:** PostgreSQL 16 (vía Docker)
|
||||||
- **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs`
|
- **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs`
|
||||||
- **API alterna:** servicio Python FastAPI + servidor MCP en [`api/`](api/) (para n8n / agentes de IA)
|
- **API alterna:** servicio Python FastAPI en [`api/`](api/) (para n8n / integraciones)
|
||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ npm run dev # http://localhost:3000
|
|||||||
```bash
|
```bash
|
||||||
cd api
|
cd api
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
uvicorn main:app --reload --port 8000 # Swagger en /docs, MCP en /mcp
|
uvicorn main:app --reload --port 8000 # Swagger en /docs
|
||||||
```
|
```
|
||||||
|
|
||||||
Referencia completa de endpoints en [`api/COTIZADOR_API_SKILL.md`](api/COTIZADOR_API_SKILL.md).
|
Referencia completa de endpoints en [`api/COTIZADOR_API_SKILL.md`](api/COTIZADOR_API_SKILL.md).
|
||||||
@@ -89,7 +89,8 @@ El stack de producción está en [docker-compose.coolify.yml](docker-compose.coo
|
|||||||
|
|
||||||
- No expongas el puerto 5432: los servicios se comunican por la red interna del compose.
|
- No expongas el puerto 5432: los servicios se comunican por la red interna del compose.
|
||||||
- El volumen `postgres_data` persiste la base de datos entre deploys. No lo borres.
|
- El volumen `postgres_data` persiste la base de datos entre deploys. No lo borres.
|
||||||
- El servidor MCP queda en `https://<dominio-api>/mcp` (auth por `X-API-Key`).
|
- El API expone REST en `https://<dominio-api>` con auth por `X-API-Key` o JWT. El servidor MCP se retiro el 2026-07-28 (ver `AGENTS.md`).
|
||||||
|
- Las versiones de `api/requirements.txt` estan fijas a proposito: un rango abierto dejo entrar `mcp` 2.0.0 y tumbo el API en produccion. Sube dependencias a proposito, no al redesplegar.
|
||||||
- La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad.
|
- La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad.
|
||||||
|
|
||||||
## Documentación para agentes
|
## Documentación para agentes
|
||||||
|
|||||||
+10
-23
@@ -418,32 +418,19 @@ granTotal = totalMensual × meses
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP Integration
|
## MCP Integration — removed (2026-07-28)
|
||||||
|
|
||||||
MCP server at `/mcp` for OpenClaw, Claude, ChatGPT.
|
The `/mcp` endpoint and the `api/app/mcp/` package no longer exist. Agents should use
|
||||||
|
the REST endpoints documented above, authenticating with `X-API-Key` or a Bearer JWT.
|
||||||
|
|
||||||
### Tools
|
It was removed for three reasons: nobody was using it; its transport had been broken
|
||||||
| Tool | Description |
|
for some time (it constructed a `StreamableHTTPServerTransport` per request with no
|
||||||
|------|-------------|
|
session handling, returning 500 even with valid credentials); and its unpinned SDK
|
||||||
| `buscar_servicios` | Search catalog by phase, payment type, category, text |
|
took the entire API down in production when `mcp` released 2.0.0 and dropped
|
||||||
| `crear_cotizacion` | Create complete quotation in one call |
|
`Server.list_tools()`.
|
||||||
| `obtener_cotizacion` | Get quotation details |
|
|
||||||
| `listar_cotizaciones` | List with filters |
|
|
||||||
| `cambiar_estado_cotizacion` | Change status |
|
|
||||||
| `actualizar_precio_servicio` | Adjust service price |
|
|
||||||
| `duplicar_cotizacion` | Clone as draft |
|
|
||||||
| `calcular_financiamiento` | Calculate payments |
|
|
||||||
| `generar_pdf_cotizacion` | Generate PDF |
|
|
||||||
| `obtener_configuracion` | Company config |
|
|
||||||
| `listar_bonos` | Available bonuses |
|
|
||||||
| `listar_planes_bucefalo` | CRM plans |
|
|
||||||
|
|
||||||
### Resources
|
To bring it back: `git show edb500f5:api/app/mcp/server.py`. Pin the SDK to the 1.x
|
||||||
| Resource | Description |
|
series and fix the transport before mounting it again.
|
||||||
|----------|-------------|
|
|
||||||
| `cotizador://servicios` | Full catalog |
|
|
||||||
| `cotizador://categorias` | Categories |
|
|
||||||
| `cotizador://configuracion` | Company config |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,453 +0,0 @@
|
|||||||
"""MCP Server for the Cotizador E3 API.
|
|
||||||
|
|
||||||
Provides MCP tools and resources for AI agents (OpenClaw, Claude, ChatGPT, etc.)
|
|
||||||
to interact with the quotation system.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from mcp.server import Server
|
|
||||||
from mcp.types import Resource, TextContent, Tool
|
|
||||||
|
|
||||||
from app.database import get_pool
|
|
||||||
from app.mcp.tools import RESOURCES, TOOLS
|
|
||||||
from app.services.calculators import (
|
|
||||||
BONOS,
|
|
||||||
PLANES_BUCEFALO,
|
|
||||||
calcular_financiamiento,
|
|
||||||
bucefalo_precio,
|
|
||||||
)
|
|
||||||
|
|
||||||
server = Server("cotizador-e3")
|
|
||||||
|
|
||||||
|
|
||||||
@server.list_tools()
|
|
||||||
async def list_tools() -> list[Tool]:
|
|
||||||
return [
|
|
||||||
Tool(
|
|
||||||
name=t["name"],
|
|
||||||
description=t["description"],
|
|
||||||
inputSchema=t["inputSchema"],
|
|
||||||
)
|
|
||||||
for t in TOOLS
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@server.list_resources()
|
|
||||||
async def list_resources() -> list[Resource]:
|
|
||||||
return [
|
|
||||||
Resource(
|
|
||||||
uri=r["uri"],
|
|
||||||
name=r["name"],
|
|
||||||
description=r["description"],
|
|
||||||
mimeType=r["mimeType"],
|
|
||||||
)
|
|
||||||
for r in RESOURCES
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@server.read_resource()
|
|
||||||
async def read_resource(uri: str) -> str:
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
if uri == "cotizador://servicios":
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
|
|
||||||
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
|
|
||||||
s.variante, s.activo, s.orden,
|
|
||||||
c.nombre as categoria_nombre
|
|
||||||
FROM "ServicioCatalogo" s
|
|
||||||
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
|
|
||||||
WHERE s.activo = true
|
|
||||||
ORDER BY s.fase ASC, s.orden ASC"""
|
|
||||||
)
|
|
||||||
return json.dumps([dict(r) for r in rows], default=str)
|
|
||||||
|
|
||||||
elif uri == "cotizador://categorias":
|
|
||||||
rows = await conn.fetch(
|
|
||||||
'SELECT id, nombre, descripcion, color, activo, orden FROM "Categoria" ORDER BY orden ASC'
|
|
||||||
)
|
|
||||||
return json.dumps([dict(r) for r in rows], default=str)
|
|
||||||
|
|
||||||
elif uri == "cotizador://configuracion":
|
|
||||||
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
|
|
||||||
return json.dumps({r["clave"]: r["valor"] for r in rows})
|
|
||||||
|
|
||||||
return json.dumps({"error": f"Unknown resource: {uri}"})
|
|
||||||
|
|
||||||
|
|
||||||
@server.call_tool()
|
|
||||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
result = await _handle_tool(conn, name, arguments)
|
|
||||||
return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_tool(conn, name: str, arguments: dict) -> dict:
|
|
||||||
if name == "buscar_servicios":
|
|
||||||
return await _buscar_servicios(conn, arguments)
|
|
||||||
elif name == "crear_cotizacion":
|
|
||||||
return await _crear_cotizacion(conn, arguments)
|
|
||||||
elif name == "obtener_cotizacion":
|
|
||||||
return await _obtener_cotizacion(conn, arguments)
|
|
||||||
elif name == "listar_cotizaciones":
|
|
||||||
return await _listar_cotizaciones(conn, arguments)
|
|
||||||
elif name == "cambiar_estado_cotizacion":
|
|
||||||
return await _cambiar_estado(conn, arguments)
|
|
||||||
elif name == "actualizar_precio_servicio":
|
|
||||||
return await _actualizar_precio(conn, arguments)
|
|
||||||
elif name == "duplicar_cotizacion":
|
|
||||||
return await _duplicar_cotizacion(conn, arguments)
|
|
||||||
elif name == "calcular_financiamiento":
|
|
||||||
return await _calcular_financiamiento(arguments)
|
|
||||||
elif name == "generar_pdf_cotizacion":
|
|
||||||
return await _generar_pdf(conn, arguments)
|
|
||||||
elif name == "obtener_configuracion":
|
|
||||||
return await _obtener_configuracion(conn)
|
|
||||||
elif name == "listar_bonos":
|
|
||||||
return {"bonos": BONOS}
|
|
||||||
elif name == "listar_planes_bucefalo":
|
|
||||||
return {"planes": PLANES_BUCEFALO}
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown tool: {name}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _buscar_servicios(conn, args: dict) -> dict:
|
|
||||||
conditions = ['s.activo = true']
|
|
||||||
params = []
|
|
||||||
idx = 1
|
|
||||||
|
|
||||||
if args.get("fase") is not None:
|
|
||||||
conditions.append(f's.fase = ${idx}')
|
|
||||||
params.append(args["fase"])
|
|
||||||
idx += 1
|
|
||||||
if args.get("tipo_pago"):
|
|
||||||
conditions.append(f's."tipoPago" = ${idx}')
|
|
||||||
params.append(args["tipo_pago"])
|
|
||||||
idx += 1
|
|
||||||
if args.get("categoria"):
|
|
||||||
conditions.append(f'LOWER(c.nombre) = LOWER(${idx})')
|
|
||||||
params.append(args["categoria"])
|
|
||||||
idx += 1
|
|
||||||
if args.get("busqueda"):
|
|
||||||
conditions.append(f'(LOWER(s.nombre) LIKE LOWER(${idx}) OR LOWER(s.descripcion) LIKE LOWER(${idx}))')
|
|
||||||
params.append(f"%{args['busqueda']}%")
|
|
||||||
idx += 1
|
|
||||||
|
|
||||||
where = " AND ".join(conditions)
|
|
||||||
query = f"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
|
|
||||||
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
|
|
||||||
s.variante, c.nombre as categoria
|
|
||||||
FROM "ServicioCatalogo" s
|
|
||||||
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY s.fase ASC, s.orden ASC"""
|
|
||||||
|
|
||||||
rows = await conn.fetch(query, *params)
|
|
||||||
servicios = []
|
|
||||||
for r in rows:
|
|
||||||
d = dict(r)
|
|
||||||
if d.get("entregablesDefault") and isinstance(d["entregablesDefault"], str):
|
|
||||||
try:
|
|
||||||
d["entregablesDefault"] = json.loads(d["entregablesDefault"])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
servicios.append(d)
|
|
||||||
|
|
||||||
return {"servicios": servicios, "total": len(servicios)}
|
|
||||||
|
|
||||||
|
|
||||||
async def _crear_cotizacion(conn, args: dict) -> dict:
|
|
||||||
cliente_data = args["cliente"]
|
|
||||||
servicios_data = args["servicios"]
|
|
||||||
plan_bucefalo = args.get("plan_bucefalo")
|
|
||||||
moneda = args.get("moneda", "MXN")
|
|
||||||
proyecto = args.get("proyecto", "MKT Digital")
|
|
||||||
esquema = args.get("esquema_pago", "Pago Unico/Mensual")
|
|
||||||
es_doble = bool(args.get("es_doble", False))
|
|
||||||
opciones_metadata = args.get("opciones_metadata") if es_doble else None
|
|
||||||
|
|
||||||
cliente = await conn.fetchrow(
|
|
||||||
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
|
|
||||||
cliente_data["nombre"],
|
|
||||||
cliente_data.get("empresa", ""),
|
|
||||||
)
|
|
||||||
if not cliente:
|
|
||||||
cliente = await conn.fetchrow(
|
|
||||||
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) RETURNING id',
|
|
||||||
cliente_data["nombre"],
|
|
||||||
cliente_data.get("empresa", ""),
|
|
||||||
cliente_data.get("email", ""),
|
|
||||||
cliente_data.get("telefono", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
cliente_id = cliente["id"]
|
|
||||||
|
|
||||||
now = datetime.now()
|
|
||||||
numero = f"UJ{str(now.year)[-2:]}{now.month:02d}AGENT001"
|
|
||||||
|
|
||||||
from app.services.calculators import calcular_vigencia
|
|
||||||
vigencia = calcular_vigencia(now)
|
|
||||||
|
|
||||||
async with conn.transaction():
|
|
||||||
cot = await conn.fetchrow(
|
|
||||||
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
|
||||||
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
|
||||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, $7, $8, '', $9, $10, NOW(), NOW())
|
|
||||||
RETURNING id, numero""",
|
|
||||||
numero, now, vigencia, moneda, proyecto, esquema,
|
|
||||||
es_doble,
|
|
||||||
json.dumps(opciones_metadata) if opciones_metadata else None,
|
|
||||||
cliente_id, "agent",
|
|
||||||
)
|
|
||||||
cot_id = cot["id"]
|
|
||||||
|
|
||||||
for srv in servicios_data:
|
|
||||||
catalogo_id = srv["servicio_id"]
|
|
||||||
cat_row = await conn.fetchrow(
|
|
||||||
'SELECT id, "precioBase", "tiempoEntrega", "entregablesDefault", fase, "tipoPago" FROM "ServicioCatalogo" WHERE id = $1',
|
|
||||||
catalogo_id,
|
|
||||||
)
|
|
||||||
if not cat_row:
|
|
||||||
continue
|
|
||||||
|
|
||||||
precio = srv.get("precio_personalizado") or cat_row["precioBase"]
|
|
||||||
entregables = cat_row["entregablesDefault"]
|
|
||||||
if isinstance(entregables, str):
|
|
||||||
try:
|
|
||||||
entregables = json.loads(entregables)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
entregables = []
|
|
||||||
|
|
||||||
opcion = (srv.get("opcion") or "ambas") if es_doble else None
|
|
||||||
|
|
||||||
await conn.fetchrow(
|
|
||||||
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
|
|
||||||
precio, "tiempoEntrega", entregables, opcion, seleccionado, "createdAt", "updatedAt")
|
|
||||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true, NOW(), NOW())""",
|
|
||||||
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
|
|
||||||
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), opcion,
|
|
||||||
)
|
|
||||||
|
|
||||||
if plan_bucefalo:
|
|
||||||
nivel = plan_bucefalo if isinstance(plan_bucefalo, str) else plan_bucefalo.get("nivel", "basico")
|
|
||||||
precio_bp = bucefalo_precio(nivel)
|
|
||||||
await conn.fetchrow(
|
|
||||||
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
|
||||||
VALUES (gen_random_uuid(), $1, $2, $3, true, NOW(), NOW())""",
|
|
||||||
cot_id, nivel, precio_bp,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"cotizacion_id": str(cot_id), "numero": numero, "estado": "borrador", "cliente_id": str(cliente_id)}
|
|
||||||
|
|
||||||
|
|
||||||
async def _obtener_cotizacion(conn, args: dict) -> dict:
|
|
||||||
cot = await conn.fetchrow(
|
|
||||||
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
|
|
||||||
cl.email as cliente_email, cl.telefono as cliente_telefono
|
|
||||||
FROM "Cotizacion" c
|
|
||||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
|
||||||
WHERE c.id = $1""",
|
|
||||||
args["cotizacion_id"],
|
|
||||||
)
|
|
||||||
if not cot:
|
|
||||||
return {"error": "Cotización no encontrada"}
|
|
||||||
|
|
||||||
servicios = await conn.fetch(
|
|
||||||
"""SELECT sc.*, s.nombre as servicio_nombre, s.fase as servicio_fase, s."tipoPago" as "servicio_tipoPago"
|
|
||||||
FROM "ServicioCotizado" sc
|
|
||||||
LEFT JOIN "ServicioCatalogo" s ON sc."servicioCatalogoId" = s.id
|
|
||||||
WHERE sc."cotizacionId" = $1""",
|
|
||||||
args["cotizacion_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await conn.fetchrow(
|
|
||||||
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
|
||||||
args["cotizacion_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
result = dict(cot)
|
|
||||||
result["cliente"] = {
|
|
||||||
"nombre": cot["cliente_nombre"],
|
|
||||||
"empresa": cot["cliente_empresa"],
|
|
||||||
"email": cot["cliente_email"],
|
|
||||||
"telefono": cot["cliente_telefono"],
|
|
||||||
}
|
|
||||||
result["servicios"] = [dict(s) for s in servicios]
|
|
||||||
result["planBucefalo"] = dict(plan) if plan else None
|
|
||||||
|
|
||||||
for k in ["cliente_nombre", "cliente_empresa", "cliente_email", "cliente_telefono"]:
|
|
||||||
result.pop(k, None)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def _listar_cotizaciones(conn, args: dict) -> dict:
|
|
||||||
conditions = []
|
|
||||||
params = []
|
|
||||||
idx = 1
|
|
||||||
|
|
||||||
if args.get("estado"):
|
|
||||||
conditions.append(f'c.estado = ${idx}')
|
|
||||||
params.append(args["estado"])
|
|
||||||
idx += 1
|
|
||||||
if args.get("cliente_nombre"):
|
|
||||||
conditions.append(f'LOWER(cl.nombre) LIKE LOWER(${idx})')
|
|
||||||
params.append(f"%{args['cliente_nombre']}%")
|
|
||||||
idx += 1
|
|
||||||
if args.get("busqueda"):
|
|
||||||
conditions.append(
|
|
||||||
f'(LOWER(c.numero) LIKE LOWER(${idx}) OR LOWER(c.proyecto) LIKE LOWER(${idx}) OR LOWER(cl.nombre) LIKE LOWER(${idx}))'
|
|
||||||
)
|
|
||||||
params.append(f"%{args['busqueda']}%")
|
|
||||||
idx += 1
|
|
||||||
|
|
||||||
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
||||||
query = f"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.estado, c.proyecto,
|
|
||||||
c.moneda, c."esquemaPago",
|
|
||||||
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
|
|
||||||
FROM "Cotizacion" c
|
|
||||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
|
||||||
{where}
|
|
||||||
ORDER BY c."createdAt" DESC
|
|
||||||
LIMIT 50"""
|
|
||||||
|
|
||||||
rows = await conn.fetch(query, *params)
|
|
||||||
cotizaciones = []
|
|
||||||
for r in rows:
|
|
||||||
d = dict(r)
|
|
||||||
d["cliente"] = {"nombre": d.pop("cliente_nombre"), "empresa": d.pop("cliente_empresa")}
|
|
||||||
cotizaciones.append(d)
|
|
||||||
|
|
||||||
return {"cotizaciones": cotizaciones, "total": len(cotizaciones)}
|
|
||||||
|
|
||||||
|
|
||||||
async def _cambiar_estado(conn, args: dict) -> dict:
|
|
||||||
cot_id = args["cotizacion_id"]
|
|
||||||
estado = args["estado"]
|
|
||||||
|
|
||||||
cot = await conn.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cot_id)
|
|
||||||
if not cot:
|
|
||||||
return {"error": "Cotización no encontrada"}
|
|
||||||
|
|
||||||
await conn.execute('UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', estado, cot_id)
|
|
||||||
return {"ok": True, "cotizacion_id": cot_id, "nuevo_estado": estado}
|
|
||||||
|
|
||||||
|
|
||||||
async def _actualizar_precio(conn, args: dict) -> dict:
|
|
||||||
cot_id = args["cotizacion_id"]
|
|
||||||
servicio_id = args["servicio_id"]
|
|
||||||
nuevo_precio = args["nuevo_precio"]
|
|
||||||
|
|
||||||
srv = await conn.fetchrow(
|
|
||||||
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
|
|
||||||
servicio_id, cot_id,
|
|
||||||
)
|
|
||||||
if not srv:
|
|
||||||
return {"error": "Servicio no encontrado en esta cotización"}
|
|
||||||
|
|
||||||
await conn.execute(
|
|
||||||
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
|
|
||||||
nuevo_precio, servicio_id,
|
|
||||||
)
|
|
||||||
return {"ok": True, "servicio_id": servicio_id, "nuevo_precio": nuevo_precio}
|
|
||||||
|
|
||||||
|
|
||||||
async def _duplicar_cotizacion(conn, args: dict) -> dict:
|
|
||||||
cot_id = args["cotizacion_id"]
|
|
||||||
|
|
||||||
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
|
|
||||||
if not original:
|
|
||||||
return {"error": "Cotización no encontrada"}
|
|
||||||
|
|
||||||
now = datetime.now()
|
|
||||||
new_numero = f"{original['numero']}-COPY"
|
|
||||||
|
|
||||||
async with conn.transaction():
|
|
||||||
new_cot = await conn.fetchrow(
|
|
||||||
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
|
||||||
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
|
||||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
|
|
||||||
RETURNING id, numero""",
|
|
||||||
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
|
|
||||||
original["proyecto"], original["esquemaPago"], original["incluirBonos"],
|
|
||||||
original["incluirFinanciamiento"], original["esDoble"], original["opcionesMetadata"],
|
|
||||||
original["observaciones"], original["clienteId"], original["asesorId"],
|
|
||||||
)
|
|
||||||
new_id = new_cot["id"]
|
|
||||||
|
|
||||||
servicios = await conn.fetch(
|
|
||||||
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cot_id
|
|
||||||
)
|
|
||||||
for s in servicios:
|
|
||||||
await conn.fetchrow(
|
|
||||||
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
|
|
||||||
precio, "tiempoEntrega", entregables, notas, opcion, seleccionado, "createdAt", "updatedAt")
|
|
||||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())""",
|
|
||||||
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
|
|
||||||
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["opcion"], s["seleccionado"],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await conn.fetchrow(
|
|
||||||
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id
|
|
||||||
)
|
|
||||||
if plan:
|
|
||||||
await conn.fetchrow(
|
|
||||||
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
|
||||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())""",
|
|
||||||
new_id, plan["nivel"], plan["precio"], plan["seleccionado"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"cotizacion_id": str(new_id), "numero": new_numero, "estado": "borrador"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _calcular_financiamiento(args: dict) -> dict:
|
|
||||||
monto = args["monto"]
|
|
||||||
meses = args["meses"]
|
|
||||||
|
|
||||||
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
|
|
||||||
if not plan:
|
|
||||||
from app.services.calculators import FINANCIAMIENTO_PLANES
|
|
||||||
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
|
|
||||||
|
|
||||||
if not plan:
|
|
||||||
return {"error": f"Plan de {meses} meses no disponible"}
|
|
||||||
|
|
||||||
result = calcular_financiamiento(monto, meses, plan["tasa"], plan["comision"])
|
|
||||||
result["meses"] = meses
|
|
||||||
result["tasa"] = plan["tasa"]
|
|
||||||
result["comision"] = plan["comision"]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def _generar_pdf(conn, args: dict) -> dict:
|
|
||||||
cot_id = args["cotizacion_id"]
|
|
||||||
cot = await conn.fetchrow(
|
|
||||||
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
|
|
||||||
FROM "Cotizacion" c
|
|
||||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
|
||||||
WHERE c.id = $1""",
|
|
||||||
cot_id,
|
|
||||||
)
|
|
||||||
if not cot:
|
|
||||||
return {"error": "Cotización no encontrada"}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "pdf_generated",
|
|
||||||
"cotizacion_id": cot_id,
|
|
||||||
"numero": cot["numero"],
|
|
||||||
"filename": f"{cot['cliente_nombre']} - {cot['numero']}.pdf",
|
|
||||||
"message": "PDF generation will be implemented with reportlab",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _obtener_configuracion(conn) -> dict:
|
|
||||||
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
|
|
||||||
return {"config": {r["clave"]: r["valor"] for r in rows}}
|
|
||||||
|
|
||||||
|
|
||||||
from app.services.calculators import FINANCIAMIENTO_PLANES
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
"""MCP (Model Context Protocol) tool definitions for the Cotizador API.
|
|
||||||
|
|
||||||
Each tool is designed to be semantically clear for AI agents like OpenClaw, Claude, and ChatGPT.
|
|
||||||
"""
|
|
||||||
|
|
||||||
TOOLS = [
|
|
||||||
{
|
|
||||||
"name": "buscar_servicios",
|
|
||||||
"description": (
|
|
||||||
"Busca servicios del catálogo de marketing digital de Consultoría E3. "
|
|
||||||
"Útil cuando el cliente pregunta por servicios disponibles, precios, o por fase del proyecto. "
|
|
||||||
"Las fases son: 0=Auditoría (diagnóstico inicial), 1=Setup (infraestructura y configuración), "
|
|
||||||
"2=Publicidad (anuncios y manejo de redes), 3=Contenido/SEO (producción de contenido y posicionamiento). "
|
|
||||||
"Tipos de pago: 'unico' (pago único) o 'mensual' (recurso recurrente)."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"fase": {
|
|
||||||
"type": "integer",
|
|
||||||
"enum": [0, 1, 2, 3],
|
|
||||||
"description": "Fase del proyecto: 0=Auditoría/Acompañamiento, 1=Setup/Infraestructura, 2=Publicidad/Manejo, 3=Contenido/SEO",
|
|
||||||
},
|
|
||||||
"tipo_pago": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["unico", "mensual"],
|
|
||||||
"description": "Tipo de pago: 'unico' para pago único, 'mensual' para recurrente",
|
|
||||||
},
|
|
||||||
"categoria": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Nombre de categoría: SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado",
|
|
||||||
},
|
|
||||||
"busqueda": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Texto libre para buscar en nombre y descripción del servicio",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "crear_cotizacion",
|
|
||||||
"description": (
|
|
||||||
"Crea una cotización completa de servicios de marketing digital para un cliente. "
|
|
||||||
"El cliente se crea automáticamente si no existe (busca por nombre+empresa). "
|
|
||||||
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
|
|
||||||
"y configuración de moneda y esquema de pago. "
|
|
||||||
"La cotización se crea en estado 'borrador'. "
|
|
||||||
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes. "
|
|
||||||
"Soporta DOBLE PROPUESTA: con es_doble=true se presentan dos opciones comparables; cada servicio "
|
|
||||||
"se asigna a la opción '1', '2' o 'ambas' (compartido), y opciones_metadata define el título, "
|
|
||||||
"descripción y exclusiones de cada opción."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cliente": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"nombre": {"type": "string", "description": "Nombre completo del contacto"},
|
|
||||||
"empresa": {"type": "string", "description": "Nombre de la empresa (opcional)"},
|
|
||||||
"email": {"type": "string", "description": "Email de contacto"},
|
|
||||||
"telefono": {"type": "string", "description": "Teléfono de contacto"},
|
|
||||||
},
|
|
||||||
"required": ["nombre"],
|
|
||||||
},
|
|
||||||
"servicios": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"},
|
|
||||||
"precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
|
|
||||||
"opcion": {"type": "string", "enum": ["1", "2", "ambas"], "description": "Solo en doble propuesta: opción a la que pertenece el servicio ('ambas' = compartido). Default 'ambas'."},
|
|
||||||
},
|
|
||||||
"required": ["servicio_id"],
|
|
||||||
},
|
|
||||||
"description": "Lista de servicios a incluir en la cotización",
|
|
||||||
},
|
|
||||||
"es_doble": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Si es true, la cotización presenta dos opciones comparables (doble propuesta).",
|
|
||||||
},
|
|
||||||
"opciones_metadata": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Solo en doble propuesta. Metadatos por opción, p.ej. {\"1\": {\"titulo\": \"...\", \"descripcion\": \"...\", \"noIncluye\": \"...\"}, \"2\": {...}}.",
|
|
||||||
"properties": {
|
|
||||||
"1": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
|
|
||||||
"2": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"plan_bucefalo": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["basico", "estandar", "premium", "empresarial"],
|
|
||||||
"description": "Nivel del plan CRM Bucefalo (opcional)",
|
|
||||||
},
|
|
||||||
"proyecto": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Nombre o descripción del proyecto (default: 'MKT Digital')",
|
|
||||||
},
|
|
||||||
"moneda": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["MXN", "USD"],
|
|
||||||
"description": "Moneda de la cotización (default: MXN)",
|
|
||||||
},
|
|
||||||
"esquema_pago": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["Pago Unico", "Mensual", "Pago Unico/Mensual"],
|
|
||||||
"description": "Esquema de pago (default: Pago Unico/Mensual)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["cliente", "servicios"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "obtener_cotizacion",
|
|
||||||
"description": (
|
|
||||||
"Obtiene los detalles completos de una cotización existente incluyendo: "
|
|
||||||
"datos del cliente, servicios seleccionados con precios, estado actual, "
|
|
||||||
"plan CRM Bucefalo si aplica, observaciones, fechas y vigencia."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
|
||||||
},
|
|
||||||
"required": ["cotizacion_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "listar_cotizaciones",
|
|
||||||
"description": (
|
|
||||||
"Lista cotizaciones con filtros opcionales. "
|
|
||||||
"Útil para revisar el pipeline de ventas, cotizaciones pendientes, o historial de un cliente. "
|
|
||||||
"Estados: borrador (en proceso), enviada (esperando respuesta), aprobada (cerrada ganada), rechazada (cerrada perdida)."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"estado": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
|
|
||||||
"description": "Filtrar por estado",
|
|
||||||
},
|
|
||||||
"cliente_nombre": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Buscar por nombre de cliente",
|
|
||||||
},
|
|
||||||
"busqueda": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Texto libre para buscar en número, proyecto o cliente",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "cambiar_estado_cotizacion",
|
|
||||||
"description": (
|
|
||||||
"Cambia el estado de una cotización. "
|
|
||||||
"Flujo normal: borrador → enviada → aprobada o rechazada. "
|
|
||||||
"Solo cambiar a 'enviada' cuando la cotización esté lista para el cliente. "
|
|
||||||
"Cambiar a 'aprobada' cuando el cliente acepte, o 'rechazada' cuando decline."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
|
||||||
"estado": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
|
|
||||||
"description": "Nuevo estado de la cotización",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["cotizacion_id", "estado"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "actualizar_precio_servicio",
|
|
||||||
"description": (
|
|
||||||
"Actualiza el precio de un servicio específico dentro de una cotización. "
|
|
||||||
"No modifica el precio base del catálogo, solo el precio en esta cotización. "
|
|
||||||
"Útil para negociar precios individuales sin recrear toda la cotización."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
|
||||||
"servicio_id": {"type": "string", "description": "ID del servicio cotizado (no el del catálogo)"},
|
|
||||||
"nuevo_precio": {"type": "number", "description": "Nuevo precio en la moneda de la cotización"},
|
|
||||||
},
|
|
||||||
"required": ["cotizacion_id", "servicio_id", "nuevo_precio"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "duplicar_cotizacion",
|
|
||||||
"description": (
|
|
||||||
"Duplica una cotización existente como nueva copia en estado 'borrador'. "
|
|
||||||
"Crea una copia exacta con nuevo ID y número. "
|
|
||||||
"Útil para crear variaciones de una propuesta o reenviar una cotización actualizada."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización a duplicar"},
|
|
||||||
},
|
|
||||||
"required": ["cotizacion_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "calcular_financiamiento",
|
|
||||||
"description": (
|
|
||||||
"Calcula las mensualidades para financiar una cotización o monto específico. "
|
|
||||||
"Plazos disponibles: 3 meses (7.7% tasa), 6 meses (10.7%), 9 meses (13.7%), 12 meses (16.7%). "
|
|
||||||
"Todos incluyen 2.5% de comisión + 16% IVA. "
|
|
||||||
"Devuelve: pago mensual, IVA mensual, total mensual, comisión total, y gran total."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"monto": {"type": "number", "description": "Monto total a financiar en MXN"},
|
|
||||||
"meses": {"type": "integer", "enum": [3, 6, 9, 12], "description": "Plazo en meses"},
|
|
||||||
},
|
|
||||||
"required": ["monto", "meses"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "generar_pdf_cotizacion",
|
|
||||||
"description": (
|
|
||||||
"Genera un PDF profesional de una cotización con: logo de la empresa, colores de marca, "
|
|
||||||
"tabla de servicios agrupados por fase, bonos incluidos, términos y condiciones, "
|
|
||||||
"y datos bancarios para transferencia. Devuelve el archivo PDF."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización guardada"},
|
|
||||||
},
|
|
||||||
"required": ["cotizacion_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "obtener_configuracion",
|
|
||||||
"description": (
|
|
||||||
"Obtiene la configuración de la empresa Consultoría E3: "
|
|
||||||
"razón social, RFC, domicilio fiscal, datos bancarios (cuenta nacional, CLABE, cuenta internacional, SWIFT), "
|
|
||||||
"colores de marca, logo, y términos y condiciones. "
|
|
||||||
"Útil para generar documentos o verificar información fiscal."
|
|
||||||
),
|
|
||||||
"inputSchema": {"type": "object", "properties": {}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "listar_bonos",
|
|
||||||
"description": (
|
|
||||||
"Lista los bonos/disponibles que se pueden incluir en una cotización: "
|
|
||||||
"1) Servicio Centinela Web (monitoreo 30 min/mes), "
|
|
||||||
"2) Workshop Buyer Persona, "
|
|
||||||
"3) Workshop Propuesta de Valor, "
|
|
||||||
"4) Membresía Premium (1 año), "
|
|
||||||
"5) Mes Gratis CRM Bucefalo, "
|
|
||||||
"6) Script de Ventas (100+ complementos)."
|
|
||||||
),
|
|
||||||
"inputSchema": {"type": "object", "properties": {}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "listar_planes_bucefalo",
|
|
||||||
"description": (
|
|
||||||
"Lista los niveles del CRM Bucefalo con precios mensuales: "
|
|
||||||
"Básico ($1,000/mes), Estándar ($3,500/mes), Premium ($4,500/mes), Empresarial ($7,500/mes). "
|
|
||||||
"Bucefalo es un CRM para gestión de ventas y clientes."
|
|
||||||
),
|
|
||||||
"inputSchema": {"type": "object", "properties": {}},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
RESOURCES = [
|
|
||||||
{
|
|
||||||
"uri": "cotizador://servicios",
|
|
||||||
"name": "Catálogo de Servicios",
|
|
||||||
"description": (
|
|
||||||
"Lista completa de servicios de marketing digital de Consultoría E3 organizados por fase: "
|
|
||||||
"Fase 0 (Auditorías), Fase 1 (Setup/Infraestructura), Fase 2 (Publicidad/Manejo), "
|
|
||||||
"Fase 3 (Contenido/SEO). Cada servicio incluye nombre, descripción, precio base, "
|
|
||||||
"tiempo de entrega, tipo de pago (único/mensual), y entregables."
|
|
||||||
),
|
|
||||||
"mimeType": "application/json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "cotizador://categorias",
|
|
||||||
"name": "Categorías de Servicios",
|
|
||||||
"description": (
|
|
||||||
"Categorías disponibles para clasificar servicios: "
|
|
||||||
"SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado."
|
|
||||||
),
|
|
||||||
"mimeType": "application/json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "cotizador://configuracion",
|
|
||||||
"name": "Configuración de la Empresa",
|
|
||||||
"description": (
|
|
||||||
"Datos fiscales, bancarios y de marca de Consultoría E3. "
|
|
||||||
"Incluye razón social, RFC, domicilio fiscal, cuentas bancarias nacionales e internacionales, "
|
|
||||||
"colores de marca, logo, y términos y condiciones."
|
|
||||||
),
|
|
||||||
"mimeType": "application/json",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
+9
-29
@@ -1,7 +1,7 @@
|
|||||||
"""Cotizador E3 — FastAPI Application
|
"""Cotizador E3 — FastAPI Application
|
||||||
|
|
||||||
REST API + MCP Server for digital marketing quotation management.
|
REST API for digital marketing quotation management.
|
||||||
Optimized for n8n workflows and AI agents (OpenClaw, Claude, ChatGPT).
|
Optimized for n8n workflows and other integrations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -28,8 +28,8 @@ app = FastAPI(
|
|||||||
title="Cotizador E3 API",
|
title="Cotizador E3 API",
|
||||||
description=(
|
description=(
|
||||||
"API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. "
|
"API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. "
|
||||||
"Optimizada para integración con n8n y agentes de IA (OpenClaw, Claude, ChatGPT) "
|
"Optimizada para integración con n8n y otros consumidores. "
|
||||||
"via MCP (Model Context Protocol)."
|
"Autenticación por X-API-Key o Bearer JWT."
|
||||||
),
|
),
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -98,30 +98,11 @@ app.include_router(financiamiento.router)
|
|||||||
app.include_router(export_.router)
|
app.include_router(export_.router)
|
||||||
app.include_router(import_.router)
|
app.include_router(import_.router)
|
||||||
|
|
||||||
# Mount MCP server
|
# El servidor MCP se retiro (2026-07-28). No se estaba usando, su transporte
|
||||||
try:
|
# llevaba tiempo roto (instanciaba un StreamableHTTPServerTransport por peticion,
|
||||||
from app.mcp.server import server as mcp_server
|
# sin manejo de sesion, y devolvia 500 con credencial valida) y su SDK sin fijar
|
||||||
|
# tumbo el API entero en produccion al saltar a 2.0.0. Si algun dia se retoma,
|
||||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
# el codigo esta en el historial: `git show edb500f5:api/app/mcp/server.py`.
|
||||||
|
|
||||||
@app.post("/mcp")
|
|
||||||
async def mcp_endpoint(request: Request):
|
|
||||||
"""MCP (Model Context Protocol) endpoint for AI agents."""
|
|
||||||
transport = StreamableHTTPServerTransport(mcp_server)
|
|
||||||
return await transport.handle_request(request)
|
|
||||||
|
|
||||||
@app.get("/mcp")
|
|
||||||
async def mcp_info():
|
|
||||||
"""MCP server info. Use POST for actual MCP communication."""
|
|
||||||
return {
|
|
||||||
"name": "cotizador-e3",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"protocol": "mcp",
|
|
||||||
"description": "MCP server for Cotizador E3 quotation system",
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
# MCP SDK not installed, skip MCP mount
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
@@ -132,7 +113,6 @@ async def root():
|
|||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"openapi": "/openapi.json",
|
"openapi": "/openapi.json",
|
||||||
"health": "/health",
|
"health": "/health",
|
||||||
"mcp": "/mcp",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+30
-12
@@ -1,12 +1,30 @@
|
|||||||
fastapi>=0.115.0
|
# Versiones FIJAS a proposito.
|
||||||
uvicorn[standard]>=0.34.0
|
#
|
||||||
asyncpg>=0.30.0
|
# Por que: el 2026-07-28 una reconstruccion del API tomo mcp 2.0.0 (el rango era
|
||||||
pydantic>=2.0
|
# `mcp>=1.0.0`), que elimino Server.list_tools(). El import lanzo AttributeError
|
||||||
pydantic-settings>=2.0
|
# al arrancar y dejo el contenedor en crash-loop ~50 minutos. Once de las doce
|
||||||
python-jose[cryptography]>=3.3.0
|
# dependencias eran rangos `>=` sin techo, o sea que cada build era una tirada de
|
||||||
passlib[bcrypt]>=1.7.4
|
# dados contra PyPI: `pydantic>=2.0` habria aceptado pydantic 3 igual de alegre.
|
||||||
python-multipart>=0.0.18
|
#
|
||||||
reportlab>=4.0
|
# Estas versiones son exactamente las que corrian sanas en produccion cuando se
|
||||||
openpyxl>=3.1.0
|
# fijaron (capturadas con `pip freeze` del contenedor healthy).
|
||||||
mcp>=1.0.0
|
#
|
||||||
httpx>=0.27.0
|
# Para subir una dependencia: cambiala aqui a proposito, reconstruye y prueba.
|
||||||
|
# Nunca por accidente al redesplegar.
|
||||||
|
#
|
||||||
|
# Limitacion conocida: esto fija las dependencias DIRECTAS. Las transitivas
|
||||||
|
# (starlette, cryptography, anyio...) las sigue resolviendo pip. Es un riesgo
|
||||||
|
# mucho menor, pero si algun dia muerde, el siguiente paso es un lock completo
|
||||||
|
# con pip-tools o uv.
|
||||||
|
|
||||||
|
fastapi==0.140.13
|
||||||
|
uvicorn[standard]==0.51.0
|
||||||
|
asyncpg==0.31.0
|
||||||
|
pydantic==2.13.4
|
||||||
|
pydantic-settings==2.14.2
|
||||||
|
python-jose[cryptography]==3.5.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.32
|
||||||
|
reportlab==5.0.0
|
||||||
|
openpyxl==3.1.5
|
||||||
|
httpx==0.28.1
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Caza del error intermitente: corre el pipeline real contra UJ2606UR001 hasta que
|
||||||
|
* falle, e imprime TODO lo que sepamos de la peticion que lo provoco.
|
||||||
|
*/
|
||||||
|
import { cargarEconomia } from "@/lib/propuesta/economia";
|
||||||
|
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia";
|
||||||
|
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema, type Hechos, type Diagnostico } from "@/lib/propuesta/schemas";
|
||||||
|
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "@/lib/propuesta/prompts";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const LOG = process.argv[2];
|
||||||
|
// Escritura directa: el buffer de stdout se pierde si el proceso muere.
|
||||||
|
function log(...a: unknown[]) {
|
||||||
|
const linea = a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" ");
|
||||||
|
fs.appendFileSync(LOG, linea + "\n");
|
||||||
|
}
|
||||||
|
const console = { log } as unknown as Console;
|
||||||
|
|
||||||
|
const NUMERO = "UJ2606UR001";
|
||||||
|
const TRANSCRIPCION = "Una propuesta de zero to hero para un negocio inicial";
|
||||||
|
|
||||||
|
/** Igual que llamarConHerramienta pero registra cada peticion y explota con detalle. */
|
||||||
|
async function correrPaso(nombre: string, msg: string, schema: z.ZodType, maxTokens: number, maxIntentos: number) {
|
||||||
|
const cliente = crearCliente();
|
||||||
|
const tools = [{ name: nombre, description: "Registra el resultado.", input_schema: aJsonSchema(schema) as never }];
|
||||||
|
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: msg }];
|
||||||
|
|
||||||
|
for (let intento = 1; intento <= maxIntentos; intento++) {
|
||||||
|
let res: Anthropic.Message;
|
||||||
|
try {
|
||||||
|
res = await cliente.messages.create({
|
||||||
|
model: modelo(), max_tokens: maxTokens,
|
||||||
|
system: [{ type: "text", text: SYSTEM_BASE, cache_control: { type: "ephemeral" } }],
|
||||||
|
tools, messages: mensajes,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { status?: number; message?: string; error?: unknown };
|
||||||
|
console.log(`\n*** ERROR EN ${nombre} intento ${intento} ***`);
|
||||||
|
console.log(" status:", err.status);
|
||||||
|
console.log(" message:", String(err.message).slice(0, 600));
|
||||||
|
if (err.error) console.log(" error:", JSON.stringify(err.error).slice(0, 800));
|
||||||
|
console.log("\n --- FORMA DE LOS MENSAJES ENVIADOS ---");
|
||||||
|
mensajes.forEach((m, i) => {
|
||||||
|
const c = m.content;
|
||||||
|
const tipos = typeof c === "string" ? "string" : (c as { type: string }[]).map((b) => b.type).join(",");
|
||||||
|
console.log(` [${i}] role=${m.role} bloques=${tipos}`);
|
||||||
|
});
|
||||||
|
throw new Error("REPRODUCIDO");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bloques = res.content.map((b) => b.type).join(",");
|
||||||
|
console.log(` ${nombre} intento ${intento}: stop=${res.stop_reason} bloques=[${bloques}] out=${res.usage.output_tokens}`);
|
||||||
|
|
||||||
|
const tus = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
|
||||||
|
const ok = tus.find((b) => b.name === nombre);
|
||||||
|
if (ok) {
|
||||||
|
const parsed = schema.safeParse(ok.input);
|
||||||
|
if (parsed.success) return parsed.data;
|
||||||
|
const errTxt = (await import("zod")).z.prettifyError(parsed.error);
|
||||||
|
console.log(` zod rechazo: ${errTxt.split("\n")[0]}`);
|
||||||
|
mensajes.push(
|
||||||
|
{ role: "assistant", content: res.content },
|
||||||
|
{ role: "user", content: [
|
||||||
|
...tus.map((b) => ({ type: "tool_result" as const, tool_use_id: b.id, is_error: true, content: errTxt })),
|
||||||
|
{ type: "text" as const, text: "Corrige y reintenta." },
|
||||||
|
] },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mensajes.push(
|
||||||
|
{ role: "assistant", content: res.content },
|
||||||
|
{ role: "user", content: `Debes llamar a ${nombre}.` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unaVuelta(entrada: ContextoEntrada, econ: Awaited<ReturnType<typeof cargarEconomia>>, n: number) {
|
||||||
|
console.log(`\n===== VUELTA ${n} =====`);
|
||||||
|
const h = (await correrPaso("registrar_hechos", mensajePaso1(entrada), hechosSchema, 8000, 5)) as Hechos | null;
|
||||||
|
if (!h) { console.log(" extraccion agoto intentos"); return; }
|
||||||
|
const d = (await correrPaso("registrar_diagnostico", mensajePaso2(entrada, h, econ), diagnosticoSchema, 8000, 4)) as Diagnostico | null;
|
||||||
|
if (!d) { console.log(" diagnostico agoto intentos"); return; }
|
||||||
|
const r = await correrPaso("registrar_propuesta", mensajePaso3(entrada, h, d, econ), redaccionSchema, 12000, 4);
|
||||||
|
console.log(r ? " vuelta completa OK" : " redaccion agoto intentos");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cot = await prisma.cotizacion.findFirst({ where: { numero: NUMERO }, include: { cliente: true, servicios: true } });
|
||||||
|
if (!cot) throw new Error("no encontrada");
|
||||||
|
const econ = await cargarEconomia(cot.id);
|
||||||
|
const entrada: ContextoEntrada = {
|
||||||
|
transcripcion: TRANSCRIPCION,
|
||||||
|
notas: cot.servicios.map((s) => s.notas).filter(Boolean).join("\n"),
|
||||||
|
observaciones: cot.observaciones || "",
|
||||||
|
cliente: cot.cliente.nombre, empresa: cot.cliente.empresa || "", proyecto: cot.proyecto,
|
||||||
|
};
|
||||||
|
console.log(`${econ.partidas.length} partidas`);
|
||||||
|
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
try { await unaVuelta(entrada, econ, i); }
|
||||||
|
catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "REPRODUCIDO") { console.log("\n>>> error reproducido, deteniendo"); break; }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error("fallo:", e); process.exit(1); });
|
||||||
@@ -40,6 +40,13 @@ services:
|
|||||||
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
|
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
|
||||||
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
|
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
|
||||||
|
# generar devuelve un error claro; el resto de la app funciona igual.
|
||||||
|
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
|
||||||
|
# mandaria la clave de MiniMax a api.anthropic.com.
|
||||||
|
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
|
||||||
|
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
|
||||||
|
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
|
||||||
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue
|
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue
|
||||||
- RUN_SEED=${RUN_SEED:-false}
|
- RUN_SEED=${RUN_SEED:-false}
|
||||||
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
|
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ services:
|
|||||||
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
|
# Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_*
|
||||||
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
|
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
# Propuesta consultiva con IA (MiniMax). Sin MINIMAX_API_KEY el boton de
|
||||||
|
# generar devuelve un error claro; el resto de la app funciona igual.
|
||||||
|
# OJO: el prefijo NO puede ser ANTHROPIC_*, el SDK lo lee por su cuenta y
|
||||||
|
# mandaria la clave de MiniMax a api.anthropic.com.
|
||||||
|
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
|
||||||
|
- MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/anthropic}
|
||||||
|
- MINIMAX_MODEL=${MINIMAX_MODEL:-MiniMax-M3}
|
||||||
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue
|
# Seed inicial: pon RUN_SEED=true solo en el primer despliegue
|
||||||
- RUN_SEED=${RUN_SEED:-false}
|
- RUN_SEED=${RUN_SEED:-false}
|
||||||
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
|
- SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:[email protected]}
|
||||||
|
|||||||
@@ -0,0 +1,859 @@
|
|||||||
|
# Business Intelligence — Plugin de Propuesta Consultiva con IA
|
||||||
|
|
||||||
|
> **Qué es este documento:** el análisis de negocio, la filosofía y los lineamientos para una **futura** implementación de un plugin que, a partir de transcripciones de reuniones y notas de texto, genere un **segundo documento** de cotización — narrativo y consultivo — como complemento al documento económico que hoy produce el Cotizador E3 de forma manual.
|
||||||
|
>
|
||||||
|
> **Qué NO es:** una especificación técnica cerrada ni un plan de implementación. No hay código aquí. Es el mapa que debe leerse antes de escribir la primera línea.
|
||||||
|
>
|
||||||
|
> **Fuentes analizadas:**
|
||||||
|
> - Proyecto real: `H:\MegaSync\Proyectos\Cotizador` (Next.js 16 + Prisma 7 + PostgreSQL, con API Python/MCP paralela)
|
||||||
|
> - Plantilla de referencia: `D:\Documents\plantilla_cotizacion.html`
|
||||||
|
> - Base de conocimiento: `Guia_Onboarding_Cliente_y_Ruta_de_Trabajo.md`, `08-Monetizacion-Onboarding-Pricing-Seguimiento.md`, `Onboarding-Cliente/04-Sesion-Propuesta-y-Continuidad.md`, `sintesis/s2-Pricing-Cobranza.md`, `sintesis/s8-Prompts-Plantillas.md`, `00-Guia-de-Uso-y-Checklist-Calidad.md`
|
||||||
|
>
|
||||||
|
> **Fecha:** 2026-07-28 · **Estado:** análisis previo a implementación
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumen ejecutivo
|
||||||
|
|
||||||
|
El Cotizador E3 hoy resuelve muy bien **el qué y el cuánto**: un catálogo de servicios por fase, precios, IVA, financiamiento, planes Bucéfalo, exportación a PDF y Excel. Es un motor contable y comercial sólido.
|
||||||
|
|
||||||
|
Lo que no resuelve —y lo que la base de conocimiento identifica como el factor que multiplica el ticket entre 3x y 15x— es **el por qué**: el diagnóstico del dolor del cliente en sus propias palabras, la cuantificación del costo de no hacer nada, el anclaje del precio en el valor anual, y la narrativa que permite al cliente defender la inversión internamente.
|
||||||
|
|
||||||
|
Ese "por qué" ya se está capturando de forma parcial pero **se está tirando a la basura**:
|
||||||
|
|
||||||
|
- El campo `observaciones` de `Cotizacion` y el campo `notas` de cada `ServicioCotizado` se guardan en la base de datos y **no se imprimen ni en el PDF ni en el Excel** (verificado por búsqueda en `src/lib/pdf-generator.ts` y `src/lib/excel-builder.ts`: cero coincidencias).
|
||||||
|
- Las reuniones de discovery generan transcripciones que hoy viven fuera del sistema.
|
||||||
|
- La plantilla HTML de `D:\Documents\` ya tiene la estructura narrativa correcta (diagnóstico con semáforo, exclusiones explícitas, argumentos de venta, estado de materiales) pero se llena **a mano**, una por una.
|
||||||
|
|
||||||
|
**La oportunidad:** un plugin que lea el contexto humano disperso (transcripción + notas + observaciones + la cotización ya armada) y genere un segundo archivo —el documento consultivo— siguiendo la filosofía de la plantilla HTML, sin tocar un solo número.
|
||||||
|
|
||||||
|
**La tesis central, en una frase:**
|
||||||
|
|
||||||
|
> El documento manual es la **fuente de verdad económica**. El documento de IA es la **fuente de verdad narrativa**. La IA nunca inventa un precio; sólo explica el que ya existe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Inventario: los tres activos que ya existen
|
||||||
|
|
||||||
|
### 2.1 El Cotizador real (`H:\MegaSync\Proyectos\Cotizador`)
|
||||||
|
|
||||||
|
**Stack.** Next.js 16 (App Router) + React 19 + Tailwind v4 + Zustand · Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`) · PostgreSQL 16 en Docker · JWT con `jose` en cookie httpOnly · PDFKit server-side · despliegue en Coolify.
|
||||||
|
|
||||||
|
**Segundo backend.** Un servicio FastAPI en `api/` que expone el mismo dominio como REST **y un servidor MCP en `/mcp`** con 12 herramientas y 3 recursos para agentes de IA. Esto es el hallazgo arquitectónico más importante del inventario: **el proyecto ya tiene una superficie diseñada para que una IA lo opere.**
|
||||||
|
|
||||||
|
Herramientas MCP existentes: `buscar_servicios`, `crear_cotizacion`, `obtener_cotizacion`, `listar_cotizaciones`, `cambiar_estado_cotizacion`, `actualizar_precio_servicio`, `duplicar_cotizacion`, `calcular_financiamiento`, `generar_pdf_cotizacion`, `obtener_configuracion`, `listar_bonos`, `listar_planes_bucefalo`. Recursos: `cotizador://servicios`, `cotizador://categorias`, `cotizador://configuracion`.
|
||||||
|
|
||||||
|
**Modelo de datos relevante.**
|
||||||
|
|
||||||
|
| Modelo | Campos que importan para el plugin |
|
||||||
|
|---|---|
|
||||||
|
| `Cotizacion` | `numero`, `fecha`, `vigencia`, `moneda`, `esquemaPago`, `estado`, `esDoble`, `opcionesMetadata` (JSON), **`observaciones`** |
|
||||||
|
| `ServicioCotizado` | `nombre`, `fase`, `tipoPago`, `precio`, `tiempoEntrega`, `entregables` (JSON), `beneficios` (JSON), **`notas`**, `modeloCobro`, `horas`, `tarifaHora`, `opcion` |
|
||||||
|
| `ServicioCatalogo` | `precioBase`, `descripcion`, `entregablesDefault`, `fase`, `nivel`, `variante` |
|
||||||
|
| `Cliente` | `nombre`, `empresa`, `email`, `telefono`, `rfc` |
|
||||||
|
| `RegistroHoras` | `fecha`, rango horario, `horas`, `tarifaHora`, `descripcion`, `estadoPago` |
|
||||||
|
| `PlanBucefaloCotizacion` | `nivel`, `precio` |
|
||||||
|
| `FinanciamientoPlan` | `meses` (3/6/9/12), `tasa`, `comision`, `montoMinimo`, `iva` |
|
||||||
|
| `Bono` | `numero`, `titulo`, `descripcion` |
|
||||||
|
| `Configuracion` | key-value: `color_primario`, `color_secundario`, `logo_base64` |
|
||||||
|
|
||||||
|
**Reglas de negocio ya codificadas** (`src/lib/calculators.ts`):
|
||||||
|
- `IVA_RATE = 0.16`
|
||||||
|
- `TARIFA_HORA_DEFAULT = 700`
|
||||||
|
- Número de cotización: `UJ{YY}{MM}{InicialesAsesor}{seq}` (ej. `UJ2605AG001`)
|
||||||
|
- Vigencia = 15 días hábiles desde la fecha (excluye sábado y domingo)
|
||||||
|
- 4 fases: F0 Auditoría · F1 Setup/Infra · F2 Publicidad/Manejo · F3 Contenido/SEO
|
||||||
|
- Planes Bucéfalo mensuales: básico $1,000 · estándar $3,500 · premium $4,500 · empresarial $7,500 (hardcodeados en `calculators.ts`, **no en la BD**)
|
||||||
|
- Modelos de cobro: `fijo`, `horas`, `retainer`, `demanda`
|
||||||
|
- 6 paquetes PyME pre-armados + paquete "General" con todo el catálogo
|
||||||
|
|
||||||
|
**El hallazgo clave: `esDoble` ya existe.** El sistema soporta cotización de doble propuesta: `esDoble: boolean` + `opcionesMetadata` con estructura `{ "1": {titulo, descripcion, noIncluye}, "2": {...} }`, y cada `ServicioCotizado` puede asignarse a la opción `"1"`, `"2"` o `"ambas"`. La herramienta MCP `crear_cotizacion` ya acepta `es_doble=true`. **Ya hay una vía nativa para presentar opciones comparables** — y la base de conocimiento dice que 3 opciones cierran 16-28% más alto. Está a mitad de camino.
|
||||||
|
|
||||||
|
### 2.2 La plantilla HTML (`D:\Documents\plantilla_cotizacion.html`)
|
||||||
|
|
||||||
|
Un documento de 690 líneas, autocontenido, con tokens CSS, tipografía editorial (Playfair Display serif + DM Sans), tema oscuro (`--bg: #0a0f1e`), responsive y con reglas `@media print` (`page-break-inside: avoid`).
|
||||||
|
|
||||||
|
Su valor no es el CSS. Es **la arquitectura argumental**:
|
||||||
|
|
||||||
|
| # | Sección | Función retórica |
|
||||||
|
|---|---|---|
|
||||||
|
| Hero | Título + subtítulo + 4 metadatos (elaborado por / cliente / plataforma / vigencia) | Encuadre y contexto |
|
||||||
|
| 01 | **Diagnóstico del proyecto** — un `.quote` con el insight clave + filas con semáforo (`dot-red` problema crítico, `dot-amber` área de mejora, `dot-blue` oportunidad, `dot-green` ventaja existente) | Demuestra que entendiste el negocio antes de vender |
|
||||||
|
| 02 | **Alcance y desglose económico** — filas categoría / entregable+descripción / precio, más caja de totales (subtotal, descuento, IVA, total) | El qué y el cuánto |
|
||||||
|
| 03 | **Qué no incluye esta propuesta** — lista con ✕ | Anti-scope-creep, preventivo |
|
||||||
|
| 04 | **Por qué tiene sentido esta solución** — pares etiqueta/explicación | Argumentos de venta traducidos a beneficio |
|
||||||
|
| 05 | **Plan de mantenimiento opcional** — dos tarjetas (mensual vs anual, una `.featured`) con features `highlight` / `good` / `muted` | Siembra de recurrencia, con límites explícitos |
|
||||||
|
| 06 | **Estado de materiales del cliente** — dos tarjetas: materiales (`done`/`pend`) y decisiones pendientes (`pend`/`neutral`) | Transparencia y transferencia de responsabilidad |
|
||||||
|
| Footer | Datos de contacto + condiciones (50/50, CFDI, avance condicionado a materiales, cambios se cotizan aparte) | Blindaje comercial |
|
||||||
|
|
||||||
|
**El semáforo de la sección 01 es el activo intelectual más valioso de la plantilla.** Obliga a clasificar cada hallazgo por urgencia — exactamente lo que la base de conocimiento pide en las 3 Preguntas de Oro. Y el marcador `dot-green` ("ventaja existente") obliga a reconocer lo que el cliente ya hizo bien, que es el detalle que convierte una propuesta en una conversación entre pares.
|
||||||
|
|
||||||
|
**La sección 06 es la que resuelve la honestidad de la IA.** Ver §10.2.
|
||||||
|
|
||||||
|
### 2.3 La base de conocimiento (vault de Obsidian)
|
||||||
|
|
||||||
|
Sintetizada desde 91 transcripts. Lo que aporta al plugin, en orden de utilidad:
|
||||||
|
|
||||||
|
**Framework de diagnóstico (Fase 2 del playbook):**
|
||||||
|
- Las 3 Preguntas de Oro: (1) ¿cuál es el problema de **negocio**, no técnico? (2) ¿cuánto le está costando hoy? (3) ¿cuánto ingreso/ahorro esperas en 12 meses?
|
||||||
|
- Costo desglosado en 4 dimensiones: dinero directo · tiempo del equipo · costo de oportunidad · costo de error humano
|
||||||
|
- Filtro de viabilidad de 4 preguntas: costo de no hacer nada · ¿los datos existen HOY? · ¿quién es el usuario final real? · ¿qué pasa si funciona?
|
||||||
|
- The Mom Test: preguntas ancladas en el pasado ("¿cómo lo resolvieron la última vez?"), no en el futuro hipotético
|
||||||
|
|
||||||
|
**Framework de pricing:**
|
||||||
|
- `Valor Anual = (horas/mes × costo horario × 12) + costo anual de errores + ingresos recuperados`
|
||||||
|
- `Precio objetivo = 15% a 25% del Valor Anual`
|
||||||
|
- Marco VALOR (techo) / COSTO real (piso, 2-3× horas técnicas) / MERCADO
|
||||||
|
- Anclaje: presentar A (2× objetivo) → B (objetivo, se elige 60%) → C (50% del objetivo)
|
||||||
|
- 3 opciones cierran **16-28% más alto** que una sola
|
||||||
|
|
||||||
|
**Reglas transversales de comunicación** (del checklist de calidad):
|
||||||
|
1. Hablar de resultados, no de herramientas
|
||||||
|
2. **Usar las palabras del cliente** — registrar frases textuales del dolor y reutilizarlas
|
||||||
|
3. Investigar hechos pasados
|
||||||
|
4. 80% habla el cliente / 20% el consultor
|
||||||
|
5. **Distinguir evidencia de hipótesis** — etiquetar cifras como confirmadas, estimadas o pendientes de validar
|
||||||
|
6. No prometer solución antes de validar
|
||||||
|
7. Recapitular y confirmar
|
||||||
|
8. Comunicar riesgos temprano
|
||||||
|
|
||||||
|
**Separación de tres conceptos** (Sesión 4 de onboarding): proyecto inicial ≠ mantenimiento ≠ evolución/retainer. Cada uno con su alcance, precio y forma de contratación explícitos.
|
||||||
|
|
||||||
|
**Criterio ético de monetización:** una mejora posterior se propone sólo si hay evidencia de que resuelve un problema real, el cliente puede decidir libremente, y el nuevo alcance/precio/plazo quedan claros.
|
||||||
|
|
||||||
|
**Los 15 errores de monetización más caros** — de los cuales el plugin puede prevenir directamente: mandar propuesta con 1 sola opción (#4), aceptar scope creep sin orden de cambio (#7), entregar sin documentación (#8), no ofrecer retainer post-entrega (#9), vender habilidades técnicas en vez de resultados (#13).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Diagnóstico: la brecha entre los tres activos
|
||||||
|
|
||||||
|
Aplico a este proyecto el mismo semáforo de la plantilla.
|
||||||
|
|
||||||
|
### 🔴 Problema crítico — el contexto humano se captura y se descarta
|
||||||
|
|
||||||
|
`Cotizacion.observaciones` y `ServicioCotizado.notas` existen en el esquema, se llenan en el formulario (`CotizacionForm.tsx:1222`, placeholder literal *"Notas adicionales para la cotizacion..."*) y **no aparecen en ningún exportador**. Es información de discovery que el asesor ya se tomó el trabajo de escribir y que muere en la base de datos.
|
||||||
|
|
||||||
|
Esto no es sólo desperdicio: es la evidencia de que el sistema fue diseñado como calculadora y no como instrumento de venta consultiva. El plugin no tiene que crear el canal de entrada — **tiene que empezar a leerlo.**
|
||||||
|
|
||||||
|
### 🔴 Problema crítico — el pricing es de menú, la filosofía es de valor
|
||||||
|
|
||||||
|
El Cotizador calcula desde `ServicioCatalogo.precioBase`: precio fijo por servicio, sumado. Es pricing de catálogo. La base de conocimiento predica pricing por valor: 15-25% del valor anual del problema.
|
||||||
|
|
||||||
|
**No son incompatibles, pero hoy no se hablan.** El precio de catálogo es el piso operativo y la base de facturación (con CFDI, IVA, RFC — cosas que un porcentaje del valor anual no te da). El valor anual es el argumento que justifica ese precio ante el cliente.
|
||||||
|
|
||||||
|
El plugin no debe reemplazar uno por el otro. Debe **superponer la capa de valor sobre la capa contable**: tomar el total que el humano ya fijó y demostrar que representa X% del valor anual del problema. Si el asesor cotizó $50,000 MXN y la IA detecta en la transcripción un dolor de ~$400,000 MXN/año, la propuesta escribe *"la inversión equivale al 12.5% del costo anual del problema"* — sin mover el $50,000.
|
||||||
|
|
||||||
|
### 🟠 Área de mejora — la doble propuesta existe pero está subutilizada
|
||||||
|
|
||||||
|
`esDoble` + `opcionesMetadata` da dos opciones. La filosofía pide tres, con anclaje psicológico (cara primero). El plugin puede generar el **argumento** de las tres opciones en su documento narrativo aun cuando el documento económico sólo tenga dos, o recomendar al asesor cómo estructurar las dos que sí caben.
|
||||||
|
|
||||||
|
Aquí hay que ser honesto: **no** hay que forzar tres opciones si el proyecto no las admite. La Sesión 4 lo dice explícito: *"Cuando haya alternativas reales, usa tres opciones… las otras no son una trampa ni una lista de extras artificiales."*
|
||||||
|
|
||||||
|
### 🟠 Área de mejora — la base de conocimiento es de otro negocio
|
||||||
|
|
||||||
|
Esto es una tensión real que hay que documentar antes de que alguien la descubra a medio proyecto:
|
||||||
|
|
||||||
|
| | Base de conocimiento | Cotizador E3 |
|
||||||
|
|---|---|---|
|
||||||
|
| Negocio | Freelance/consultoría de automatización con Python | Consultoría de digitalización y marketing digital |
|
||||||
|
| Geografía | LATAM, cobro internacional | Querétaro, MX |
|
||||||
|
| Moneda | USD (Wise/Stripe/Payoneer) | MXN con IVA 16% y CFDI |
|
||||||
|
| Rangos | $300 – $25,000 USD | $7,490 – $90,800 MXN (paquetes) |
|
||||||
|
| Servicios | Scripts, RAG, dashboards, APIs | Ads, SEO, WordPress, CRM Bucéfalo, contenido |
|
||||||
|
| Recurrencia | Retainer de mantenimiento de software | Mensualidades de manejo de pauta y CRM |
|
||||||
|
|
||||||
|
**Los frameworks son transferibles; las cifras no.** La IA debe importar el *método* de diagnóstico, el *marco* de pricing y las *reglas* de comunicación — y jamás las tablas de precios en USD. Si el plugin sugiere "$1,800 USD" en una cotización de E3, es un bug, no una feature.
|
||||||
|
|
||||||
|
### 🔵 Oportunidad — el servidor MCP ya está construido
|
||||||
|
|
||||||
|
`api/app/mcp/server.py` (453 líneas) + `tools.py` (305 líneas) exponen el catálogo, las cotizaciones y la configuración a agentes. El plugin no necesita una capa de acceso a datos nueva: puede consumir la que existe. Y como los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` con paridad obligatoria, la IA puede **leer** totales sin riesgo de recalcularlos mal.
|
||||||
|
|
||||||
|
### 🔵 Oportunidad — la plantilla HTML es un contrato de salida listo
|
||||||
|
|
||||||
|
No hay que diseñar el formato del segundo documento. La plantilla ya define las secciones, la jerarquía y el vocabulario visual. El trabajo es mapear cada sección a su fuente de datos (ver §7) y dejar que la IA llene el contenido, no la estructura.
|
||||||
|
|
||||||
|
### 🟢 Ventaja existente — el asesor ya hace el trabajo difícil
|
||||||
|
|
||||||
|
El humano ya: califica al cliente, corre la reunión, elige servicios del catálogo, ajusta precios, decide el esquema de pago. El plugin no reemplaza ese juicio. **Se monta sobre él.** Esto reduce drásticamente el riesgo: si la IA falla, el documento manual sigue siendo entregable.
|
||||||
|
|
||||||
|
### 🟢 Ventaja existente — el vault ya tiene el system prompt esbozado
|
||||||
|
|
||||||
|
`sintesis/s8-Prompts-Plantillas.md` §1 tiene un system prompt maestro, §3 un prompt de diagnóstico de proyecto, §4 un prompt de propuesta comercial y §9 una plantilla de output estándar. No hay que empezar de cero — hay que **adaptar de USD/Python a MXN/marketing digital**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Filosofía: los 10 principios del plugin
|
||||||
|
|
||||||
|
Estos son los principios no negociables. Si una decisión de implementación los contradice, la decisión está mal.
|
||||||
|
|
||||||
|
### P1 · La IA no toca los números
|
||||||
|
|
||||||
|
La IA **lee** precios, totales, IVA, vigencia y financiamiento. **Nunca los calcula, propone ni modifica.** Todo número que aparezca en el documento de IA debe ser byte-por-byte el mismo que produjo `calculators.ts`.
|
||||||
|
|
||||||
|
*Por qué:* un precio inventado por la IA en un documento con el logo de E3 es un pasivo comercial y potencialmente fiscal. Además destruye la confianza en todo el resto del documento.
|
||||||
|
|
||||||
|
*Implicación técnica:* los totales se inyectan como datos ya calculados en el prompt y se instruye a la IA a citarlos literalmente; idealmente el render final los toma del objeto de cotización, no del texto generado.
|
||||||
|
|
||||||
|
### P2 · Dos documentos, dos trabajos, cero solapamiento
|
||||||
|
|
||||||
|
| | Documento 1 (manual, PDF/Excel) | Documento 2 (IA, HTML) |
|
||||||
|
|---|---|---|
|
||||||
|
| Autor | Asesor humano | IA, revisada por el asesor |
|
||||||
|
| Función | Contractual y económica | Consultiva y narrativa |
|
||||||
|
| Contenido | Servicios, precios, totales, condiciones | Diagnóstico, valor, argumentos, exclusiones, pendientes |
|
||||||
|
| Números | **Fuente de verdad** | Espejo, sólo citados |
|
||||||
|
| Si se contradicen | Gana este | Se corrige este |
|
||||||
|
| Vive en | `pdf-generator.ts` / `excel-builder.ts` | Nuevo generador |
|
||||||
|
|
||||||
|
El documento 2 es un **superconjunto narrativo** y un **subconjunto numérico** del documento 1.
|
||||||
|
|
||||||
|
### P3 · Toda afirmación es trazable
|
||||||
|
|
||||||
|
Cada dato que la IA escriba sobre el cliente debe apuntar a su origen: una línea de transcripción, un archivo de notas, un campo del formulario, o el catálogo. Si no hay origen, no se escribe.
|
||||||
|
|
||||||
|
*Implicación:* el contrato de salida (§8) lleva un campo `evidencia` por afirmación. El documento renderizado puede ocultarlo al cliente, pero el asesor debe poder auditarlo antes de enviar.
|
||||||
|
|
||||||
|
### P4 · Evidencia, estimación e hipótesis se etiquetan distinto
|
||||||
|
|
||||||
|
Directo de la regla 5 del checklist de calidad. Tres estados:
|
||||||
|
|
||||||
|
- **Confirmado** — el cliente lo dijo textualmente o hay un dato duro.
|
||||||
|
- **Estimado** — se derivó de algo que el cliente dijo, con un cálculo explícito.
|
||||||
|
- **Por validar** — hipótesis del consultor que aún nadie confirmó.
|
||||||
|
|
||||||
|
Un "estimado" presentado como "confirmado" es la forma más rápida de perder credibilidad en una reunión de presentación. La IA debe marcar cada cifra.
|
||||||
|
|
||||||
|
### P5 · Las palabras del cliente son sagradas
|
||||||
|
|
||||||
|
La sección de diagnóstico debe usar frases textuales del cliente, no paráfrasis corporativas. Si en la transcripción dice *"se nos van los clientes porque nadie contesta el WhatsApp el fin de semana"*, eso va entre comillas — no *"oportunidades de mejora en la gestión omnicanal de la comunicación"*.
|
||||||
|
|
||||||
|
*Regla operativa:* el bloque `.quote` de la sección 01 debe ser **siempre** una cita literal, nunca una síntesis de la IA.
|
||||||
|
|
||||||
|
### P6 · Se vende resultado, no herramienta
|
||||||
|
|
||||||
|
Error #13 de la lista de errores de monetización. Traducir toda capacidad técnica a tiempo recuperado, errores evitados, ingresos protegidos o tranquilidad operativa.
|
||||||
|
|
||||||
|
*Malo:* "Configuración de Google Ads con estructura SKAG y scripts de puja automatizada."
|
||||||
|
*Bueno:* "Cada peso de pauta se dirige a las búsquedas que sí compran, en lugar de repartirse entre términos que sólo generan clics. Reportamos costo por prospecto real, no impresiones."
|
||||||
|
|
||||||
|
### P7 · Lo que NO incluye vale tanto como lo que sí
|
||||||
|
|
||||||
|
La sección 03 de la plantilla no es un trámite: es prevención de scope creep, que según la base de conocimiento promedia **27% de sobrecosto** en proyectos sin control de alcance. La IA debe generar exclusiones **específicas del proyecto**, no genéricas.
|
||||||
|
|
||||||
|
*Malo:* "No incluye servicios no mencionados."
|
||||||
|
*Bueno:* "No incluye la migración del histórico de 4 años de pedidos que mencionaron en la reunión; eso se evalúa como fase 2 cuando el sistema base esté operando."
|
||||||
|
|
||||||
|
### P8 · Mantenimiento y evolución van separados, con límites por escrito
|
||||||
|
|
||||||
|
De la Sesión 4: *"No prometas mantenimiento gratuito indefinido y no vendas un retainer como si incluyera trabajo ilimitado."* La sección 05 debe listar explícitamente qué **no** cubre el plan (la plantilla ya tiene la clase `.plan-feat.muted` con ✕ para eso). El backlog de evolución se registra aparte y **no se compromete**.
|
||||||
|
|
||||||
|
### P9 · La incertidumbre se declara, no se rellena
|
||||||
|
|
||||||
|
Si falta información, la IA **no la inventa ni la omite silenciosamente**: la escribe en la sección 06 como material o decisión pendiente. Ver §10.2 — esto es el mecanismo central de honestidad del sistema.
|
||||||
|
|
||||||
|
### P10 · El asesor aprueba antes de enviar
|
||||||
|
|
||||||
|
El documento de IA nace en estado borrador. Nadie lo manda al cliente sin que un humano lo lea. El plugin es un copiloto de redacción, no un autopiloto de ventas.
|
||||||
|
|
||||||
|
*Corolario:* el flujo de UI debe hacer que revisar sea fácil (diff, semáforo de confianza, evidencia expandible), no que aprobar sea rápido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Insights de monetización que hay que codificar
|
||||||
|
|
||||||
|
Estos son los mecanismos concretos de la base de conocimiento que el plugin debe hacer operativos. Cada uno con su traducción a comportamiento del sistema.
|
||||||
|
|
||||||
|
| # | Insight de la base de conocimiento | Cómo lo codifica el plugin |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | El cliente describe síntomas; tu trabajo es diagnosticar la enfermedad | La sección 01 nunca repite la petición del cliente ("quiero una página web"); reformula al problema de negocio ("pierden prospectos porque no tienen dónde mandarlos desde los anuncios") |
|
||||||
|
| M2 | El costo del problema se desglosa en 4 dimensiones | La IA busca en la transcripción las 4 dimensiones y las presenta con su etiqueta de confianza; las que faltan van a la sección 06 como pendiente de validar |
|
||||||
|
| M3 | Precio = 15-25% del valor anual | La IA calcula el **ratio inverso**: dado el total que fijó el humano y el valor anual detectado, reporta qué porcentaje representa. Si sale <10%, señala al asesor que probablemente subcotizó. Si sale >40%, señala que va a haber objeción de precio |
|
||||||
|
| M4 | 3 opciones cierran 16-28% más alto | Si `esDoble = false`, el plugin sugiere al asesor un esquema de 2-3 opciones basado en los servicios ya elegidos (quitando o agregando del mismo catálogo, sin inventar servicios) |
|
||||||
|
| M5 | Anclaje: la opción cara primero | En el render de la sección de opciones, el orden es A (mayor) → B (recomendada, `.featured`) → C (esencial) |
|
||||||
|
| M6 | La objeción de precio se responde con alcance, no con descuento | El documento incluye, en notas internas para el asesor (no visibles al cliente), el "plan de recorte": qué servicios quitar primero si el cliente pide bajar el precio, ordenados por menor impacto en el resultado prometido |
|
||||||
|
| M7 | Anticipo 50% obligatorio antes de empezar | El footer lo enuncia siempre; no es opcional ni negociable por la IA |
|
||||||
|
| M8 | Updates 2×/semana aumentan confiabilidad percibida +125% | La propuesta incluye el compromiso de cadencia de comunicación como **parte del alcance**, no como cortesía |
|
||||||
|
| M9 | El retainer se ofrece a las 2 semanas post-entrega, no al inicio | La sección 05 presenta el mantenimiento como **opción con límites explícitos**, nunca como requisito ni como amenaza velada ("para que no se rompa") |
|
||||||
|
| M10 | Las 3 capas de documentación triplican la tarifa | Los entregables documentales (README ejecutivo, registro de decisiones, guía de mantenimiento — adaptados a marketing digital: manual de operación de campañas, bitácora de decisiones de segmentación, guía de qué puede ajustar el cliente solo) se listan como entregables con valor, no como anexos |
|
||||||
|
| M11 | Red flags del cliente | El plugin genera, en notas internas, una sección de riesgo: red flags detectadas en la transcripción (pidió descuento antes de entender el valor, no hay decisor presente, no hay cifra del problema, "hagan todo y luego vemos") |
|
||||||
|
| M12 | Separar proyecto / mantenimiento / evolución | Tres bloques distintos en el documento, con encabezados que lo hagan obvio. El backlog de evolución dice explícitamente "no comprometido en esta propuesta" |
|
||||||
|
| M13 | Criterio ético: sólo se propone lo que hay evidencia de que resuelve un problema real | La IA **no puede** agregar un servicio del catálogo que el asesor no eligió. Puede señalar en notas internas *"el cliente mencionó X, que el catálogo cubre con el servicio Y; considera si aplica"* — pero no lo mete en la propuesta |
|
||||||
|
|
||||||
|
### 5.1 El indicador de salud de la cotización
|
||||||
|
|
||||||
|
De M3 y M11 sale la feature más útil para el negocio y la más fácil de implementar: un semáforo interno, sólo para el asesor, que evalúa la cotización antes de enviarla.
|
||||||
|
|
||||||
|
| Señal | Cómo se calcula | Qué significa |
|
||||||
|
|---|---|---|
|
||||||
|
| Ratio precio/valor | `total / valor_anual_detectado` | <10% subcotizado · 15-25% en rango · >40% objeción probable |
|
||||||
|
| Dolor cuantificado | ¿hay al menos una cifra confirmada del costo del problema? | Si no: no hay ancla de valor; la propuesta va a competir por precio |
|
||||||
|
| Usuario final entrevistado | ¿la transcripción incluye a quien operará, no sólo al que firma? | Si no: riesgo de baja adopción |
|
||||||
|
| Decisor presente | ¿se identificó a quien autoriza el gasto? | Si no: riesgo de "voy a preguntar" |
|
||||||
|
| Exclusiones definidas | ¿hay al menos 3 exclusiones específicas? | Si no: riesgo de scope creep |
|
||||||
|
| Materiales pendientes | conteo de la sección 06 | Alto: la fecha de entrega es irreal |
|
||||||
|
| Red flags | conteo de señales de la lista | ≥2: evaluar si conviene el proyecto |
|
||||||
|
|
||||||
|
Esto no es un adorno. Es **business intelligence real** sobre el pipeline: agregado en el tiempo, dice qué tipo de cotizaciones cierran y cuáles no.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Anatomía del documento de IA
|
||||||
|
|
||||||
|
El documento 2 hereda la estructura de la plantilla HTML, con tres cambios. Marcados **[NUEVO]** los que no existen en la plantilla.
|
||||||
|
|
||||||
|
| Sección | Origen del contenido | Notas |
|
||||||
|
|---|---|---|
|
||||||
|
| **Hero** | `Cliente`, `Cotizacion.numero`, `fecha`, `vigencia`, `proyecto`, asesor | El subtítulo lo redacta la IA: una o dos oraciones que enmarcan el problema en lenguaje del cliente |
|
||||||
|
| **01 · Diagnóstico** | Transcripción + notas + observaciones | `.quote` = cita literal obligatoria. Filas con semáforo: al menos 1 rojo, 1 ámbar, 1 azul, 1 verde. Cada fila con etiqueta de confianza |
|
||||||
|
| **01b · Valor del problema** **[NUEVO]** | Transcripción, con las 4 dimensiones de costo | Tabla: dimensión / cifra / estado (confirmado/estimado/por validar) / evidencia. Cierra con el valor anual y el ratio de M3 |
|
||||||
|
| **02 · Resultados a lograr** **[NUEVO]** | Derivado del diagnóstico + entregables de los servicios elegidos | 3-5 resultados de negocio cuantificados. Es el puente entre problema y precio. La plantilla no lo tiene y la base de conocimiento lo pide en toda propuesta |
|
||||||
|
| **03 · Alcance y desglose económico** | `ServicioCotizado[]`, agrupado por fase y `tipoPago` | **Números literales del documento 1.** La IA sólo reescribe las descripciones en lenguaje de resultado (P6). Separar pago único de mensual, como ya hace el PDF |
|
||||||
|
| **04 · Opciones de inversión** | `esDoble` + `opcionesMetadata` | Sólo si hay opciones reales. Orden A → B(`.featured`) → C |
|
||||||
|
| **05 · Qué no incluye** | Transcripción (menciones fuera de alcance) + `opcionesMetadata.noIncluye` | Exclusiones específicas, nunca genéricas (P7) |
|
||||||
|
| **06 · Por qué tiene sentido** | `ServicioCotizado.beneficios` + diagnóstico | Cada beneficio amarrado a un hallazgo del diagnóstico. Si un beneficio no responde a ningún dolor detectado, se elimina |
|
||||||
|
| **07 · Plan de mantenimiento** | `PlanBucefaloCotizacion`, servicios `tipoPago: mensual`, `modeloCobro: retainer` | Con exclusiones explícitas (`.plan-feat.muted`). Mensual vs anual si aplica |
|
||||||
|
| **08 · Estado de materiales y decisiones** | Todo lo que la IA no pudo determinar + menciones de dependencias en la transcripción | **El canal de honestidad.** Ver §10.2 |
|
||||||
|
| **09 · Backlog de evolución** **[NUEVO]** | Fricciones mencionadas por el cliente que no entran en fase 1 | Con encabezado explícito: "registrado, no comprometido en esta propuesta" |
|
||||||
|
| **Footer** | `Configuracion` (logo, colores) + condiciones fijas | 50/50, CFDI, avance condicionado a materiales, cambios se cotizan aparte, MXN |
|
||||||
|
| **Anexo interno** **[NUEVO]** | Todo lo de §5.1 + M6 + M11 | **No se entrega al cliente.** Semáforo de salud, plan de recorte, red flags, evidencia completa. Puede ser una segunda página o un archivo aparte |
|
||||||
|
|
||||||
|
### 6.1 Nota sobre el anexo interno
|
||||||
|
|
||||||
|
Esta es la parte del diseño que más valor operativo tiene y la que más cuidado necesita. Un documento con "plan de recorte de precio" y "red flags del cliente" enviado por error al cliente es un incidente serio.
|
||||||
|
|
||||||
|
Recomendación: **archivo separado**, nombre distinto y visualmente inconfundible (fondo distinto, marca de agua "INTERNO — NO ENVIAR"). No una sección oculta del mismo archivo, no un `display: none`, no un comentario HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Arquitectura de ingesta de contexto
|
||||||
|
|
||||||
|
### 7.1 Las cuatro fuentes
|
||||||
|
|
||||||
|
| Fuente | Formato | Volumen típico | Confianza |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Transcripción de reunión** | `.txt`, `.md`, `.vtt`, `.srt` | 60 min ≈ 9,000 palabras ≈ 13K tokens | Alta para citas; media para cifras (la gente redondea al hablar) |
|
||||||
|
| **Notas del asesor** | `.md`, `.txt` | 200-2,000 palabras | Alta — es interpretación experta |
|
||||||
|
| **Campos del formulario** | `Cotizacion.observaciones`, `ServicioCotizado.notas` | 50-500 palabras | Alta — escrito con intención |
|
||||||
|
| **Cotización estructurada** | JSON desde Prisma/MCP | 3-5K tokens | **Absoluta** — es la fuente de verdad |
|
||||||
|
|
||||||
|
### 7.2 Precedencia en caso de conflicto
|
||||||
|
|
||||||
|
Cuando dos fuentes se contradicen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Cotización estructurada > Notas del asesor > Observaciones > Transcripción
|
||||||
|
```
|
||||||
|
|
||||||
|
*Razón:* la cotización es dato validado; las notas son interpretación experta posterior a la reunión; la transcripción es materia prima cruda con ruido de ASR, muletillas y correcciones en vivo ("son como 20 horas… no, más bien 30").
|
||||||
|
|
||||||
|
Si el conflicto es sobre una **cifra**, la IA no elige: lo reporta en la sección 08 como decisión pendiente. *"En la reunión se mencionaron 20 y 30 horas semanales en momentos distintos; confirmar la cifra antes de calcular el valor anual."*
|
||||||
|
|
||||||
|
### 7.3 Pipeline de preprocesamiento
|
||||||
|
|
||||||
|
Antes de que un token llegue al modelo:
|
||||||
|
|
||||||
|
1. **Normalización de formato** — VTT/SRT a texto plano con marcas de tiempo opcionales; Markdown se conserva.
|
||||||
|
2. **Sanitización de PII** — Esto es obligatorio, no opcional. Las transcripciones contienen nombres de empleados, comentarios sobre desempeño, cifras salariales, quejas sobre terceros. Se redactan nombres de personas que no son el interlocutor comercial, y se marca cualquier mención de temas laborales, legales o de salud como bloque excluido del documento final.
|
||||||
|
3. **Segmentación por hablante** cuando el formato lo permite — permite aplicar la regla 80/20 y detectar si se entrevistó al usuario operativo o sólo al decisor.
|
||||||
|
4. **Conteo de tokens** con `client.messages.countTokens` (nunca con estimadores de otros proveedores) para decidir si cabe entero o requiere segmentación.
|
||||||
|
5. **Detección de idioma** — si la transcripción tiene mezcla, se preserva; el documento final va en español.
|
||||||
|
|
||||||
|
### 7.4 Dónde se suben los archivos
|
||||||
|
|
||||||
|
Tres opciones, en orden de preferencia:
|
||||||
|
|
||||||
|
1. **Nuevo campo en la UI de cotización** — una zona de drop en la pestaña de observaciones. Los archivos se guardan como adjuntos vinculados a la `Cotizacion`. Requiere un modelo nuevo (`ContextoCotizacion`: `cotizacionId`, `tipo`, `nombreArchivo`, `contenido`/`ruta`, `createdAt`).
|
||||||
|
2. **Carpeta convenida en disco** — más simple para un MVP, peor para el despliegue en Coolify (contenedor sin volumen persistente por defecto). Sirve para probar localmente.
|
||||||
|
3. **Files API de Anthropic** (beta `files-api-2025-04-14`) — útil si se quiere pasar un PDF de reunión sin extraer texto; límite 500 MB. Requiere el header beta tanto en el upload como en el `messages.create` que lo referencia.
|
||||||
|
|
||||||
|
Para el MVP: opción 2 para validar la calidad del output; opción 1 para producción.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Contrato de salida
|
||||||
|
|
||||||
|
La IA **no genera HTML**. Genera un objeto estructurado que un renderizador convierte a HTML usando la plantilla. Esto es crítico por tres razones: valida la estructura antes de renderizar, permite que el asesor edite campo por campo, y evita que la IA rompa el CSS o inyecte markup arbitrario.
|
||||||
|
|
||||||
|
La API soporta esto nativamente con `output_config: { format: { type: "json_schema", schema: {...} } }` (structured outputs), disponible en Opus 5, Sonnet 5 y Haiku 4.5. En TypeScript hay helper de Zod: `zodOutputFormat()` + `client.messages.parse()`, que devuelve el objeto ya validado en `response.parsed_output`.
|
||||||
|
|
||||||
|
### 8.1 Forma conceptual del schema
|
||||||
|
|
||||||
|
```
|
||||||
|
PropuestaConsultiva
|
||||||
|
├─ meta: { numeroCotizacion, cliente, empresa, proyecto, vigencia, asesor }
|
||||||
|
├─ hero: { titulo, subtitulo }
|
||||||
|
├─ diagnostico:
|
||||||
|
│ ├─ citaLiteral: { texto, origen } ← obligatorio, literal
|
||||||
|
│ └─ hallazgos[]: { urgencia: rojo|ambar|azul|verde,
|
||||||
|
│ titulo, descripcion,
|
||||||
|
│ confianza: confirmado|estimado|por_validar,
|
||||||
|
│ evidencia }
|
||||||
|
├─ valorProblema:
|
||||||
|
│ ├─ dimensiones[]: { tipo: dinero|tiempo|oportunidad|error,
|
||||||
|
│ descripcion, montoAnualMXN|null,
|
||||||
|
│ confianza, evidencia }
|
||||||
|
│ ├─ valorAnualEstimadoMXN: number|null
|
||||||
|
│ └─ notaMetodologia: string ← cómo se llegó a la cifra
|
||||||
|
├─ resultados[]: { enunciado, metrica, lineaBase|null, periodoMedicion }
|
||||||
|
├─ alcance:
|
||||||
|
│ └─ items[]: { fase, categoria, nombre,
|
||||||
|
│ descripcionResultado, ← reescritura en lenguaje de beneficio
|
||||||
|
│ precioMXN, tipoPago, tiempoEntrega }
|
||||||
|
├─ totales: { subtotal, descuento, iva, total, moneda } ← COPIADO, nunca calculado
|
||||||
|
├─ opciones[]|null: { etiqueta, titulo, descripcion, paraQuien,
|
||||||
|
│ incluye[], noIncluye[], recomendada: bool }
|
||||||
|
├─ exclusiones[]: { texto, razon, origen } ← específicas del proyecto
|
||||||
|
├─ beneficios[]: { etiqueta, texto, hallazgoRelacionado } ← amarrado al diagnóstico
|
||||||
|
├─ mantenimiento|null:
|
||||||
|
│ └─ planes[]: { nombre, periodicidad, precioMXN, incluye[], noIncluye[], destacado }
|
||||||
|
├─ pendientes:
|
||||||
|
│ ├─ materiales[]: { texto, estado: recibido|pendiente, bloqueaEntrega: bool }
|
||||||
|
│ └─ decisiones[]: { texto, quienDecide, fechaSugerida }
|
||||||
|
├─ backlogEvolucion[]: { problema, impactoPosible, evidencia, momentoSugerido }
|
||||||
|
└─ interno: ← NUNCA en el documento del cliente
|
||||||
|
├─ salud: { ratioPrecioValor, dolorCuantificado, usuarioFinalEntrevistado,
|
||||||
|
│ decisorPresente, exclusionesDefinidas, materialesPendientes }
|
||||||
|
├─ planRecorte[]: { servicio, impactoEnResultado: bajo|medio|alto, ordenSugerido }
|
||||||
|
├─ redFlags[]: { señal, evidencia, severidad }
|
||||||
|
└─ huecos[]: { queFalta, porQueImporta, comoObtenerlo }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Restricciones del JSON Schema en la API
|
||||||
|
|
||||||
|
Hay que diseñar el schema con estos límites en mente:
|
||||||
|
|
||||||
|
- **Soportado:** tipos básicos, `enum`, `const`, `anyOf`, `allOf`, `$ref`/`$def`, formatos de string (`date`, `date-time`, `email`, `uri`, `uuid`).
|
||||||
|
- **No soportado:** esquemas recursivos, restricciones numéricas (`minimum`, `maximum`, `multipleOf`), restricciones de string (`minLength`, `maxLength`), restricciones complejas de array.
|
||||||
|
- **Obligatorio:** `additionalProperties: false` en todos los objetos.
|
||||||
|
- Los SDK de Python y TypeScript quitan automáticamente las restricciones no soportadas del schema enviado y las validan del lado del cliente — así que se pueden usar en Zod, sabiendo que la validación es local.
|
||||||
|
- Incompatible con citations (devuelve 400) y con prefill de mensaje de asistente.
|
||||||
|
- Primera petición con un schema nuevo tiene costo de compilación; después hay caché de 24h.
|
||||||
|
|
||||||
|
### 8.3 Reglas de validación post-generación (código, no IA)
|
||||||
|
|
||||||
|
Después de recibir el objeto y antes de renderizar:
|
||||||
|
|
||||||
|
1. `totales` debe ser **idéntico** al de la cotización en BD. Si no, se descarta el objeto y se registra el fallo. Sin excepciones.
|
||||||
|
2. Cada `alcance.items[].precioMXN` debe existir en `ServicioCotizado`. Ningún item nuevo, ningún precio distinto.
|
||||||
|
3. `diagnostico.citaLiteral.texto` debe encontrarse como substring (normalizado) en alguna fuente de contexto. Si no aparece, se marca como no verificada y se pide revisión.
|
||||||
|
4. Todo `hallazgo` con `confianza: confirmado` debe tener `evidencia` no vacía.
|
||||||
|
5. `interno` se separa del objeto antes de pasar al renderizador del documento del cliente.
|
||||||
|
6. Si `valorProblema.valorAnualEstimadoMXN` es `null`, el documento **no** puede afirmar un ratio precio/valor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Lineamientos del prompt
|
||||||
|
|
||||||
|
### 9.1 Estructura de capas
|
||||||
|
|
||||||
|
El prompt se arma en tres capas, ordenadas por estabilidad (esto importa para el caché — ver §11.3):
|
||||||
|
|
||||||
|
```
|
||||||
|
Capa 1 — ESTABLE (idéntica en toda cotización, cacheable)
|
||||||
|
· Identidad y rol
|
||||||
|
· Filosofía (los 10 principios de §4)
|
||||||
|
· Frameworks de diagnóstico y pricing
|
||||||
|
· Reglas de marca y confidencialidad
|
||||||
|
· Contrato de salida y reglas de validación
|
||||||
|
· Catálogo de servicios activo (snapshot)
|
||||||
|
· Ejemplos de buena y mala redacción
|
||||||
|
|
||||||
|
Capa 2 — SEMI-ESTABLE (por cliente o sector)
|
||||||
|
· Historial del cliente si es recurrente
|
||||||
|
· Notas del sector
|
||||||
|
|
||||||
|
Capa 3 — VOLÁTIL (por cotización)
|
||||||
|
· Cotización estructurada (JSON)
|
||||||
|
· Transcripción sanitizada
|
||||||
|
· Notas y observaciones
|
||||||
|
· Instrucción específica del asesor
|
||||||
|
```
|
||||||
|
|
||||||
|
Nada dinámico —fechas, IDs, timestamps— debe entrar en la capa 1. Una fecha interpolada en el system prompt invalida el caché de todo lo que viene después.
|
||||||
|
|
||||||
|
### 9.2 Reglas del system prompt
|
||||||
|
|
||||||
|
Adaptando el prompt maestro de `s8-Prompts-Plantillas.md` §1 al contexto de E3:
|
||||||
|
|
||||||
|
**Identidad.** Consultor senior de digitalización de negocios para PyMEs mexicanas. Redacta propuestas comerciales consultivas para Consultoría E3 (Querétaro, MX). No es vendedor: es diagnosticador.
|
||||||
|
|
||||||
|
**Reglas de contenido.**
|
||||||
|
1. Cita fuentes internamente para cada dato del cliente. Si no hay fuente, no se escribe.
|
||||||
|
2. Etiqueta toda cifra como confirmada, estimada o por validar.
|
||||||
|
3. Traduce toda capacidad técnica a dinero, tiempo, riesgo evitado o tranquilidad operativa.
|
||||||
|
4. Usa las palabras textuales del cliente en el diagnóstico.
|
||||||
|
5. Nunca proponer un servicio que el asesor no eligió.
|
||||||
|
6. Nunca calcular, sugerir ni modificar un precio.
|
||||||
|
7. Cuando falte información, escribirla como pendiente — jamás rellenarla.
|
||||||
|
8. Español de México, tono cercano y profesional, tuteo con el cliente cuando el registro de la reunión lo permita. Sin lenguaje corporativo vacío (*sinergias*, *holístico*, *stakeholders*, *ecosistema*, *disruptivo*).
|
||||||
|
9. Moneda: MXN. IVA 16%. Facturación CFDI.
|
||||||
|
10. El CRM se llama **Bucéfalo**. No se menciona ninguna otra plataforma de CRM.
|
||||||
|
11. E3 ofrece únicamente servicios digitales. Si en la reunión se pidió algo de marketing tradicional o diseño para imprenta, va a la sección de exclusiones aclarando que no es un servicio de E3.
|
||||||
|
|
||||||
|
**Prohibiciones explícitas.**
|
||||||
|
- No importar rangos de precio en USD de ninguna base de conocimiento.
|
||||||
|
- No incluir datos personales de empleados del cliente, información salarial, ni temas legales o de prestaciones. Si la reunión los tocó, se omiten del documento y se anota en el bloque interno que el tema debe manejarse por el canal correspondiente.
|
||||||
|
- No prometer resultados garantizados en pauta o posicionamiento (número de ventas, posición #1 en Google). Se prometen entregables, procesos y métricas de seguimiento.
|
||||||
|
- No prometer soporte ilimitado.
|
||||||
|
- No usar la palabra "garantizado" sobre resultados de mercado.
|
||||||
|
|
||||||
|
**Formato de razonamiento.** Con `claude-opus-5` el thinking está activo por defecto. Conviene dejarlo así: el diagnóstico requiere razonamiento multi-paso (leer transcripción → detectar dolor → cuantificar → cruzar con servicios elegidos → redactar). No hay que desactivarlo.
|
||||||
|
|
||||||
|
### 9.3 Descomposición en tareas
|
||||||
|
|
||||||
|
Un solo prompt monolítico va a producir un documento mediocre en todas sus partes. Mejor pipeline de 3 pasos, cada uno con su schema:
|
||||||
|
|
||||||
|
| Paso | Entrada | Salida | Modelo sugerido |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1 · Extracción** | Transcripción + notas | Hechos estructurados: dolores, cifras con confianza, citas literales, materiales mencionados, decisiones pendientes, red flags, menciones fuera de alcance | Opus 5 (`effort: high`) — es el paso que determina la calidad de todo lo demás |
|
||||||
|
| **2 · Diagnóstico y valoración** | Hechos del paso 1 + cotización estructurada + catálogo | Hallazgos con semáforo, valor del problema, resultados a lograr, ratio precio/valor, semáforo de salud | Opus 5 (`effort: high`) |
|
||||||
|
| **3 · Redacción** | Salida de pasos 1 y 2 | `PropuestaConsultiva` completa | Opus 5 (`effort: medium`) — con el análisis hecho, esto es principalmente escritura |
|
||||||
|
|
||||||
|
Ventajas de separar: cada paso se evalúa por separado, el paso 1 se puede cachear si sólo cambia la cotización, y el asesor puede corregir los hechos del paso 1 antes de que se propaguen.
|
||||||
|
|
||||||
|
### 9.4 Ejemplos en el prompt
|
||||||
|
|
||||||
|
La base de conocimiento es enfática en que los **ejemplos positivos** funcionan mejor que las prohibiciones. Hay que incluir en la capa 1 al menos:
|
||||||
|
|
||||||
|
- 2 ejemplos de diagnóstico bien escrito (uno con dolor cuantificado, uno donde falta la cifra y se declara)
|
||||||
|
- 2 ejemplos de descripción de servicio traducida a resultado
|
||||||
|
- 2 ejemplos de exclusión específica vs. genérica
|
||||||
|
- 1 ejemplo de sección 08 bien poblada
|
||||||
|
- 1 ejemplo de qué se ve cuando la transcripción es pobre (documento honesto con muchos pendientes, en lugar de documento inventado)
|
||||||
|
|
||||||
|
Ese último ejemplo es el más importante y el que se suele omitir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Guardarraíles y riesgos
|
||||||
|
|
||||||
|
### 10.1 Matriz de riesgos
|
||||||
|
|
||||||
|
| Riesgo | Severidad | Mitigación |
|
||||||
|
|---|---|---|
|
||||||
|
| **La IA inventa un precio** | Crítica | P1 + validación programática §8.3.1. Los totales se toman del objeto de cotización en el render, no del texto generado |
|
||||||
|
| **La IA inventa una cifra del cliente** | Crítica | Etiqueta de confianza obligatoria + campo `evidencia` + validación de que las citas existan en las fuentes |
|
||||||
|
| **Fuga de datos personales de empleados** | Crítica | Sanitización de PII previa (§7.3.2) + prohibición explícita en el prompt + revisión humana |
|
||||||
|
| **El anexo interno llega al cliente** | Crítica | Archivo separado con marca visual inconfundible, nunca sección oculta (§6.1) |
|
||||||
|
| **Promesa de resultado garantizado en pauta/SEO** | Alta | Prohibición explícita + lista de palabras vetadas en validación post-generación |
|
||||||
|
| **Importación de precios en USD desde el vault** | Alta | Prohibición explícita + validación: ningún monto del documento puede estar en USD ni ser un número que no exista en la cotización |
|
||||||
|
| **Se menciona una plataforma de CRM que no es Bucéfalo** | Alta | Regla de marca en prompt + validación por lista de términos prohibidos |
|
||||||
|
| **La propuesta contradice el PDF manual** | Alta | Los dos documentos se generan del mismo objeto de cotización; el de IA se regenera si la cotización cambia (invalidación por `updatedAt`) |
|
||||||
|
| **Scope creep porque las exclusiones son genéricas** | Media | Requerir mínimo 3 exclusiones con campo `origen` no vacío; si la IA no puede justificarlas, el documento se marca como incompleto |
|
||||||
|
| **La IA propone servicios que el asesor no eligió** | Media | P1 + validación §8.3.2. Las sugerencias van sólo al bloque interno |
|
||||||
|
| **Costo de API descontrolado** | Baja | Ver §11.4: el costo es despreciable frente al ticket. Aun así: caché de prefijo + límite de tamaño de transcripción + conteo previo de tokens |
|
||||||
|
| **El asesor aprueba sin leer** | Media | La UI debe hacer visible el semáforo de salud y el conteo de afirmaciones sin evidencia **antes** del botón de aprobar |
|
||||||
|
| **Rechazo por clasificador de seguridad** | Baja | Manejar `stop_reason: "refusal"` antes de leer `content`; activar `fallbacks: "default"`. Poco probable en este dominio pero el código debe no reventar |
|
||||||
|
|
||||||
|
### 10.2 El mecanismo central de honestidad
|
||||||
|
|
||||||
|
Este es el diseño del que depende que el sistema sea confiable, y merece su propia sección.
|
||||||
|
|
||||||
|
**El problema.** Todo generador de documentos con LLM tiene la misma tentación: cuando falta información, rellenar con plausible. Una propuesta comercial con una cifra inventada del negocio del cliente no es un error cosmético — es una mentira con el logo de E3 encima, que el cliente puede refutar en la reunión de presentación.
|
||||||
|
|
||||||
|
**La solución.** La plantilla HTML ya trae el canal de salida para la incertidumbre: la **sección 06, Estado de materiales del cliente**, con sus dos tarjetas y tres estados (`done` ✓, `pend` ○, `neutral` ·).
|
||||||
|
|
||||||
|
Se convierte esa sección en el destino obligatorio de todo hueco de información:
|
||||||
|
|
||||||
|
```
|
||||||
|
¿La IA no encontró el dato?
|
||||||
|
├─ ¿Es un material que el cliente debe entregar?
|
||||||
|
│ → sección 08, tarjeta "Materiales", estado pendiente
|
||||||
|
├─ ¿Es una decisión que el cliente debe tomar?
|
||||||
|
│ → sección 08, tarjeta "Decisiones", con quién decide y fecha sugerida
|
||||||
|
├─ ¿Es una cifra del negocio que nadie confirmó?
|
||||||
|
│ → sección 01b con confianza "por_validar", y punto a confirmar en la 08
|
||||||
|
└─ ¿Es información que el asesor necesita conseguir?
|
||||||
|
→ bloque interno, campo `huecos`, con "qué falta / por qué importa / cómo obtenerlo"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lo elegante del diseño:** un hueco de información deja de ser un defecto del documento y se vuelve **contenido de valor**. Un cliente que recibe una propuesta con una sección clara de "esto necesitamos de ustedes, y esto está pendiente de decidir" percibe rigor, no incompetencia. Y de paso transfiere la responsabilidad del retraso a donde corresponde — que es exactamente lo que dice el footer de la plantilla: *"El avance queda condicionado a la entrega de materiales por parte del cliente."*
|
||||||
|
|
||||||
|
**La regla de oro operativa:** una propuesta con 8 pendientes honestos es infinitamente mejor que una con 8 cifras inventadas. El prompt debe decir esto literalmente, y el ejemplo de §9.4 debe demostrarlo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Consideraciones técnicas
|
||||||
|
|
||||||
|
### 11.1 Elección de modelo
|
||||||
|
|
||||||
|
Recomendación: **`claude-opus-5`** para los tres pasos del pipeline.
|
||||||
|
|
||||||
|
| Modelo | Precio (entrada / salida por MTok) | Contexto | Cuándo usarlo aquí |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `claude-opus-5` | $5 / $25 | 1M | **Recomendado.** Los tres pasos. Es una cotización de decenas de miles de pesos; la diferencia de costo con Sonnet es de centavos |
|
||||||
|
| `claude-sonnet-5` | $3 / $15 (intro $2 / $10 hasta 2026-08-31) | 1M | Alternativa si el volumen crece mucho. Probar en el paso 3 (redacción) antes que en el 1 (extracción) |
|
||||||
|
| `claude-haiku-4-5` | $1 / $5 | 200K | Sólo para tareas mecánicas auxiliares: detectar idioma, clasificar tipo de archivo, sanitizar formato |
|
||||||
|
|
||||||
|
**Por qué Opus 5 y no el más barato:** la calidad del diagnóstico es el producto. Un diagnóstico mediocre produce una propuesta que compite por precio, y eso cuesta miles de pesos de margen. Ahorrar $0.15 USD por documento para perder 10% de ticket es una decisión de negocio terrible. El costo del modelo no es la variable a optimizar aquí.
|
||||||
|
|
||||||
|
**Detalles de la API que importan:**
|
||||||
|
- El thinking está **activo por defecto** en Opus 5 — no hay que configurarlo. `output_config: { effort: "high" }` para pasos 1 y 2, `"medium"` para el 3.
|
||||||
|
- `max_tokens` limita thinking + texto juntos. Para el paso 3, que produce un documento completo, hay que dar holgura: **usar streaming** con `max_tokens` alto (>16K obliga a streaming para no chocar con timeouts HTTP del SDK).
|
||||||
|
- No usar `temperature`, `top_p` ni `top_k` — están removidos en Opus 5 y devuelven 400.
|
||||||
|
- No usar prefill de mensaje de asistente — devuelve 400. Para forzar formato: structured outputs.
|
||||||
|
- Manejar `stop_reason: "refusal"` antes de leer `content`, y activar `fallbacks: "default"` con el header beta `server-side-fallback-2026-07-01`.
|
||||||
|
|
||||||
|
### 11.2 SDK y punto de integración
|
||||||
|
|
||||||
|
El proyecto es TypeScript/Next.js → **`@anthropic-ai/sdk`**. Nada de llamadas HTTP crudas ni de shims compatibles con otros proveedores.
|
||||||
|
|
||||||
|
Dos alternativas de arquitectura:
|
||||||
|
|
||||||
|
**Opción A — Route handler en Next.js.** `src/app/api/propuesta-ia/[id]/route.ts`, siguiendo el patrón de `export/pdf/[id]`. Más simple, un solo despliegue, comparte auth JWT y middleware. Riesgo: un paso de 3 llamadas con `effort: high` puede tardar minutos; requiere streaming al cliente o un patrón de job asíncrono (crear job → poll de estado → descargar).
|
||||||
|
|
||||||
|
**Opción B — En el servicio Python de `api/`.** Ya tiene MCP y auth por API key, y está pensado para agentes. Ventaja: la generación no bloquea el servidor web. Desventaja: duplica la lógica del prompt en otro lenguaje, y el proyecto ya sufre de duplicación de `calculators`.
|
||||||
|
|
||||||
|
**Recomendación:** Opción A con patrón de job. Un modelo nuevo (`PropuestaIA`: `cotizacionId`, `estado`, `objetoGenerado` JSON, `htmlRenderizado`, `costoTokens`, `createdAt`, `aprobadaPor`, `aprobadaAt`) permite historial, auditoría y regeneración sin perder versiones anteriores.
|
||||||
|
|
||||||
|
### 11.3 Caché de prompt
|
||||||
|
|
||||||
|
El caché es prefix match: cualquier cambio de un byte invalida todo lo que sigue. Orden de render: `tools` → `system` → `messages`.
|
||||||
|
|
||||||
|
Diseño para este caso:
|
||||||
|
|
||||||
|
| Contenido | Tamaño estimado | Cacheable | Notas |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Filosofía + frameworks + reglas + contrato + ejemplos | 12-20K tokens | **Sí** — breakpoint aquí | Congelado. Cambia sólo cuando se edita la filosofía |
|
||||||
|
| Catálogo de servicios activo | 3-6K tokens | **Sí** — segundo breakpoint | Cambia cuando se edita el catálogo; serializar con orden determinista (por `orden`, `id`) |
|
||||||
|
| Cotización estructurada | 3-5K tokens | No | Varía por cotización |
|
||||||
|
| Transcripción + notas | 5-20K tokens | No | Varía por cotización |
|
||||||
|
|
||||||
|
Notas operativas:
|
||||||
|
- El mínimo cacheable en Opus 5 es **512 tokens** (bajó desde 1024 en Opus 4.8). La capa 1 lo supera con holgura.
|
||||||
|
- Máximo 4 breakpoints por petición. Con dos alcanza.
|
||||||
|
- Lectura de caché ≈ 0.1× del precio de entrada; escritura 1.25× (TTL 5 min) o 2× (TTL 1h). Con 2+ peticiones el TTL de 5 minutos ya sale a favor — y el pipeline de 3 pasos garantiza al menos 3 peticiones seguidas con el mismo prefijo.
|
||||||
|
- **Invalidadores silenciosos a evitar:** `new Date()` en el system prompt, `JSON.stringify` de un objeto con orden de llaves no determinista, el número de cotización en la capa 1, cambio de modelo a media conversación.
|
||||||
|
- Verificar con `response.usage.cache_read_input_tokens`. Si sale 0 en peticiones repetidas con el mismo prefijo, hay un invalidador escondido.
|
||||||
|
|
||||||
|
### 11.4 Costos estimados
|
||||||
|
|
||||||
|
Escenario base por documento (una cotización con transcripción de 60 min):
|
||||||
|
|
||||||
|
```
|
||||||
|
Entrada: capa estable ~18K + catálogo ~5K + cotización ~4K + contexto ~15K ≈ 42K tokens
|
||||||
|
Salida: objeto estructurado + razonamiento ≈ 8K tokens
|
||||||
|
Peticiones: 3 (pipeline de §9.3), las 3 comparten prefijo de 23K
|
||||||
|
```
|
||||||
|
|
||||||
|
| Configuración | Costo estimado por documento |
|
||||||
|
|---|---|
|
||||||
|
| Opus 5 sin caché, 1 petición | ≈ $0.41 USD |
|
||||||
|
| Opus 5 con caché, pipeline de 3 pasos | ≈ **$0.30 – $0.55 USD** |
|
||||||
|
| Sonnet 5 con caché (precio intro) | ≈ $0.12 – $0.22 USD |
|
||||||
|
| Batch API (50% descuento) si se generan en lote nocturno | ≈ mitad de lo anterior |
|
||||||
|
|
||||||
|
A 20 MXN/USD: **entre $6 y $11 MXN por documento** con Opus 5.
|
||||||
|
|
||||||
|
**El insight de negocio:** el paquete PyME más barato del catálogo es de ~$7,490 MXN. El costo de generar su propuesta consultiva es **0.1% del ticket**. En el paquete de escalamiento ($90,800 MXN) es 0.01%.
|
||||||
|
|
||||||
|
Con 40 cotizaciones al mes: **~$12–22 USD/mes** (~$240–440 MXN). Menos que una comida.
|
||||||
|
|
||||||
|
Conclusión: **el costo del modelo no es una restricción de diseño.** Cualquier decisión que sacrifique calidad de output para ahorrar tokens está optimizando la variable equivocada. Optimizar para calidad, medir con `countTokens` para no llevarse sorpresas, y ya.
|
||||||
|
|
||||||
|
### 11.5 Renderizado del documento
|
||||||
|
|
||||||
|
La plantilla HTML es autocontenida (CSS inline, sin dependencias externas más que Google Fonts). Dos rutas:
|
||||||
|
|
||||||
|
1. **HTML directo** — un renderizador toma el objeto validado y produce el HTML usando la plantilla como base. Ventajas: fiel al diseño, imprimible con `@media print`, editable, ligero. Requiere sustituir Google Fonts por fuentes embebidas si se quiere funcionamiento offline o dentro de un PDF.
|
||||||
|
2. **PDF vía PDFKit** — reutiliza `pdf-generator.ts`. Coherente con el resto del sistema, pero la plantilla HTML tiene gradientes, radial-gradients y tipografía serif/sans mezclada que en PDFKit son trabajo considerable.
|
||||||
|
|
||||||
|
**Recomendación:** HTML como entregable primario (se ve mejor, se comparte por link o adjunto, y el cliente lo abre en el celular). Si se necesita PDF, imprimir el HTML desde el navegador o con un headless — no reimplementar el diseño en PDFKit.
|
||||||
|
|
||||||
|
Nota heredada del proyecto: en `pdf-generator.ts` hay un bug documentado de auto-page-break en el footer (`doc.text()` en `y > page.height - margins.bottom` dispara página nueva). Si se va por PDFKit, ese bug ya tiene workaround en el código: poner `margins.bottom = 0` temporalmente.
|
||||||
|
|
||||||
|
Los colores y el logo salen de `Configuracion` (`color_primario`, `color_secundario`, `logo_base64`), así que el documento respeta la marca sin hardcodear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Métricas de éxito del plugin
|
||||||
|
|
||||||
|
Si no se puede medir, no se sabe si sirvió. Estas son las métricas, separadas por qué preguntan.
|
||||||
|
|
||||||
|
### 12.1 ¿La IA escribe bien? (calidad del output)
|
||||||
|
|
||||||
|
| Métrica | Cómo medirla | Meta inicial |
|
||||||
|
|---|---|---|
|
||||||
|
| Tasa de edición del asesor | % de campos del objeto modificados antes de aprobar | <30% |
|
||||||
|
| Afirmaciones sin evidencia | Conteo por documento (validación automática) | 0 |
|
||||||
|
| Citas no verificables | Citas que no aparecen en las fuentes | 0 |
|
||||||
|
| Rechazos completos | Documentos descartados y regenerados desde cero | <10% |
|
||||||
|
| Tiempo de revisión | Minutos entre generación y aprobación | <15 min |
|
||||||
|
|
||||||
|
### 12.2 ¿Sirvió comercialmente? (impacto en el negocio)
|
||||||
|
|
||||||
|
| Métrica | Cómo medirla | Por qué importa |
|
||||||
|
|---|---|---|
|
||||||
|
| Tasa de cierre con vs. sin documento consultivo | Comparar `estado: aprobada` entre cotizaciones con y sin propuesta de IA | La prueba central de la tesis |
|
||||||
|
| Ticket promedio | Total promedio de cotizaciones aprobadas, ambos grupos | La base de conocimiento predice +16-28% con opciones bien presentadas |
|
||||||
|
| Objeciones de precio | Registrar en `observaciones` si el cliente pidió descuento | Un buen anclaje de valor debería reducirlas |
|
||||||
|
| Tiempo de ciclo | Días entre `fecha` y cambio a `aprobada`/`rechazada` | Una propuesta clara decide más rápido, en cualquier dirección |
|
||||||
|
| Scope creep | Cotizaciones que requirieron orden de cambio | El objetivo de las exclusiones específicas |
|
||||||
|
| Conversión a mensualidad | % de cotizaciones aprobadas que incluyen servicio `mensual` o plan Bucéfalo | El objetivo de la sección de mantenimiento |
|
||||||
|
|
||||||
|
**Advertencia metodológica:** con 40 cotizaciones al mes, cualquier comparación de tasa de cierre tarda meses en ser significativa, y hay mil variables confundidas (el asesor, el sector, la temporada, el tamaño del cliente). No conviene tomar decisiones grandes con 3 semanas de datos. Lo honesto es empezar midiendo §12.1, que sí es medible de inmediato, y dejar §12.2 acumular.
|
||||||
|
|
||||||
|
### 12.3 ¿Cuesta lo que debe? (operación)
|
||||||
|
|
||||||
|
- Costo por documento generado (tokens de entrada/salida × precio, guardado en `PropuestaIA.costoTokens`)
|
||||||
|
- Tasa de aciertos de caché (`cache_read_input_tokens / total`)
|
||||||
|
- Latencia p50 y p95 del pipeline completo
|
||||||
|
- Tasa de fallos: refusals, timeouts, validaciones rechazadas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Roadmap sugerido
|
||||||
|
|
||||||
|
Cada fase entrega algo usable. Ninguna requiere la siguiente para tener valor.
|
||||||
|
|
||||||
|
### Fase 0 — Ganancia inmediata sin IA (días)
|
||||||
|
|
||||||
|
**Imprimir `observaciones` y `notas` en el PDF y el Excel.** Es un cambio de pocas líneas en `pdf-generator.ts` y `excel-builder.ts`, y recupera información que ya se está capturando y descartando.
|
||||||
|
|
||||||
|
Esto no requiere nada de este documento. Debería hacerse ya, independientemente de si el plugin se construye.
|
||||||
|
|
||||||
|
### Fase 1 — Validación de calidad (1-2 semanas)
|
||||||
|
|
||||||
|
Sin UI, sin base de datos, sin despliegue. Un script que:
|
||||||
|
- Lee una cotización existente vía la API o Prisma
|
||||||
|
- Lee una transcripción de un archivo local
|
||||||
|
- Corre el pipeline de 3 pasos
|
||||||
|
- Escupe el objeto JSON y un HTML
|
||||||
|
|
||||||
|
**El entregable real no es el script: son 5-10 documentos generados sobre cotizaciones reales pasadas, revisados por el asesor.** Si esos documentos no son buenos, nada de lo demás importa y hay que iterar el prompt, no construir infraestructura.
|
||||||
|
|
||||||
|
Criterio de avance: el asesor dice *"esto lo mandaría a un cliente después de editarlo 10 minutos."*
|
||||||
|
|
||||||
|
### Fase 2 — Integración mínima (2-3 semanas)
|
||||||
|
|
||||||
|
- Modelo `PropuestaIA` y `ContextoCotizacion` en Prisma
|
||||||
|
- Zona de carga de archivos en `CotizacionForm.tsx`
|
||||||
|
- Route handler con patrón de job (crear → poll → descargar)
|
||||||
|
- Renderizador HTML desde la plantilla
|
||||||
|
- Vista de revisión con semáforo de salud y evidencia expandible
|
||||||
|
- Botón de aprobación que registra quién y cuándo
|
||||||
|
|
||||||
|
### Fase 3 — Inteligencia comercial (3-4 semanas)
|
||||||
|
|
||||||
|
- Anexo interno como archivo separado
|
||||||
|
- Semáforo de salud de §5.1 en el dashboard, agregado sobre todas las cotizaciones
|
||||||
|
- Sugerencia de esquema de 2-3 opciones cuando `esDoble = false`
|
||||||
|
- Alerta de subcotización (ratio precio/valor <10%)
|
||||||
|
- Métricas de §12.1 instrumentadas
|
||||||
|
|
||||||
|
### Fase 4 — Cierre del ciclo (después, con datos)
|
||||||
|
|
||||||
|
- Aprendizaje de las ediciones del asesor: qué corrige sistemáticamente → ajuste del prompt
|
||||||
|
- Comparación de tasa de cierre (sólo cuando haya volumen suficiente)
|
||||||
|
- Herramienta MCP nueva: `generar_propuesta_consultiva` en `api/app/mcp/tools.py`, para que un agente externo pueda pedirla
|
||||||
|
- Reutilización del documento en la conversación de retainer a las 2 semanas post-entrega (M9)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Decisiones abiertas
|
||||||
|
|
||||||
|
Cosas que hay que decidir antes de implementar y que este análisis no puede resolver solo.
|
||||||
|
|
||||||
|
| # | Decisión | Opciones | Recomendación |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | ¿Dónde se genera? | Route handler en Next.js vs. servicio Python | Next.js con patrón de job (§11.2) |
|
||||||
|
| D2 | ¿Dónde viven las transcripciones? | Adjuntos en BD · disco · Files API | Disco para Fase 1, adjuntos en BD para Fase 2 |
|
||||||
|
| D3 | ¿Un documento o dos archivos? | Documento del cliente + anexo interno separados, o uno solo | **Dos archivos.** El riesgo de fuga del anexo es demasiado alto (§6.1) |
|
||||||
|
| D4 | ¿HTML o PDF como entregable? | HTML · PDF · ambos | HTML primario; PDF por impresión del navegador si se necesita |
|
||||||
|
| D5 | ¿El plugin puede sugerir servicios? | Sí en la propuesta · sólo en el anexo interno · no | **Sólo en el anexo interno** (M13, criterio ético) |
|
||||||
|
| D6 | ¿Se regenera al cambiar la cotización? | Automático · manual con aviso · manual silencioso | Manual con aviso de desincronización visible |
|
||||||
|
| D7 | ¿Quién puede aprobar? | Cualquier asesor · sólo el dueño de la cotización · rol admin | El asesor dueño; admin puede aprobar cualquiera |
|
||||||
|
| D8 | ¿Se guarda la transcripción cruda o sólo la sanitizada? | Cruda · sanitizada · ambas | Sólo sanitizada en BD. La cruda no debe persistir con datos de empleados |
|
||||||
|
| D9 | ¿Se versiona el prompt? | Sí, con las propuestas apuntando a su versión · no | Sí. Sin esto no se puede saber si un cambio de prompt mejoró o empeoró |
|
||||||
|
| D10 | ¿Qué pasa si no hay transcripción? | Bloquear · generar con lo que haya · generar sólo el esqueleto | Generar con lo que haya, con la sección 08 muy poblada y aviso claro de confianza baja |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anexo A — Mapeo campo → sección
|
||||||
|
|
||||||
|
Referencia rápida para implementación.
|
||||||
|
|
||||||
|
| Sección del documento | Fuentes de datos |
|
||||||
|
|---|---|
|
||||||
|
| Hero | `Cliente.nombre`, `.empresa`, `Cotizacion.numero`, `.fecha`, `.vigencia`, `.proyecto`, `User.name` (asesor) |
|
||||||
|
| 01 Diagnóstico | Transcripción, `Cotizacion.observaciones`, `ServicioCotizado.notas`, archivos de notas |
|
||||||
|
| 01b Valor del problema | Transcripción (4 dimensiones), `totales.total` para el ratio |
|
||||||
|
| 02 Resultados | Derivado del diagnóstico + `ServicioCotizado.entregables` |
|
||||||
|
| 03 Alcance | `ServicioCotizado[]` (nombre, fase, tipoPago, precio, tiempoEntrega, entregables), `Categoria.nombre` |
|
||||||
|
| Totales | `calculators.ts` — copiados literalmente, `IVA_RATE`, descuentos |
|
||||||
|
| 04 Opciones | `Cotizacion.esDoble`, `.opcionesMetadata`, `ServicioCotizado.opcion` |
|
||||||
|
| 05 No incluye | Transcripción (menciones fuera de alcance), `opcionesMetadata[].noIncluye` |
|
||||||
|
| 06 Por qué tiene sentido | `ServicioCotizado.beneficios`, cruzado con hallazgos del diagnóstico |
|
||||||
|
| 07 Mantenimiento | `PlanBucefaloCotizacion`, servicios con `tipoPago: mensual`, `modeloCobro: retainer`, `Bono[]` |
|
||||||
|
| 08 Materiales y decisiones | Huecos detectados + dependencias mencionadas en la transcripción |
|
||||||
|
| 09 Backlog | Fricciones mencionadas fuera del alcance de fase 1 |
|
||||||
|
| Footer | `Configuracion.logo_base64`, `.color_primario`, `.color_secundario` + condiciones fijas |
|
||||||
|
| Financiamiento (si aplica) | `FinanciamientoPlan[]`, `Cotizacion.incluirFinanciamiento`, `calcularFinanciamiento()` |
|
||||||
|
| Anexo interno | Todo lo anterior + `RegistroHoras` si aplica + análisis de §5.1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anexo B — Checklist de calidad antes de enviar
|
||||||
|
|
||||||
|
Adaptado de `Onboarding-Cliente/00-Guia-de-Uso-y-Checklist-Calidad.md`. Este checklist debe estar visible en la UI de revisión, no enterrado en un documento.
|
||||||
|
|
||||||
|
**Problema y valor**
|
||||||
|
- [ ] El problema está redactado en lenguaje del cliente, sin jerga técnica
|
||||||
|
- [ ] Hay al menos un ejemplo real y reciente del problema, citado
|
||||||
|
- [ ] Se estimó el costo anual, con las cifras etiquetadas por confianza
|
||||||
|
- [ ] La métrica de éxito tiene línea base, resultado deseado y periodo
|
||||||
|
- [ ] Consta si se habló con el usuario operativo, no sólo con quien autoriza
|
||||||
|
|
||||||
|
**Viabilidad y alcance**
|
||||||
|
- [ ] Se sabe dónde viven los datos/accesos y quién los autoriza
|
||||||
|
- [ ] La fase 1 resuelve un cuello de botella prioritario, no todos
|
||||||
|
- [ ] Está explícito: incluido ahora / excluido ahora / backlog futuro
|
||||||
|
- [ ] Hay responsables del cliente y decisiones pendientes identificadas
|
||||||
|
- [ ] Está definido qué significa "terminado" y quién acepta
|
||||||
|
|
||||||
|
**Propuesta comercial**
|
||||||
|
- [ ] Si hay opciones, cambian alcance o nivel de servicio — no sólo el precio
|
||||||
|
- [ ] La opción recomendada sí cumple el resultado de negocio definido
|
||||||
|
- [ ] El precio se sustenta en valor, costo real y mercado
|
||||||
|
- [ ] El mantenimiento aparece con servicios concretos, límites y tiempos de respuesta
|
||||||
|
- [ ] Se presentará en llamada, no como cifra suelta por mensajería
|
||||||
|
|
||||||
|
**Integridad del documento generado**
|
||||||
|
- [ ] Todo número coincide con el documento manual
|
||||||
|
- [ ] Cero afirmaciones sin evidencia
|
||||||
|
- [ ] Cero cifras marcadas como confirmadas sin origen
|
||||||
|
- [ ] Ningún dato personal de empleados del cliente
|
||||||
|
- [ ] Ningún monto en USD
|
||||||
|
- [ ] Ninguna promesa de resultado garantizado
|
||||||
|
- [ ] El CRM se menciona como Bucéfalo
|
||||||
|
- [ ] El anexo interno está en archivo separado y no se va a enviar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anexo C — Cruce con la base de conocimiento
|
||||||
|
|
||||||
|
Para profundizar durante la implementación:
|
||||||
|
|
||||||
|
- **Preguntas de diagnóstico completas:** `Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md`
|
||||||
|
- **Playbooks de pricing y cierre:** `08-Monetizacion-Onboarding-Pricing-Seguimiento.md`
|
||||||
|
- **Errores a evitar:** `01-Errores-a-Evitar.md`, `sintesis/s3-Errores-Fatales.md`
|
||||||
|
- **Modelos de pricing detallados:** `sintesis/s2-Pricing-Cobranza.md`
|
||||||
|
- **Prompts y plantillas base:** `sintesis/s8-Prompts-Plantillas.md` (§1 system prompt, §3 diagnóstico, §4 propuesta, §9 output estándar)
|
||||||
|
- **Las cuatro sesiones de onboarding:** `Onboarding-Cliente/01` a `04`, con `00-Guia-de-Uso-y-Checklist-Calidad.md` como criterio de calidad
|
||||||
|
- **Playbook maestro:** `Guia_Onboarding_Cliente_y_Ruta_de_Trabajo.md`
|
||||||
|
- **Paquetes del catálogo:** `docs/paquetes-recomendados-pyme.md` (este repo)
|
||||||
|
- **Convenciones del proyecto:** `AGENTS.md` (este repo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cierre
|
||||||
|
|
||||||
|
El Cotizador E3 ya sabe cuánto cobrar. Lo que le falta es explicar por qué vale.
|
||||||
|
|
||||||
|
Ese "por qué" no se inventa: se extrae de lo que el cliente ya dijo en la reunión, de lo que el asesor ya anotó en el campo de observaciones, y del catálogo que ya está armado. Está todo ahí, disperso y sin usar.
|
||||||
|
|
||||||
|
El plugin no es un generador de texto bonito. Es un **traductor**: convierte una lista de precios en un argumento de negocio, usando material que ya existe y sin tocar un solo número. Y cuando no tiene material suficiente, lo dice — porque una propuesta con pendientes honestos cierra mejor que una con cifras inventadas, y porque el día que un cliente refute un dato inventado, el costo no lo paga el modelo: lo paga la marca.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento de análisis previo a implementación · Consultoría E3 · 2026-07-28*
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,511 @@
|
|||||||
|
# Propuesta consultiva con IA — Diseño de Fase 0 y Fase 1
|
||||||
|
|
||||||
|
> **Qué es este documento:** la especificación técnica cerrada de las dos primeras fases del
|
||||||
|
> plugin descrito en [`docs/BI-propuesta-consultiva-IA.md`](../../BI-propuesta-consultiva-IA.md).
|
||||||
|
> Aquel documento es el análisis de negocio y dice explícitamente que no es un spec. Este sí lo es.
|
||||||
|
>
|
||||||
|
> **Alcance:** Fase 0 (recuperar el contexto humano que hoy se descarta, más los bloqueadores que
|
||||||
|
> el análisis destapó) y Fase 1 (script local que valida si la IA escribe documentos utilizables).
|
||||||
|
> Fases 2-4 quedan fuera y tendrán su propio ciclo.
|
||||||
|
>
|
||||||
|
> **Fecha:** 2026-07-28 · **Estado:** Fase 0 desplegada en producción · Fase 1 pendiente
|
||||||
|
> **Base verificada:** commit `26e6d3a9`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Registro de ejecución — Fase 0 (2026-07-28)
|
||||||
|
|
||||||
|
Fase 0 está en producción. Tres cosas salieron distinto de lo diseñado y este documento
|
||||||
|
quedaría mintiendo si no se dijeran.
|
||||||
|
|
||||||
|
### 0.1 El movimiento de datos (Despliegue B) se canceló, no se pospuso
|
||||||
|
|
||||||
|
El §4.3 describe un `UPDATE` que mueve `observaciones` → `observacionesInternas`. **No se
|
||||||
|
va a ejecutar nunca.** Al inspeccionar producción antes de migrar, la única fila con
|
||||||
|
`observaciones` no vacías resultó ser `UJ2606AG777` (estado `aprobada`), y su texto son
|
||||||
|
condiciones de pago dirigidas al cliente en segunda persona: *"llevamos una bitácora de
|
||||||
|
horas con acceso para ti; a fin de mes te enviamos el reporte… solo pagas las horas
|
||||||
|
efectivamente trabajadas."*
|
||||||
|
|
||||||
|
Aplicar el `UPDATE` conservador habría ocultado condiciones ya acordadas de un documento
|
||||||
|
emitido. Esa fila ya estaba clasificada correctamente. La migración quedó reducida a
|
||||||
|
`ADD COLUMN IF NOT EXISTS`: cero filas modificadas. Verificado tras el despliegue —
|
||||||
|
conteos `2/3/59/5/2` idénticos al respaldo y cero filas en `observacionesInternas`.
|
||||||
|
|
||||||
|
**Lección para el futuro: mirar el dato real antes de diseñar su migración.** El diseño
|
||||||
|
"conservador" era el equivocado para estos datos.
|
||||||
|
|
||||||
|
### 0.2 El servidor MCP se retiró en vez de asegurarse
|
||||||
|
|
||||||
|
El §4.1 diseñaba Fase 0-A como *"aplicar `require_auth` + lista blanca de columnas"*. Eso
|
||||||
|
se implementó y se verificó en producción (401 sin credencial). Después se descubrió que,
|
||||||
|
**con credencial válida, el endpoint devolvía 500**: el código construía un
|
||||||
|
`StreamableHTTPServerTransport` por petición sin manejo de sesión. Llevaba roto desde antes
|
||||||
|
de este trabajo — lo que estaba expuesto era la puerta abierta de un cuarto averiado.
|
||||||
|
|
||||||
|
Dado que nadie lo usaba, se retiró por completo (endpoint, paquete `api/app/mcp/` y la
|
||||||
|
dependencia). Es una mitigación más fuerte que autenticarlo. Recuperable con
|
||||||
|
`git show edb500f5:api/app/mcp/server.py`.
|
||||||
|
|
||||||
|
### 0.3 Una dependencia sin fijar tumbó el API 50 minutos
|
||||||
|
|
||||||
|
`api/requirements.txt` tenía `mcp>=1.0.0`. La primera reconstrucción en dos semanas tomó
|
||||||
|
`mcp` 2.0.0, que eliminó `Server.list_tools()`. El import lanzó `AttributeError` al
|
||||||
|
arrancar y el contenedor quedó en crash-loop.
|
||||||
|
|
||||||
|
Lo agravó que `main.py` capturara solo `ImportError` alrededor del montaje del MCP: un
|
||||||
|
`AttributeError` se escapó y tumbó también el REST, por un componente opcional.
|
||||||
|
|
||||||
|
Once de las doce dependencias eran rangos `>=` sin techo. **Todas se fijaron a versión
|
||||||
|
exacta** — las que corrían sanas en producción. El `web` (Next.js) nunca estuvo caído:
|
||||||
|
tiene `package-lock.json` y es reproducible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Por qué el alcance es este
|
||||||
|
|
||||||
|
El documento de negocio describe ocho subsistemas. Construirlos de una vez significa un mes de
|
||||||
|
trabajo antes de saber si la tesis funciona. Su propio §13 argumenta la salida:
|
||||||
|
|
||||||
|
> *"El entregable real no es el script: son 5-10 documentos generados sobre cotizaciones reales,
|
||||||
|
> revisados por el asesor. Si esos documentos no son buenos, nada de lo demás importa y hay que
|
||||||
|
> iterar el prompt, no construir infraestructura."*
|
||||||
|
|
||||||
|
Se adopta ese criterio. Fase 1 termina cuando el asesor puede decir *"esto lo mandaría a un
|
||||||
|
cliente después de editarlo 10 minutos"* — o cuando queda claro que no.
|
||||||
|
|
||||||
|
El análisis previo a este spec encontró cuatro problemas que el documento de negocio no anticipaba.
|
||||||
|
Tres son bloqueadores y entran al alcance; el cuarto cambia dónde vive un dato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hallazgos que modifican el plan original
|
||||||
|
|
||||||
|
Los cuatro están verificados en disco, no inferidos.
|
||||||
|
|
||||||
|
### 2.1 🔴 El endpoint MCP publica todas las cotizaciones sin autenticación
|
||||||
|
|
||||||
|
| Eslabón | Evidencia |
|
||||||
|
|---|---|
|
||||||
|
| `POST /mcp` no valida nada | `api/main.py:107` — sin un solo `Depends()`. Cero coincidencias de `require_auth` en el archivo |
|
||||||
|
| La función de auth existe y no se usa ahí | `api/app/auth.py:45` define `require_auth` |
|
||||||
|
| La herramienta devuelve la fila completa | `api/app/mcp/server.py:250` — `SELECT c.*` seguido de `dict(cot)`, sin lista blanca |
|
||||||
|
| Tiene dominio público en producción | `docker-compose.coolify.yml:67` — `SERVICE_FQDN_API_8000` |
|
||||||
|
|
||||||
|
El agujero **ya existe**: precios, catálogo y datos de clientes son legibles hoy por cualquiera.
|
||||||
|
Lo relevante para este spec es que `SELECT c.*` publicaría la columna nueva de notas internas sin
|
||||||
|
que nadie toque `server.py`. Entra al alcance como **Fase 0-A**, previa a todo lo demás.
|
||||||
|
|
||||||
|
### 2.2 🟠 No existe una fuente de verdad para los totales
|
||||||
|
|
||||||
|
La regla de validación §8.3.1 del documento de negocio exige comparar contra *"el total de la
|
||||||
|
cotización en BD"*. No hay tal cosa:
|
||||||
|
|
||||||
|
- Ninguna función devuelve los totales de una cotización. El cálculo está duplicado con
|
||||||
|
`.filter().reduce()` en **siete** consumidores (PDF, Excel, detalle, PreciosEditables, el
|
||||||
|
formulario, la lista y el dashboard), con criterios que no coinciden.
|
||||||
|
- El IVA está hardcodeado como `* 1.16` en `pdf-generator.ts:328-329` y `excel-builder.ts:377-378`,
|
||||||
|
ignorando `IVA_RATE`.
|
||||||
|
- El flag `Cotizacion.incluirIva` no se respeta en esos cálculos.
|
||||||
|
|
||||||
|
Hoy el PDF y el Excel de una misma cotización pueden discrepar y nada lo detecta. Entra como
|
||||||
|
**Fase 0-B**.
|
||||||
|
|
||||||
|
### 2.3 🔴 El Excel es un documento del cliente, no una herramienta interna
|
||||||
|
|
||||||
|
Este hallazgo corrigió un supuesto equivocado del diseño inicial, que proponía poner las notas
|
||||||
|
internas en el Excel.
|
||||||
|
|
||||||
|
| Evidencia | Ubicación |
|
||||||
|
|---|---|
|
||||||
|
| Banner con la razón social de E3 | `excel-builder.ts:165` |
|
||||||
|
| `"En atencion a:"` + nombre del cliente | `excel-builder.ts:181` |
|
||||||
|
| Nota legal de precios, IVA y vigencia | `excel-builder.ts:400` |
|
||||||
|
| Razón social + domicilio fiscal al pie de cada hoja de detalle | `excel-builder.ts:539-547` |
|
||||||
|
| Nombre de archivo `{empresa} - {cliente} - {numero}.xlsx` | `src/app/api/export/excel/[id]/route.ts:76` |
|
||||||
|
| Botón en la misma fila que Preview PDF y PDF | `src/app/(app)/cotizaciones/[id]/page.tsx:93-95` |
|
||||||
|
|
||||||
|
Escenario de fallo: el asesor exporta ambos, caen en Descargas con el mismo prefijo, los arrastra
|
||||||
|
juntos al correo, y el cliente abre la última pestaña y lee el plan de recorte de precio y las red
|
||||||
|
flags sobre él mismo.
|
||||||
|
|
||||||
|
**Consecuencia:** en Fase 0 las notas internas no entran a **ningún** exportador. Viven solo en la
|
||||||
|
app. Es la misma garantía estructural que ya tiene el PDF.
|
||||||
|
|
||||||
|
### 2.4 🟠 Las transcripciones se sincronizarían a la nube de MEGA
|
||||||
|
|
||||||
|
El repo vive en `H:\MegaSync\Proyectos\Cotizador`. El `.megaignore` actual excluye únicamente
|
||||||
|
`.next`, `node_modules` y `.turbo`. Cualquier transcripción guardada dentro del repo se sube a la
|
||||||
|
nube: nombres de empleados, comentarios de desempeño, cifras salariales. `.gitignore` protege el
|
||||||
|
repositorio, no la sincronización.
|
||||||
|
|
||||||
|
**Consecuencia:** `contexto/` y `salidas/` van a `.gitignore` **y** a `.megaignore`, y la exclusión
|
||||||
|
se verifica empíricamente antes de colocar material real.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Decisiones cerradas
|
||||||
|
|
||||||
|
Ninguna de estas se re-litiga durante la implementación.
|
||||||
|
|
||||||
|
| # | Decisión | Resolución | Razón |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | Alcance del ciclo | Fase 0 + Fase 1 | Validar calidad antes de construir infraestructura |
|
||||||
|
| D2 | Separar interno de visible | Campo nuevo `observacionesInternas`; `observaciones` pasa a ser texto del cliente | Hace la fuga estructuralmente imposible en vez de depender de un marcador que se olvida |
|
||||||
|
| D3 | Dónde viven las notas internas | Solo en la app. Ningún exportador | §2.3 |
|
||||||
|
| D4 | Forzar el schema sin structured outputs | Tool-calling: `input_schema` de la herramienta = contrato. Zod valida siempre | MiniMax no soporta `output_config` |
|
||||||
|
| D5 | Proveedor | MiniMax-M3 vía endpoint compatible con Anthropic | Decisión del negocio (Token Plan contratado) |
|
||||||
|
| D6 | Sanitización de PII | Filtro de **salida** solamente | El modelo necesita el contexto completo para diagnosticar; el riesgo real es lo que llega al cliente |
|
||||||
|
| D7 | Seguridad del MCP | Cerrar el agujero primero: `require_auth` + lista blanca de columnas | §2.1 |
|
||||||
|
| D8 | Totales | Función canónica `calcularTotalesCotizacion()` + migrar PDF y Excel | §2.2 |
|
||||||
|
| D9 | Base del ratio precio/valor | Primer año **con IVA** (único + mensual × 12) | Es el desembolso real a 12 meses; hace comparable el ratio contra un valor *anual* |
|
||||||
|
| D10 | Nombres de empleados detectados | Advertir, no bloquear | Un bloqueo heurístico produce falsos positivos constantes y enseña a ignorar la alerta |
|
||||||
|
| D11 | Ubicación de transcripciones | Dentro del repo, en `.gitignore` **y** `.megaignore` | §2.4 |
|
||||||
|
| D12 | Migración de datos | Dos despliegues: A aditivo, B mueve datos | §4.3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Fase 0 — Recuperar el contexto humano
|
||||||
|
|
||||||
|
Cuatro bloques. A y B son prerrequisitos de C.
|
||||||
|
|
||||||
|
### 4.1 Fase 0-A · Cerrar el MCP
|
||||||
|
|
||||||
|
> **Superado por §0.2.** Esto se implementó tal cual y se verificó (401 sin credencial),
|
||||||
|
> pero después el servidor MCP se retiró por completo. Se conserva el diseño original
|
||||||
|
> porque documenta el agujero que existía y por qué importaba.
|
||||||
|
|
||||||
|
1. Aplicar `require_auth` (`api/app/auth.py:45`) al endpoint `POST /mcp` en `api/main.py:107`.
|
||||||
|
2. Sustituir el `SELECT c.*` de `_obtener_cotizacion` (`api/app/mcp/server.py:250`) por una lista
|
||||||
|
blanca de columnas explícita, con el mismo criterio que el REST ya aplica vía
|
||||||
|
`CotizacionResponse` (`api/app/models/cotizacion.py:130-152`).
|
||||||
|
3. Auditar el resto de `server.py` en busca de otros `SELECT *` y darles el mismo tratamiento.
|
||||||
|
|
||||||
|
**Criterio de aceptación:** una petición `POST /mcp` sin credencial devuelve 401, y
|
||||||
|
`obtener_cotizacion` con credencial válida no incluye `observacionesInternas` en su salida.
|
||||||
|
|
||||||
|
### 4.2 Fase 0-B · Totales canónicos
|
||||||
|
|
||||||
|
Escribir en `src/lib/calculators.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
calcularTotalesCotizacion(cotizacion): {
|
||||||
|
subtotalUnico, subtotalMensual,
|
||||||
|
ivaUnico, ivaMensual,
|
||||||
|
totalUnico, totalMensual,
|
||||||
|
totalPrimerAnio, // totalUnico + totalMensual * 12, con IVA — base del ratio (D9)
|
||||||
|
moneda
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas: usa `IVA_RATE`, respeta `Cotizacion.incluirIva`, y solo suma partidas con
|
||||||
|
`seleccionado: true`. Para cotizaciones dobles se apoya en `calcularTotalesOpcion`.
|
||||||
|
|
||||||
|
Migrar a ella `pdf-generator.ts` y `excel-builder.ts`, eliminando los `* 1.16`. Los otros cinco
|
||||||
|
consumidores se migran en un ciclo posterior; se documenta la deuda.
|
||||||
|
|
||||||
|
**Criterio de aceptación:** para un conjunto de cotizaciones reales, el total del PDF y el del
|
||||||
|
Excel coinciden dígito por dígito, y una cotización con `incluirIva: false` no muestra IVA en
|
||||||
|
ninguno.
|
||||||
|
|
||||||
|
### 4.3 Fase 0-C · El campo de contexto humano
|
||||||
|
|
||||||
|
**Despliegue A — solo aditivo.**
|
||||||
|
|
||||||
|
- `ALTER TABLE "Cotizacion" ADD COLUMN "observacionesInternas" TEXT;` — sin `UPDATE`.
|
||||||
|
- El formulario muestra dos textareas visualmente inconfundibles: *"Observaciones (las ve el
|
||||||
|
cliente)"* y *"Notas internas (no salen de la app)"*, con badge de color distinto.
|
||||||
|
- La vista de detalle muestra ambos campos; el histórico aparece etiquetado como
|
||||||
|
*"Observaciones (histórico, sin clasificar)"*.
|
||||||
|
- El PDF imprime **solo** `observaciones`. `CotizacionPDFData` (`pdf-generator.ts:31-53`) no
|
||||||
|
declara `observacionesInternas` — la garantía es estructural, no disciplinaria.
|
||||||
|
- Ningún exportador recibe el campo interno.
|
||||||
|
|
||||||
|
**Despliegue B — movimiento de datos. CANCELADO, ver §0.1.** El `UPDATE` de abajo no se
|
||||||
|
ejecutó ni se va a ejecutar: la única fila afectada contenía texto dirigido al cliente y ya
|
||||||
|
estaba donde debía. Se conserva por si algún día aparece una instalación con datos mal
|
||||||
|
clasificados.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE "Cotizacion"
|
||||||
|
SET "observacionesInternas" = "observaciones",
|
||||||
|
"observaciones" = NULL
|
||||||
|
WHERE "observaciones" IS NOT NULL
|
||||||
|
AND btrim("observaciones") <> ''
|
||||||
|
AND "observacionesInternas" IS NULL; -- guarda de idempotencia, obligatoria
|
||||||
|
```
|
||||||
|
|
||||||
|
Antes de correr B, exportar y guardar **fuera de MegaSync**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
COPY (SELECT id, numero, estado, observaciones FROM "Cotizacion"
|
||||||
|
WHERE observaciones IS NOT NULL AND btrim(observaciones) <> '')
|
||||||
|
TO STDOUT WITH CSV HEADER;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ese CSV es el `down` que Prisma no da: no hay migraciones de reversa en el repo ni servicio de
|
||||||
|
backup en `docker-compose.coolify.yml`.
|
||||||
|
|
||||||
|
**Por qué dos despliegues.** Si A mueve los datos y el despliegue falla, Coolify redespliega la
|
||||||
|
imagen anterior, el código viejo lee `observaciones` (NULL en el 100% de las filas) y el asesor ve
|
||||||
|
todas sus notas desaparecidas — sin error, sin log, en el momento de máximo estrés.
|
||||||
|
|
||||||
|
**Puntos de integración del campo nuevo** (rastreados de punta a punta):
|
||||||
|
`prisma/schema.prisma`, `src/lib/schemas.ts` (los dos schemas), `src/lib/store.ts`,
|
||||||
|
`CotizacionForm.tsx`, `POST /api/cotizaciones`, `PUT /api/cotizaciones/[id]`,
|
||||||
|
`cotizaciones/[id]/page.tsx`, `cotizaciones/[id]/editar/page.tsx`, y en Python los tres modelos
|
||||||
|
Pydantic (`CotizacionCreate`, `CotizacionUpdate`, `CotizacionResponse`) más el `_add` del PUT.
|
||||||
|
|
||||||
|
Los INSERT de Python **no** son urgentes: la columna es nullable y Postgres pone NULL. Los modelos
|
||||||
|
Pydantic sí lo son — hay precedente demostrado de que sin declararlos el campo nunca sale del API
|
||||||
|
(`incluirIva` existe en la tabla desde `20260630000000` y el API Python no lo devuelve).
|
||||||
|
|
||||||
|
**Deuda declarada:** `zod` se importa en `src/lib/schemas.ts:1` y resuelve transitivamente a 4.3.6,
|
||||||
|
pero no está en `package.json`. Se declara explícitamente (`npm i zod@^4.3.6`); un
|
||||||
|
`npm ci --omit=dev` revienta hoy sin eso, y Fase 1 depende fuertemente de zod.
|
||||||
|
|
||||||
|
### 4.4 Fase 0-D · Bugs vecinos aprobados
|
||||||
|
|
||||||
|
| Bug | Arreglo |
|
||||||
|
|---|---|
|
||||||
|
| Orden de partidas | Ninguna consulta tiene `orderBy` en `servicios`, y `drawSection` (`pdf-generator.ts:216-268`) asume que vienen agrupadas por fase. Añadir `orderBy: [{fase}, {createdAt}]` en las cuatro rutas de export |
|
||||||
|
| Datos bancarios en borrador | `export/pdf/route.ts:45` llama solo a `getConfigBranding()`; el Excel llama a ambas. Añadir `getConfigBancaria()` |
|
||||||
|
| Partida por demanda en $0 | Las copias locales de `detalleModelo` en `pdf-generator.ts:21-29` y `excel-builder.ts:58-66` no manejan `modeloCobro === "demanda"`; la canónica de `calculators.ts:23-41` sí. Usar la canónica |
|
||||||
|
| Bonos con dos redacciones | `pdf-generator.ts:377-384` tiene seis bonos hardcodeados y la tabla `Bono` del seed dice otra cosa; la tabla no se consulta en `src/`. Definir la tabla como fuente de verdad |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Fase 1 — Script local de validación de calidad
|
||||||
|
|
||||||
|
Sin base de datos nueva, sin UI, sin despliegue. Un script que lee una cotización y una
|
||||||
|
transcripción, corre el pipeline, y escribe un JSON, un HTML y un reporte.
|
||||||
|
|
||||||
|
### 5.1 Contratos compartidos
|
||||||
|
|
||||||
|
Los cuatro diseños explorados compartían vocabulario pero no contratos. Estas resoluciones se
|
||||||
|
escriben **una vez**, en un solo archivo de tipos que todos importan. Sin esto el sistema arranca,
|
||||||
|
no falla, y valida el vacío.
|
||||||
|
|
||||||
|
| # | Conflicto | Resolución |
|
||||||
|
|---|---|---|
|
||||||
|
| B1 | Clave de join partida ↔ IA | `refPartida`, formato `/^P\d{2}$/` → `"P01"`. El cuid de `ServicioCotizado` **nunca** sale hacia el proveedor: no es estable entre ediciones porque el PUT hace `deleteMany` + `createMany` |
|
||||||
|
| B2 | Forma de la evidencia | Arrays de IDs (`citas[]`, `hechos[]`), no prosa. Auditable por máquina, que es el punto de P3 |
|
||||||
|
| B3 | Cita literal | Referenciada por ID. Se **verifica en el paso 1**, donde vive el texto, y su fallo bloquea antes de gastar los pasos 2 y 3 |
|
||||||
|
| B4 | Árbol de módulos | `src/lib/propuesta/{ia,validacion,render}/`, entrada `scripts/propuesta-consultiva.ts`, tipo raíz `PropuestaConsultiva` |
|
||||||
|
| B5 | Códigos de salida | `0` limpio · `1` requiere revisión · `2` bloqueado · `3` error del proveedor · `64` error de uso |
|
||||||
|
| B6 | Variables de entorno | Solo `MINIMAX_API_KEY` / `MINIMAX_BASE_URL` / `MINIMAX_MODEL`, pasadas explícitas al constructor. **Nunca** prefijo `ANTHROPIC_*`: el SDK las lee por su cuenta y un `ANTHROPIC_BASE_URL` exportado en la shell mandaría la clave de MiniMax a Anthropic |
|
||||||
|
| B7 | `tool_choice` | No se envía en Fase 1. Fijarlo solo en el reintento le da al reintento un prefijo distinto, o sea que se paga el contexto completo justo cuando es más grande |
|
||||||
|
| B8 | Snapshot del catálogo | Sin `id` y sin `precioBase`. La resolución es por `nombre` + `fase`, reportando ambigüedad — `ServicioCatalogo.nombre` no es `@unique` |
|
||||||
|
| B9 | Valor anual del problema | `dimensiones[].calculo.montoAnualMXN`. El total se suma **en código**, nunca lo emite el modelo |
|
||||||
|
|
||||||
|
### 5.2 El corte: qué copia el código y qué genera el modelo
|
||||||
|
|
||||||
|
La protección más fuerte del principio P1 no es una regla de validación: es que **ningún schema
|
||||||
|
que llena el modelo contiene un solo campo de dinero de la cotización**.
|
||||||
|
|
||||||
|
- **El modelo devuelve:** `refPartida` + prosa (diagnóstico, resultados, descripciones en lenguaje
|
||||||
|
de beneficio, exclusiones, pendientes, backlog).
|
||||||
|
- **El código inyecta después:** precios, totales, IVA, número de cotización, vigencia, fechas.
|
||||||
|
|
||||||
|
Así P1 deja de ser algo que alguien puede olvidar validar y pasa a ser estructuralmente imposible
|
||||||
|
de violar. El ratio precio/valor también lo calcula el código (D9) y se imprime solo en el anexo
|
||||||
|
interno.
|
||||||
|
|
||||||
|
### 5.3 El pipeline de tres pasos
|
||||||
|
|
||||||
|
| Paso | Entrada | Salida | Por qué separado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 · Extracción | Transcripción + notas + observaciones | Hechos: dolores, cifras con confianza, citas literales con ID, materiales, decisiones, red flags, menciones fuera de alcance | Determina la calidad de todo lo demás. Es corregible por el asesor antes de propagarse |
|
||||||
|
| 2 · Diagnóstico | Hechos del paso 1 + cotización + catálogo | Hallazgos con semáforo, valor del problema, resultados a lograr | Razonamiento, no redacción |
|
||||||
|
| 3 · Redacción | Salidas de 1 y 2 | `PropuestaConsultiva` | Con el análisis hecho, esto es escritura |
|
||||||
|
|
||||||
|
Cada paso define una herramienta cuyo `input_schema` es su contrato, derivado del Zod con
|
||||||
|
`z.toJSONSchema` nativo (verificado: preserva `additionalProperties` y `description`, inlinea
|
||||||
|
subschemas, omite `superRefine`, y emite un `$schema` que hay que borrar). **No hace falta
|
||||||
|
`zod-to-json-schema`.**
|
||||||
|
|
||||||
|
### 5.4 El presupuesto de reintentos
|
||||||
|
|
||||||
|
Hallazgo verificado que ningún diseño anticipó: **`.superRefine` no corre si la forma falla.** Eso
|
||||||
|
crea cuatro compuertas secuenciales:
|
||||||
|
|
||||||
|
1. Forma Zod — siempre
|
||||||
|
2. `superRefine` (IDs únicos, refs internas) — solo si 1 pasa
|
||||||
|
3. Validación cruzada de runtime (`refPartida` reales) — solo si 2 pasa
|
||||||
|
4. Filtro de contenido y PII — solo si 3 pasa
|
||||||
|
|
||||||
|
Con tres intentos y cuatro compuertas, el modo de fallo más probable del piloto es *"tres llamadas
|
||||||
|
pagadas, cero documento"* — justo lo que Fase 1 no puede permitirse, porque su criterio de éxito es
|
||||||
|
que el asesor lea algo.
|
||||||
|
|
||||||
|
**Diseño adoptado:**
|
||||||
|
|
||||||
|
- El filtro de contenido y PII corre sobre el objeto **crudo** aunque Zod haya fallado, recorriendo
|
||||||
|
rutas parciales. Los hallazgos de las cuatro compuertas se acumulan en **un solo** mensaje de
|
||||||
|
corrección.
|
||||||
|
- Presupuesto: **3 intentos totales** por paso (1 inicial + 2 reintentos). El paso 3 recibe **4
|
||||||
|
intentos totales** (1 + 3) por ser el más largo y el que atraviesa más compuertas.
|
||||||
|
- Al agotarse los intentos se escribe igual el HTML del cliente **si y solo si** no hay bloqueantes
|
||||||
|
de PII, moneda, garantía ni marca. Salir con código 1, no con 2.
|
||||||
|
- En la rama `tool_incorrecta` hay que emitir un `tool_result` con `is_error: true` por **cada**
|
||||||
|
`tool_use` del turno antes del texto de corrección; un mensaje de usuario plano después de un
|
||||||
|
`tool_use` produce un 400.
|
||||||
|
|
||||||
|
### 5.5 Validación post-generación
|
||||||
|
|
||||||
|
Las seis reglas de §8.3 del documento de negocio, más una nueva que el análisis de riesgo destapó.
|
||||||
|
|
||||||
|
| Regla | Qué comprueba | Al fallar |
|
||||||
|
|---|---|---|
|
||||||
|
| **R0** (nueva) | Ningún n-grama de 7 tokens de `observacionesInternas` ni de `ServicioCotizado.notas` aparece en el HTML del cliente | **Bloquea** |
|
||||||
|
| R1 | Todo monto en prosa pertenece al conjunto de la economía (BD) o al del valor (declarado y auditado por R4) | Bloquea |
|
||||||
|
| R2 | Todo `refPartida` existe en `ServicioCotizado`. Ninguna partida nueva | Bloquea |
|
||||||
|
| R3 | Cada cita literal existe en las fuentes, con normalización de 9 pasos (NFD sin diacríticos, minúsculas, puntuación, espacios, muletillas de ASR). Corre en el paso 1 | Bloquea si cruza hablantes |
|
||||||
|
| R4 | Todo hallazgo `confirmado` tiene `citas.length + hechos.length > 0`. Corre sobre el objeto del **paso 2** | Degrada a `por_validar` |
|
||||||
|
| R5 | El bloque `interno` se separa antes de renderizar el documento del cliente | Bloquea |
|
||||||
|
| R6 | Sin valor anual, ninguna afirmación de ratio en la prosa | Bloquea la afirmación |
|
||||||
|
|
||||||
|
**R0 existe porque las otras seis no la cubren.** `R5` compara el bloque interno que *produjo el
|
||||||
|
modelo* contra el HTML; nunca compara `observacionesInternas`, que es una **entrada** que el modelo
|
||||||
|
recibe en el prompt y puede copiar literalmente en cualquier campo de prosa.
|
||||||
|
|
||||||
|
**Filtro de PII de salida** (advertencia, no bloqueo — D10): montos en USD, promesas de resultado
|
||||||
|
garantizado, CRMs que no sean Bucéfalo, datos de empleados, temas salariales o legales. Los
|
||||||
|
patrones heurísticos se marcan como tales en el reporte, con su extracto, para que el asesor
|
||||||
|
juzgue. Se debe añadir a la cosecha de nombres las etiquetas de hablante del VTT y los dos campos
|
||||||
|
de notas, que hoy quedarían fuera.
|
||||||
|
|
||||||
|
### 5.6 El renderizador HTML
|
||||||
|
|
||||||
|
**Es el entregable primario y ningún diseño lo tenía.** Primer paso de Fase 1: copiar
|
||||||
|
`D:/Documents/plantilla_cotizacion.html` (27,100 bytes, hoy fuera del repo) a
|
||||||
|
`src/lib/propuesta/render/plantilla.ts` y congelar su CSS. Sin esto, Fase 1 termina con tres JSON
|
||||||
|
impecables y nada que enseñar.
|
||||||
|
|
||||||
|
Cambios obligatorios sobre la plantilla:
|
||||||
|
|
||||||
|
| Problema | Arreglo |
|
||||||
|
|---|---|
|
||||||
|
| 12 `rgba()` con canales escritos a mano duplican `--blue`, `--cream`, `--green`, `--amber` | Parametrizar de verdad, o `color_primario` produce un documento con dos azules |
|
||||||
|
| `.section { page-break-inside: avoid }` en secciones enteras | Con 15-20 partidas empuja la sección a hoja nueva y la parte igual. Aplicar a filas, no a secciones |
|
||||||
|
| No hay `@page` | Todo PDF impreso lleva `localhost:3000/...` estampado al pie de una propuesta comercial |
|
||||||
|
| Tema oscuro + "Gráficos de fondo" desactivado por defecto en Chrome | El PDF sale con texto crema sobre blanco. Necesita un modo de impresión claro |
|
||||||
|
| Cero hueco para el logo | El Anexo A del documento de negocio lo mapea como si existiera |
|
||||||
|
| `.plan-grid` fijo a `1fr 1fr` | La sección de opciones pide tres tarjetas (anclaje A → B → C) |
|
||||||
|
| Números de sección escritos a mano con cinco secciones condicionales | Un documento sin mantenimiento salta del 04 al 06. Numerar en el renderizador |
|
||||||
|
| "Valor del problema" pide tabla de cuatro columnas | No hay patrón CSS; hay que crearlo |
|
||||||
|
|
||||||
|
Estrategia: template literals puros, sin motor de plantillas. El proyecto no tiene ninguno
|
||||||
|
instalado y no lo amerita.
|
||||||
|
|
||||||
|
**Inyección CSS:** `PUT /api/configuracion` filtra las claves permitidas pero no valida los valores,
|
||||||
|
y esos valores terminan dentro de un `<style>`. Validar `/^#[0-9a-fA-F]{3,8}$/` en el punto de
|
||||||
|
inyección y caer al default del seed si no cumple.
|
||||||
|
|
||||||
|
**Anexo interno:** archivo separado, `INTERNO-NO-ENVIAR.html`, visualmente inconfundible. Nunca una
|
||||||
|
sección oculta ni `display: none`.
|
||||||
|
|
||||||
|
### 5.7 Ergonomía del script
|
||||||
|
|
||||||
|
```
|
||||||
|
npx tsx scripts/propuesta-consultiva.ts \
|
||||||
|
--cotizacion UJ2607UJ003 \
|
||||||
|
--transcripcion contexto/UJ2607UJ003/reunion.txt \
|
||||||
|
--salida salidas/UJ2607UJ003/
|
||||||
|
```
|
||||||
|
|
||||||
|
Banderas: `--cotizacion --transcripcion --salida --desde-paso --solo-validar`. Nada más.
|
||||||
|
`--desde-paso` y `--solo-validar` se ganan su lugar: son la diferencia entre iterar el prompt del
|
||||||
|
paso 3 veinte veces o cuatro.
|
||||||
|
|
||||||
|
Artefactos: `propuesta.html`, `INTERNO-NO-ENVIAR.html`, `propuesta.json` (con la traza embebida),
|
||||||
|
`REPORTE.md`, y `traza/pasoN.json` porque `--desde-paso` los necesita.
|
||||||
|
|
||||||
|
Consola: los tres pasos tardan; imprime progreso por paso, tokens consumidos y aciertos de caché.
|
||||||
|
|
||||||
|
`contexto/` y `salidas/` van a `.gitignore` **y** a `.megaignore` (D11), con la exclusión
|
||||||
|
verificada empíricamente antes de colocar material real.
|
||||||
|
|
||||||
|
### 5.8 Prompts y caché
|
||||||
|
|
||||||
|
Tres capas por estabilidad: estable (filosofía, frameworks, reglas, contrato, ejemplos),
|
||||||
|
semi-estable (catálogo), volátil (cotización y transcripción). Dos breakpoints de `cache_control`.
|
||||||
|
|
||||||
|
MiniMax soporta caché con `cache_control` (4 breakpoints, TTL 5 min, verificable con
|
||||||
|
`usage.cache_read_input_tokens`), pero el prefijo arranca en `tools`, no en `system`.
|
||||||
|
|
||||||
|
**Invalidadores a prevenir:** `new Date()` en la capa 1, `JSON.stringify` con orden de llaves no
|
||||||
|
determinista, el número de cotización en la capa estable, cambio de modelo a media ejecución.
|
||||||
|
Serializar el catálogo con orden explícito (`orden`, `id`).
|
||||||
|
|
||||||
|
**Los ejemplos few-shot deben validar contra su propio schema Zod al arrancar el script.** Cuesta
|
||||||
|
diez líneas y es el único test que este proyecto va a tener. Un ejemplar defectuoso no produce un
|
||||||
|
error: produce N documentos parecidos entre sí y se diagnostica tarde. El análisis encontró tres
|
||||||
|
defectos en los ejemplos propuestos —uno que no valida, uno que mete el nombre de pila de una
|
||||||
|
empleada en un campo del cliente, y uno que dispara el filtro de montos con `"400 pesos"`— que hay
|
||||||
|
que corregir antes de la primera corrida.
|
||||||
|
|
||||||
|
También hay que pasar el **texto fijo de la plantilla** por el catálogo de PII una vez antes de la
|
||||||
|
primera corrida, o la primera ejecución devolverá bloqueos que vienen del footer y no del modelo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Supuestos sin verificar
|
||||||
|
|
||||||
|
Se documentan porque el diseño se apoya en ellos y la primera corrida debe medirlos.
|
||||||
|
|
||||||
|
| # | Supuesto | Qué hacer |
|
||||||
|
|---|---|---|
|
||||||
|
| V1 | MiniMax devuelve `cache_read_input_tokens` con `tools` fijo y 2 breakpoints | Primera medición obligatoria. Si no cachea, mandar las tres herramientas en cada petición es costo puro |
|
||||||
|
| V2 | Con `stop_reason: "max_tokens"` el SDK entrega `input` como objeto parcial | Probar a propósito con `max_tokens: 200` |
|
||||||
|
| V3 | `countTokens` existe en el endpoint compatible | Degradar a estimador local y marcar las cifras como estimadas en la traza |
|
||||||
|
|
||||||
|
Verificados durante el análisis, sin acción pendiente: `z.toJSONSchema` preserva lo necesario;
|
||||||
|
`z.prettifyError` emite multi-error con ruta; `tsx` resuelve el alias `@/`; `core.autocrlf=true`
|
||||||
|
exige normalizar saltos de línea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Riesgos aceptados
|
||||||
|
|
||||||
|
| Riesgo | Por qué se acepta | Mitigación |
|
||||||
|
|---|---|---|
|
||||||
|
| La transcripción cruda con datos de empleados viaja a MiniMax | El modelo necesita el contexto para diagnosticar (D6) | Queda escrito aquí, no solo en la conversación. El filtro de salida protege el documento, no el tránsito |
|
||||||
|
| El PDF de una cotización ya enviada deja de ser reproducible | Fase 0 cambia el renderizador; el sistema no archiva los PDF emitidos | Que el asesor archive lo enviado. Documentar en AGENTS.md que los exportadores no son reproducibles en el tiempo |
|
||||||
|
| El hábito del asesor no migra solo | Tras Fase 0, el textarea donde lleva meses escribiendo contexto interno pasa a imprimirse en el PDF | Avisar por el canal del equipo, no solo cambiar la etiqueta |
|
||||||
|
| Fase 1 añade una tercera superficie solo en TypeScript | AGENTS.md ya exige paridad entre dos backends | Documentar la divergencia. Si Fase 4 expone esto por MCP, la validación tendrá que existir del lado Python |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Criterios de éxito
|
||||||
|
|
||||||
|
**Fase 0** — verificable de inmediato:
|
||||||
|
|
||||||
|
- El endpoint `/mcp` ya no existe (§0.2). Se cumplió antes con 401 verificado; la retirada lo supera.
|
||||||
|
- `api/requirements.txt` fija versiones exactas y el API arranca sin el paquete `mcp` (§0.3).
|
||||||
|
- El total del PDF y el del Excel coinciden dígito por dígito en cotizaciones reales.
|
||||||
|
- Una cotización con `incluirIva: false` no muestra IVA en ningún exportador.
|
||||||
|
- Las notas internas no aparecen en ningún archivo exportable.
|
||||||
|
- El texto del cliente aparece en el PDF, con saltos de línea correctos y sin romper el footer.
|
||||||
|
|
||||||
|
**Fase 1** — el criterio es cualitativo y es el que importa:
|
||||||
|
|
||||||
|
> El asesor revisa 5-10 documentos generados sobre cotizaciones reales pasadas y dice
|
||||||
|
> *"esto lo mandaría a un cliente después de editarlo 10 minutos."*
|
||||||
|
|
||||||
|
Métricas de apoyo: cero afirmaciones sin evidencia, cero citas no verificables, y tasa de edición
|
||||||
|
del asesor por debajo del 30%.
|
||||||
|
|
||||||
|
Si el criterio no se cumple, la conclusión correcta es iterar el prompt — no construir Fase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fuera de alcance
|
||||||
|
|
||||||
|
Modelos `PropuestaIA` y `ContextoCotizacion`; carga de archivos en la UI; route handler con patrón
|
||||||
|
de job; vista de revisión con semáforo de salud; dashboard de BI agregado; herramienta MCP
|
||||||
|
`generar_propuesta_consultiva`; sugerencia de esquema de 2-3 opciones; alerta de subcotización.
|
||||||
|
|
||||||
|
También difierido: migrar los cinco consumidores restantes de totales duplicados, y el semáforo de
|
||||||
|
salud de §5.1 del documento de negocio — es una función de producto, no una validación, y no hace
|
||||||
|
falta para saber si el texto generado sirve.
|
||||||
Generated
+73
-1
@@ -8,6 +8,7 @@
|
|||||||
"name": "cotizador-e3",
|
"name": "cotizador-e3",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.115.0",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -51,6 +53,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
|
||||||
|
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -243,6 +266,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -2233,6 +2265,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -5179,6 +5217,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
@@ -6364,6 +6408,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -8554,6 +8611,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
@@ -8890,6 +8957,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -9498,7 +9571,6 @@
|
|||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"seed": "npx tsx prisma/seed.ts"
|
"seed": "npx tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.115.0",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Fase 0-C, Despliegue A: separar el texto que ve el cliente del contexto interno.
|
||||||
|
--
|
||||||
|
-- ESTA MIGRACION ES PURAMENTE ADITIVA. No modifica ni una sola fila existente.
|
||||||
|
-- El movimiento de datos (observaciones -> observacionesInternas) seria un
|
||||||
|
-- Despliegue B posterior y NO se incluye aqui, por dos razones:
|
||||||
|
--
|
||||||
|
-- 1. Rollback seguro. No hay migraciones `down` en este repo ni servicio de
|
||||||
|
-- backup en docker-compose.coolify.yml. Si un UPDATE vaciara "observaciones"
|
||||||
|
-- y el despliegue se revirtiera, el codigo anterior leeria NULL en todas las
|
||||||
|
-- filas y el asesor veria sus notas desaparecidas: sin error y sin log.
|
||||||
|
--
|
||||||
|
-- 2. El dato real no lo necesita. Al momento de escribir esto la unica fila de
|
||||||
|
-- produccion con "observaciones" no vacias (UJ2606AG777, aprobada) contiene
|
||||||
|
-- condiciones de pago dirigidas al cliente en segunda persona. Moverlas a
|
||||||
|
-- "internas" ocultaria terminos ya acordados en un documento emitido.
|
||||||
|
-- Esa fila ya esta clasificada correctamente donde esta.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS hace la sentencia idempotente si alguien la aplica a mano.
|
||||||
|
|
||||||
|
ALTER TABLE "Cotizacion" ADD COLUMN IF NOT EXISTS "observacionesInternas" TEXT;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Propuesta consultiva con IA: tabla nueva.
|
||||||
|
-- ADITIVA. No toca ninguna tabla ni fila existente.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "PropuestaIA" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"cotizacionId" TEXT NOT NULL,
|
||||||
|
"estado" TEXT NOT NULL DEFAULT 'borrador',
|
||||||
|
"contenidoIA" JSONB NOT NULL,
|
||||||
|
"contenidoEditado" JSONB,
|
||||||
|
"traza" JSONB,
|
||||||
|
"avisos" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"modelo" TEXT NOT NULL,
|
||||||
|
"aprobadaPor" TEXT,
|
||||||
|
"aprobadaAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PropuestaIA_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "PropuestaIA_cotizacionId_idx" ON "PropuestaIA"("cotizacionId");
|
||||||
|
|
||||||
|
ALTER TABLE "PropuestaIA"
|
||||||
|
ADD CONSTRAINT "PropuestaIA_cotizacionId_fkey"
|
||||||
|
FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Guardar la transcripcion usada al generar la propuesta.
|
||||||
|
-- ADITIVA: columna nullable, no toca ninguna fila existente.
|
||||||
|
ALTER TABLE "PropuestaIA" ADD COLUMN IF NOT EXISTS "transcripcion" TEXT;
|
||||||
@@ -49,7 +49,13 @@ model Cotizacion {
|
|||||||
incluirIva Boolean @default(true)
|
incluirIva Boolean @default(true)
|
||||||
esDoble Boolean @default(false)
|
esDoble Boolean @default(false)
|
||||||
opcionesMetadata Json?
|
opcionesMetadata Json?
|
||||||
|
// Texto que SI ve el cliente: se imprime en el PDF y en el Excel.
|
||||||
observaciones String?
|
observaciones String?
|
||||||
|
// Contexto de discovery, notas del asesor, riesgos. NO sale de la app: ningun
|
||||||
|
// exportador declara este campo en su interfaz de datos (CotizacionPDFData /
|
||||||
|
// ExcelData) y la herramienta MCP obtener_cotizacion usa lista blanca de
|
||||||
|
// columnas. La garantia es estructural, no depende de recordar filtrarlo.
|
||||||
|
observacionesInternas String?
|
||||||
clienteId String
|
clienteId String
|
||||||
asesorId String
|
asesorId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -60,6 +66,7 @@ model Cotizacion {
|
|||||||
servicios ServicioCotizado[]
|
servicios ServicioCotizado[]
|
||||||
planBucefalo PlanBucefaloCotizacion?
|
planBucefalo PlanBucefaloCotizacion?
|
||||||
registrosHoras RegistroHoras[]
|
registrosHoras RegistroHoras[]
|
||||||
|
propuestasIA PropuestaIA[]
|
||||||
|
|
||||||
@@index([clienteId])
|
@@index([clienteId])
|
||||||
@@index([asesorId])
|
@@index([asesorId])
|
||||||
@@ -214,6 +221,33 @@ model RegistroHoras {
|
|||||||
@@index([estadoPago])
|
@@index([estadoPago])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Documento consultivo generado por IA a partir del contexto humano de la cotizacion.
|
||||||
|
// Se guarda por separado lo que produjo el modelo y lo que edito el asesor, para poder
|
||||||
|
// mostrar que cambio y para no perder el original al editar.
|
||||||
|
model PropuestaIA {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
cotizacionId String
|
||||||
|
estado String @default("borrador") // borrador | aprobada
|
||||||
|
contenidoIA Json
|
||||||
|
contenidoEditado Json?
|
||||||
|
// Transcripcion sanitizada usada al generar. Sin esto, el PATCH revalida con
|
||||||
|
// fuentes incompletas y R3 no puede comprobar que la cita destacada sea literal.
|
||||||
|
transcripcion String?
|
||||||
|
// Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache.
|
||||||
|
traza Json?
|
||||||
|
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
|
||||||
|
avisos Json @default("[]")
|
||||||
|
modelo String
|
||||||
|
aprobadaPor String?
|
||||||
|
aprobadaAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([cotizacionId])
|
||||||
|
}
|
||||||
|
|
||||||
model Configuracion {
|
model Configuracion {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
clave String @unique
|
clave String @unique
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { crearCliente, modelo, normalizarRespuesta } from "@/lib/propuesta/cliente-ia";
|
||||||
|
|
||||||
|
// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro.
|
||||||
|
|
||||||
|
let fallas = 0;
|
||||||
|
function check(n: string, ok: boolean, d = "") {
|
||||||
|
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
|
||||||
|
if (!ok) fallas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guardada = process.env.MINIMAX_API_KEY;
|
||||||
|
const guardadaBase = process.env.MINIMAX_BASE_URL;
|
||||||
|
|
||||||
|
delete process.env.MINIMAX_API_KEY;
|
||||||
|
let msg = "";
|
||||||
|
try {
|
||||||
|
crearCliente();
|
||||||
|
} catch (e) {
|
||||||
|
msg = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
check("sin clave falla en vez de llamar a ciegas", msg.length > 0);
|
||||||
|
check("el error nombra la variable correcta", msg.includes("MINIMAX_API_KEY"));
|
||||||
|
check("el error advierte del prefijo ANTHROPIC_", msg.includes("ANTHROPIC_API_KEY"));
|
||||||
|
|
||||||
|
process.env.MINIMAX_API_KEY = "clave-de-prueba";
|
||||||
|
delete process.env.MINIMAX_BASE_URL;
|
||||||
|
const c = crearCliente();
|
||||||
|
check("baseURL apunta a MiniMax por defecto", String(c.baseURL).includes("api.minimax.io"), String(c.baseURL));
|
||||||
|
check("modelo por defecto es MiniMax-M3", modelo() === "MiniMax-M3", modelo());
|
||||||
|
|
||||||
|
// Aunque el shell tenga ANTHROPIC_BASE_URL, el cliente no debe hacerle caso.
|
||||||
|
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||||
|
const c2 = crearCliente();
|
||||||
|
check("ANTHROPIC_BASE_URL del shell no secuestra el baseURL",
|
||||||
|
!String(c2.baseURL).includes("api.anthropic.com"), String(c2.baseURL));
|
||||||
|
delete process.env.ANTHROPIC_BASE_URL;
|
||||||
|
|
||||||
|
process.env.MINIMAX_BASE_URL = "https://ejemplo.local/anthropic";
|
||||||
|
check("MINIMAX_BASE_URL si se respeta", String(crearCliente().baseURL).includes("ejemplo.local"));
|
||||||
|
|
||||||
|
process.env.MINIMAX_MODEL = "MiniMax-M2";
|
||||||
|
check("MINIMAX_MODEL se respeta", modelo() === "MiniMax-M2");
|
||||||
|
delete process.env.MINIMAX_MODEL;
|
||||||
|
|
||||||
|
if (guardada) process.env.MINIMAX_API_KEY = guardada;
|
||||||
|
else delete process.env.MINIMAX_API_KEY;
|
||||||
|
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
|
||||||
|
else delete process.env.MINIMAX_BASE_URL;
|
||||||
|
|
||||||
|
// ── Normalizacion de rarezas de MiniMax, observadas contra la API real ──
|
||||||
|
const igual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
check("desenvuelve {item: X} cuando item es la unica clave",
|
||||||
|
igual(normalizarRespuesta({ item: { a: 1 } }), { a: 1 }));
|
||||||
|
check("desenvuelve dentro de arrays",
|
||||||
|
igual(normalizarRespuesta([{ item: { a: 1 } }, { item: { a: 2 } }]), [{ a: 1 }, { a: 2 }]));
|
||||||
|
check("desenvuelve en profundidad",
|
||||||
|
igual(normalizarRespuesta({ lista: [{ item: { x: [{ item: 5 }] } }] }), { lista: [{ x: [5] }] }));
|
||||||
|
check("NO toca un objeto con item junto a otras claves",
|
||||||
|
igual(normalizarRespuesta({ item: 1, otro: 2 }), { item: 1, otro: 2 }));
|
||||||
|
check("deja intactos los valores simples",
|
||||||
|
normalizarRespuesta("texto") === "texto" && normalizarRespuesta(5) === 5 && normalizarRespuesta(null) === null);
|
||||||
|
check("no altera un objeto normal",
|
||||||
|
igual(normalizarRespuesta({ a: 1, b: [1, 2] }), { a: 1, b: [1, 2] }));
|
||||||
|
check("el caso real observado: alcance envuelto",
|
||||||
|
igual(normalizarRespuesta({ alcance: [{ item: { refPartida: "P01", descripcionResultado: "x" } }] }),
|
||||||
|
{ alcance: [{ refPartida: "P01", descripcionResultado: "x" }] }));
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { calcularRatio } from "@/lib/propuesta/economia";
|
||||||
|
|
||||||
|
let fallas = 0;
|
||||||
|
function check(n: string, ok: boolean, d = "") {
|
||||||
|
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
|
||||||
|
if (!ok) fallas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
check("sin valor anual no hay ratio", calcularRatio(100000, null) === null);
|
||||||
|
check("valor anual cero no divide entre cero", calcularRatio(100000, 0) === null);
|
||||||
|
check("valor anual negativo no produce ratio", calcularRatio(100000, -5) === null);
|
||||||
|
|
||||||
|
check("ratio 5% = subcotizado", calcularRatio(50000, 1000000)?.lectura === "subcotizado");
|
||||||
|
check("ratio 20% = en rango", calcularRatio(200000, 1000000)?.lectura === "en_rango");
|
||||||
|
check("ratio 35% = alto", calcularRatio(350000, 1000000)?.lectura === "alto");
|
||||||
|
check("ratio 60% = objecion probable", calcularRatio(600000, 1000000)?.lectura === "objecion_probable");
|
||||||
|
|
||||||
|
// Los cortes exactos: 0.10 entra en rango, 0.25 sigue en rango, 0.40 sigue alto.
|
||||||
|
check("corte 10% cae en rango", calcularRatio(100000, 1000000)?.lectura === "en_rango");
|
||||||
|
check("corte 25% cae en rango", calcularRatio(250000, 1000000)?.lectura === "en_rango");
|
||||||
|
check("corte 40% cae en alto", calcularRatio(400000, 1000000)?.lectura === "alto");
|
||||||
|
|
||||||
|
check("ratio redondeado a 3 decimales", calcularRatio(123456, 1000000)?.ratio === 0.123);
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import zlib from "node:zlib";
|
||||||
|
import { generarPropuestaPDF, generarAnexoInternoPDF, type DatosPropuestaPDF } from "@/lib/propuesta/pdf";
|
||||||
|
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
|
||||||
|
import type { DatosEconomicos } from "@/lib/propuesta/economia";
|
||||||
|
|
||||||
|
/** Extrae el texto de un PDF de PDFKit. Con las fuentes base (Helvetica) no hay
|
||||||
|
* subsetting ni ToUnicode: el texto va como hex de UN byte por caracter dentro de
|
||||||
|
* streams comprimidos con Flate. */
|
||||||
|
function textoDePdf(pdf: Buffer): string {
|
||||||
|
const crudo = pdf.toString("latin1");
|
||||||
|
let salida = "";
|
||||||
|
const re = /stream\r?\n/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(crudo)) !== null) {
|
||||||
|
const ini = m.index + m[0].length;
|
||||||
|
const fin = crudo.indexOf("endstream", ini);
|
||||||
|
if (fin === -1) continue;
|
||||||
|
let bloque = "";
|
||||||
|
try {
|
||||||
|
bloque = zlib.inflateSync(Buffer.from(crudo.slice(ini, fin), "latin1")).toString("latin1");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// PDFKit alterna dos codificaciones segun el contenido de la cadena:
|
||||||
|
// hex `<48656c6c6f>` y literal `(Hello) Tj`. Hay que leer las dos.
|
||||||
|
// OJO: no se puede meter un separador entre trozos hex. PDFKit aplica kerning y
|
||||||
|
// parte una misma palabra en varios elementos de un arreglo TJ
|
||||||
|
// (`[<4d41> -20 <524341444f52>]`), asi que un espacio de mas convierte
|
||||||
|
// "MARCADORVERDE" en "MARCA DORVERDE" y ninguna busqueda lo encuentra.
|
||||||
|
for (const t of bloque.matchAll(/<([0-9a-fA-F]{2,})>/g)) {
|
||||||
|
const hex = t[1];
|
||||||
|
for (let i = 0; i + 2 <= hex.length; i += 2) {
|
||||||
|
const b = parseInt(hex.slice(i, i + 2), 16);
|
||||||
|
if (b >= 32 && b < 256) salida += String.fromCharCode(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
salida += " ";
|
||||||
|
for (const t of bloque.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
|
||||||
|
salida += t[1].replace(/\\([()\\])/g, "$1") + " ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return salida;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallas = 0;
|
||||||
|
function check(n: string, ok: boolean, d = "") {
|
||||||
|
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
|
||||||
|
if (!ok) fallas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const econ: DatosEconomicos = {
|
||||||
|
partidas: [
|
||||||
|
{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web institucional", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
|
||||||
|
{ refPartida: "P02", servicioCotizadoId: "c2", nombre: "Manejo de pauta", fase: 2, tipoPago: "mensual", precio: 8000, tiempoEntrega: "Mensual", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] },
|
||||||
|
],
|
||||||
|
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
|
||||||
|
moneda: "MXN",
|
||||||
|
planBucefalo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const propuesta: PropuestaConsultiva = {
|
||||||
|
hechos: {
|
||||||
|
citas: [{ id: "C01", textoLiteral: "MARCADORCITA nadie contesta el fin de semana", quienLoDijo: "el socio" }],
|
||||||
|
hechos: [{ id: "H01", enunciado: "Sin control de atencion", confianza: "confirmado", citas: ["C01"] }],
|
||||||
|
materialesPendientes: [{ texto: "MARCADORMATERIAL accesos al dominio", bloqueaEntrega: true }],
|
||||||
|
decisionesPendientes: [{ texto: "MARCADORDECISION definir responsable", quienDecide: "el socio" }],
|
||||||
|
mencionesFueraDeAlcance: ["MARCADORFUERA app movil"],
|
||||||
|
redFlags: [{ senal: "MARCADORFLAG pidio descuento antes de ver el alcance", severidad: "alta" }],
|
||||||
|
},
|
||||||
|
diagnostico: {
|
||||||
|
hallazgos: [
|
||||||
|
{ id: "D01", urgencia: "rojo", titulo: "MARCADORHALLAZGO", descripcion: "Se pierden prospectos cada fin de semana.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
|
||||||
|
{ id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] },
|
||||||
|
],
|
||||||
|
valorProblema: {
|
||||||
|
dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
|
||||||
|
notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.",
|
||||||
|
},
|
||||||
|
resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }],
|
||||||
|
},
|
||||||
|
redaccion: {
|
||||||
|
hero: { titulo: "MARCADORTITULO", subtitulo: "MARCADORSUB ordenar la atencion para dejar de perder prospectos." },
|
||||||
|
citaDestacadaId: "C01",
|
||||||
|
alcance: [
|
||||||
|
{ refPartida: "P01", descripcionResultado: "MARCADORALCANCE un lugar a donde mandar prospectos." },
|
||||||
|
{ refPartida: "P02", descripcionResultado: "MARCADORALCANCE2 cada peso a las busquedas que compran." },
|
||||||
|
],
|
||||||
|
beneficios: [{ etiqueta: "Respuesta", texto: "MARCADORBENEFICIO cada mensaje con responsable.", hallazgoId: "D01" }],
|
||||||
|
exclusiones: [{ texto: "MARCADOREXCLUSION no incluye migrar el historico.", razon: "fase 2" }],
|
||||||
|
backlogEvolucion: [{ problema: "MARCADORBACKLOG automatizar reportes", momentoSugerido: "tras la fase 1" }],
|
||||||
|
notasInternas: ["MARCADORINTERNO el socio decide, no el que vino a la reunion"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const datos: DatosPropuestaPDF = {
|
||||||
|
propuesta,
|
||||||
|
economia: econ,
|
||||||
|
cliente: "Cliente Prueba",
|
||||||
|
empresa: "Empresa SA",
|
||||||
|
asesor: "Asesor",
|
||||||
|
numero: "UJ2607TEST",
|
||||||
|
fecha: new Date("2026-07-29"),
|
||||||
|
vigencia: new Date("2026-08-19"),
|
||||||
|
proyecto: "MKT Digital",
|
||||||
|
branding: { colorPrimario: "#2563eb" },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pdf = await generarPropuestaPDF(datos);
|
||||||
|
const txt = textoDePdf(pdf);
|
||||||
|
check("se genera el documento del cliente", pdf.length > 2000, `${pdf.length} bytes`);
|
||||||
|
|
||||||
|
for (const marca of [
|
||||||
|
"MARCADORTITULO", "MARCADORSUB", "MARCADORCITA", "MARCADORHALLAZGO", "MARCADORVERDE",
|
||||||
|
"MARCADORVALOR", "MARCADORMETODO", "MARCADORRESULTADO", "MARCADORALCANCE", "MARCADORALCANCE2",
|
||||||
|
"MARCADORBENEFICIO", "MARCADOREXCLUSION", "MARCADORMATERIAL", "MARCADORDECISION", "MARCADORBACKLOG",
|
||||||
|
]) {
|
||||||
|
check(`el documento del cliente incluye ${marca}`, txt.includes(marca));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lo que NUNCA debe salir al cliente.
|
||||||
|
check("NO lleva notas internas", !txt.includes("MARCADORINTERNO"));
|
||||||
|
check("NO lleva red flags", !txt.includes("MARCADORFLAG"));
|
||||||
|
check("NO lleva menciones fuera de alcance", !txt.includes("MARCADORFUERA"));
|
||||||
|
|
||||||
|
// El dinero lo pone el codigo, no la IA.
|
||||||
|
check("imprime el precio de P01", txt.includes("12,000"));
|
||||||
|
check("imprime el precio de P02", txt.includes("8,000"));
|
||||||
|
check("imprime el total unico con IVA", txt.includes("13,920"));
|
||||||
|
check("imprime la mensualidad con IVA", txt.includes("9,280"));
|
||||||
|
// El desglose debe cuadrar: lineas sin IVA + IVA como renglon propio.
|
||||||
|
check("imprime el subtotal sin IVA", txt.includes("Subtotal pago unico") && txt.includes("12,000"));
|
||||||
|
check("imprime el IVA como renglon propio", txt.includes("IVA 16%") && txt.includes("1,920"));
|
||||||
|
check("imprime el total con IVA", txt.includes("Total pago unico"));
|
||||||
|
check("la nota NO dice que las lineas incluyan IVA", !txt.includes("Importes con IVA incluido"));
|
||||||
|
check("la nota aclara que las partidas van sin IVA", txt.includes("no incluyen IVA"));
|
||||||
|
|
||||||
|
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
|
||||||
|
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
|
||||||
|
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
|
||||||
|
|
||||||
|
// Sin IVA no debe aparecer el renglon de IVA.
|
||||||
|
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
|
||||||
|
const txtSinIva = textoDePdf(sinIva);
|
||||||
|
check("sin IVA no se imprime el renglon de IVA", !txtSinIva.includes("IVA 16%"));
|
||||||
|
check("sin IVA el total lo declara", txtSinIva.includes("sin IVA"));
|
||||||
|
|
||||||
|
// El plan Bucefalo tiene que aparecer: esta en la cotizacion y en los otros dos exportadores.
|
||||||
|
const conPlan = await generarPropuestaPDF({ ...datos, economia: { ...econ, planBucefalo: { nivel: "premium", precio: 4500 } } });
|
||||||
|
const txtPlan = textoDePdf(conPlan);
|
||||||
|
check("el plan Bucefalo aparece en el documento", txtPlan.includes("Bucefalo"));
|
||||||
|
check("el plan aparece con su nivel", txtPlan.includes("Premium"));
|
||||||
|
check("el plan aparece con su precio mensual", txtPlan.includes("4,500"));
|
||||||
|
|
||||||
|
// Una partida que la IA invento no se dibuja (R2 ya la marco).
|
||||||
|
const conFantasma = await generarPropuestaPDF({
|
||||||
|
...datos,
|
||||||
|
propuesta: { ...propuesta, redaccion: { ...propuesta.redaccion, alcance: [...propuesta.redaccion.alcance, { refPartida: "P99", descripcionResultado: "MARCADORFANTASMA inventada" }] } },
|
||||||
|
});
|
||||||
|
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA"));
|
||||||
|
|
||||||
|
// Completitud: la cotizacion manda, no lo que la IA alcanzo a describir.
|
||||||
|
// Verificado con UJ2606UR001 en produccion: 58 partidas, la IA describio 40.
|
||||||
|
const econGrande = { ...econ, partidas: [...econ.partidas,
|
||||||
|
{ refPartida: "P03", servicioCotizadoId: "c3", nombre: "MARCADORNODESCRITA servicio extra", fase: 3, tipoPago: "unico" as const, precio: 5000, tiempoEntrega: "1 semana", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: ["Entregable A", "Entregable B"] },
|
||||||
|
] };
|
||||||
|
const conNoDescrita = await generarPropuestaPDF({ ...datos, economia: econGrande });
|
||||||
|
const txtND = textoDePdf(conNoDescrita);
|
||||||
|
check("una partida SIN descripcion de la IA igual aparece", txtND.includes("MARCADORNODESCRITA"));
|
||||||
|
check("y aparece con su precio", txtND.includes("5,000"));
|
||||||
|
check("y con su detalle del catalogo como respaldo", txtND.includes("Entregable A"));
|
||||||
|
|
||||||
|
// ── Anexo interno ──
|
||||||
|
const anexo = await generarAnexoInternoPDF(datos);
|
||||||
|
const txtA = textoDePdf(anexo);
|
||||||
|
check("se genera el anexo interno", anexo.length > 1000, `${anexo.length} bytes`);
|
||||||
|
check("el anexo se marca NO ENVIAR", txtA.includes("NO ENVIAR"));
|
||||||
|
check("el anexo lleva las notas internas", txtA.includes("MARCADORINTERNO"));
|
||||||
|
check("el anexo lleva las red flags", txtA.includes("MARCADORFLAG"));
|
||||||
|
check("el anexo lleva lo que quedo fuera", txtA.includes("MARCADORFUERA"));
|
||||||
|
// 125280 / 166400 = 75.3% -> objecion probable
|
||||||
|
check("el anexo calcula el ratio", txtA.includes("75.3"), "125280/166400");
|
||||||
|
check("el anexo interpreta el ratio", txtA.includes("objecion probable"));
|
||||||
|
check("el anexo sugiere responder con alcance", txtA.includes("no con descuento"));
|
||||||
|
check("el anexo desglosa de donde sale el valor anual", txtA.includes("De donde sale el valor anual"));
|
||||||
|
check("el anexo muestra los factores con su confianza", txtA.includes("confirmado") && txtA.includes("estimado"));
|
||||||
|
|
||||||
|
// R5b: una cifra sostenida solo por estimaciones debe llevar alerta visible.
|
||||||
|
const soloEstimado = await generarAnexoInternoPDF({
|
||||||
|
...datos,
|
||||||
|
propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema,
|
||||||
|
dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } },
|
||||||
|
});
|
||||||
|
check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada"));
|
||||||
|
check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada"));
|
||||||
|
|
||||||
|
// Robustez del branding: la API de configuracion no valida los valores.
|
||||||
|
for (const malo of ["</style><script>", "rojo", "#GGG", ""]) {
|
||||||
|
const r = await generarPropuestaPDF({ ...datos, branding: { colorPrimario: malo } });
|
||||||
|
check(`un color invalido (${JSON.stringify(malo)}) no rompe el PDF`, r.length > 2000);
|
||||||
|
}
|
||||||
|
const logoMalo = await generarPropuestaPDF({ ...datos, branding: { logoBase64: "no-es-base64-valido" } });
|
||||||
|
check("un logo invalido no rompe el PDF", logoMalo.length > 2000);
|
||||||
|
|
||||||
|
// Contenido largo: no debe perderse ni romper el pie de pagina.
|
||||||
|
const largo = await generarPropuestaPDF({
|
||||||
|
...datos,
|
||||||
|
propuesta: {
|
||||||
|
...propuesta,
|
||||||
|
diagnostico: {
|
||||||
|
...propuesta.diagnostico,
|
||||||
|
hallazgos: Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
id: `D${String(i + 1).padStart(2, "0")}`, urgencia: "ambar" as const,
|
||||||
|
titulo: `Hallazgo ${i} MARCADORLARGO`, descripcion: "x".repeat(300),
|
||||||
|
confianza: "estimado" as const, citas: [], hechos: [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const txtL = textoDePdf(largo);
|
||||||
|
check("el contenido largo se imprime", txtL.includes("MARCADORLARGO"));
|
||||||
|
check("el contenido largo conserva las secciones posteriores", txtL.includes("MARCADOREXCLUSION"));
|
||||||
|
check("el contenido largo genera varias paginas", (largo.toString("latin1").match(/\/Type\s*\/Page[^s]/g) || []).length > 1);
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Error inesperado:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { SYSTEM_BASE, bloqueContexto, mensajePaso1, mensajePaso2, mensajePaso3 } from "@/lib/propuesta/prompts";
|
||||||
|
import type { DatosEconomicos } from "@/lib/propuesta/economia";
|
||||||
|
import type { Hechos, Diagnostico } from "@/lib/propuesta/schemas";
|
||||||
|
|
||||||
|
let fallas = 0;
|
||||||
|
function check(n: string, ok: boolean, d = "") {
|
||||||
|
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
|
||||||
|
if (!ok) fallas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── La capa 1 tiene que ser 100% estable o el cache no sirve de nada ──
|
||||||
|
check("no contiene el anio actual", !SYSTEM_BASE.includes(String(new Date().getFullYear())));
|
||||||
|
check("no contiene una fecha ISO", !/\d{4}-\d{2}-\d{2}/.test(SYSTEM_BASE));
|
||||||
|
check("no contiene un numero de cotizacion", !/UJ\d{4}/.test(SYSTEM_BASE));
|
||||||
|
check("supera el minimo cacheable de 512 tokens", SYSTEM_BASE.length > 2500, `${SYSTEM_BASE.length} chars`);
|
||||||
|
|
||||||
|
// ── Los diez principios y el semaforo estan presentes ──
|
||||||
|
for (const p of ["P1.", "P2.", "P3.", "P4.", "P5.", "P6.", "P7.", "P8."]) {
|
||||||
|
check(`declara el principio ${p}`, SYSTEM_BASE.includes(p));
|
||||||
|
}
|
||||||
|
check("explica el semaforo completo",
|
||||||
|
["rojo:", "ambar:", "azul:", "verde:"].every((c) => SYSTEM_BASE.includes(c)));
|
||||||
|
check("justifica por que el verde importa", SYSTEM_BASE.includes("entre pares"));
|
||||||
|
|
||||||
|
// ── Reglas de marca ──
|
||||||
|
check("nombra Bucefalo", SYSTEM_BASE.includes("Bucefalo"));
|
||||||
|
check("prohibe otros CRM", SYSTEM_BASE.includes("ningun otro CRM"));
|
||||||
|
check("prohibe USD", SYSTEM_BASE.includes("USD"));
|
||||||
|
check("prohibe garantizar resultados", SYSTEM_BASE.includes("garantizado"));
|
||||||
|
check("prohibe lenguaje corporativo vacio", SYSTEM_BASE.includes("sinergias"));
|
||||||
|
check("restringe a servicios digitales", SYSTEM_BASE.includes("UNICAMENTE servicios digitales"));
|
||||||
|
check("prohibe datos de empleados", SYSTEM_BASE.includes("nombres de empleados"));
|
||||||
|
check("cubre el caso de material pobre", SYSTEM_BASE.includes("MATERIAL ES POBRE"));
|
||||||
|
check("dice que un hueco declarado es contenido de valor", SYSTEM_BASE.includes("contenido de valor"));
|
||||||
|
|
||||||
|
// ── El contexto degrada con gracia ──
|
||||||
|
const sinNada = bloqueContexto({ transcripcion: "", notas: "", observaciones: "", cliente: "Ana", empresa: "", proyecto: "P" });
|
||||||
|
check("sin transcripcion lo declara explicitamente", sinNada.includes("No se entrego transcripcion"));
|
||||||
|
check("sin empresa no imprime parentesis vacios", !sinNada.includes("()"));
|
||||||
|
|
||||||
|
const conTodo = bloqueContexto({ transcripcion: "TRANS", notas: "NOTAS", observaciones: "OBS", cliente: "Ana", empresa: "ACME", proyecto: "P" });
|
||||||
|
check("incluye la transcripcion", conTodo.includes("TRANS"));
|
||||||
|
check("incluye las notas por partida", conTodo.includes("NOTAS"));
|
||||||
|
check("incluye las observaciones", conTodo.includes("OBS"));
|
||||||
|
check("incluye la empresa entre parentesis", conTodo.includes("(ACME)"));
|
||||||
|
|
||||||
|
// ── Los tres mensajes piden su herramienta y no filtran dinero ──
|
||||||
|
const econ: DatosEconomicos = {
|
||||||
|
partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
|
||||||
|
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
|
||||||
|
moneda: "MXN",
|
||||||
|
planBucefalo: null,
|
||||||
|
};
|
||||||
|
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
|
||||||
|
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
|
||||||
|
const diag = { hallazgos: [], valorProblema: { dimensiones: [], notaMetodologia: "" }, resultados: [] } as unknown as Diagnostico;
|
||||||
|
|
||||||
|
const m1 = mensajePaso1(ctx);
|
||||||
|
const m2 = mensajePaso2(ctx, hechos, econ);
|
||||||
|
const m3 = mensajePaso3(ctx, hechos, diag, econ);
|
||||||
|
|
||||||
|
check("paso 1 pide registrar_hechos", m1.includes("registrar_hechos"));
|
||||||
|
check("paso 2 pide registrar_diagnostico", m2.includes("registrar_diagnostico"));
|
||||||
|
check("paso 3 pide registrar_propuesta", m3.includes("registrar_propuesta"));
|
||||||
|
check("paso 1 exige citas textuales", m1.includes("TEXTUALES"));
|
||||||
|
|
||||||
|
// P1 estructural: el precio y el cuid NO pueden aparecer en ningun prompt.
|
||||||
|
for (const [nombre, msg] of [["paso 2", m2], ["paso 3", m3]] as const) {
|
||||||
|
check(`${nombre} NO filtra el precio de la partida`, !msg.includes("12345"));
|
||||||
|
check(`${nombre} NO filtra el total`, !msg.includes("14320"));
|
||||||
|
check(`${nombre} NO filtra el cuid real`, !msg.includes("cuid-secreto-abc123"));
|
||||||
|
check(`${nombre} SI incluye la refPartida`, msg.includes("P01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema } from "@/lib/propuesta/schemas";
|
||||||
|
|
||||||
|
let fallas = 0;
|
||||||
|
function check(n: string, ok: boolean, d = "") {
|
||||||
|
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
|
||||||
|
if (!ok) fallas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P1 por construccion: ningun schema declara un campo de dinero de E3 ──
|
||||||
|
const todos = JSON.stringify([
|
||||||
|
aJsonSchema(hechosSchema),
|
||||||
|
aJsonSchema(diagnosticoSchema),
|
||||||
|
aJsonSchema(redaccionSchema),
|
||||||
|
]);
|
||||||
|
for (const prohibido of ["precio", "total", "subtotal", "iva", "montoMinimo", "tarifaHora", "descuento"]) {
|
||||||
|
check(`ningun schema declara la propiedad "${prohibido}"`, !todos.includes(`"${prohibido}"`));
|
||||||
|
}
|
||||||
|
// montoAnualMXN si debe existir: es el costo del problema del cliente, no un precio nuestro.
|
||||||
|
check("montoAnualMXN si existe (costo del problema del cliente)", todos.includes("montoAnualMXN"));
|
||||||
|
|
||||||
|
// ── Formato de las claves ──
|
||||||
|
const redaccionBase = {
|
||||||
|
hero: { titulo: "Titulo de prueba", subtitulo: "x".repeat(25) },
|
||||||
|
citaDestacadaId: "C01",
|
||||||
|
alcance: [{ refPartida: "P01", descripcionResultado: "x".repeat(25) }],
|
||||||
|
beneficios: [],
|
||||||
|
exclusiones: [{ texto: "x".repeat(20), razon: "porque si" }],
|
||||||
|
backlogEvolucion: [],
|
||||||
|
notasInternas: [],
|
||||||
|
};
|
||||||
|
check("una redaccion bien formada valida", redaccionSchema.safeParse(redaccionBase).success);
|
||||||
|
check("refPartida en minuscula se rechaza",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "p01", descripcionResultado: "x".repeat(25) }] }).success);
|
||||||
|
check("refPartida sin ceros se rechaza",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, alcance: [{ refPartida: "P1", descripcionResultado: "x".repeat(25) }] }).success);
|
||||||
|
check("citaDestacadaId con otro prefijo se rechaza",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, citaDestacadaId: "H01" }).success);
|
||||||
|
check("exige al menos una exclusion",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, exclusiones: [] }).success);
|
||||||
|
check("rechaza propiedades desconocidas (strict)",
|
||||||
|
!redaccionSchema.safeParse({ ...redaccionBase, precioTotal: 99999 }).success);
|
||||||
|
|
||||||
|
// ── El calculo del valor debe venir desglosado ──
|
||||||
|
const diagBase = {
|
||||||
|
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Titulo del hallazgo", descripcion: "x".repeat(25), confianza: "confirmado", citas: [], hechos: [] }],
|
||||||
|
valorProblema: {
|
||||||
|
dimensiones: [{
|
||||||
|
tipo: "tiempo", descripcion: "x".repeat(12),
|
||||||
|
factores: [{ nombre: "a", valor: 8, confianza: "confirmado" }, { nombre: "b", valor: 52, confianza: "estimado" }], montoAnualMXN: 416,
|
||||||
|
confianza: "estimado", hechos: [],
|
||||||
|
}],
|
||||||
|
notaMetodologia: "n",
|
||||||
|
},
|
||||||
|
resultados: [],
|
||||||
|
};
|
||||||
|
check("un diagnostico bien formado valida", diagnosticoSchema.safeParse(diagBase).success,
|
||||||
|
JSON.stringify(diagnosticoSchema.safeParse(diagBase).error?.issues?.[0] ?? ""));
|
||||||
|
check("con monto, exige al menos 2 factores",
|
||||||
|
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1, confianza: "estimado" }], montoAnualMXN: 1 }] } }).success);
|
||||||
|
check("sin monto, factores vacio es lo correcto",
|
||||||
|
diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [], montoAnualMXN: null }] } }).success);
|
||||||
|
// Una dimension SIN la clave montoAnualMXN: es lo que manda el modelo cuando no tiene
|
||||||
|
// cifras, y por eso el campo lleva .default(null) en vez de solo .nullable().
|
||||||
|
const dimSinMonto = { tipo: "tiempo", descripcion: "x".repeat(12), factores: [], confianza: "por_validar", hechos: [] };
|
||||||
|
const sinMonto = { ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [dimSinMonto] } };
|
||||||
|
check("omitir montoAnualMXN se tolera", diagnosticoSchema.safeParse(sinMonto).success,
|
||||||
|
JSON.stringify(diagnosticoSchema.safeParse(sinMonto).error?.issues?.[0] ?? ""));
|
||||||
|
check("al omitirlo, Zod lo rellena con null",
|
||||||
|
diagnosticoSchema.parse(sinMonto).valorProblema.dimensiones[0].montoAnualMXN === null);
|
||||||
|
check("la dimension quedo plana: sin nivel `calculo`",
|
||||||
|
!JSON.stringify(aJsonSchema(diagnosticoSchema)).includes('"calculo"'));
|
||||||
|
check("un factor sin confianza se rechaza",
|
||||||
|
!diagnosticoSchema.safeParse({ ...diagBase, valorProblema: { ...diagBase.valorProblema, dimensiones: [{ ...diagBase.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 1 }, { nombre: "b", valor: 2 }], montoAnualMXN: 2 }] } }).success);
|
||||||
|
|
||||||
|
check("exige al menos un hallazgo",
|
||||||
|
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [] }).success);
|
||||||
|
check("urgencia fuera del semaforo se rechaza",
|
||||||
|
!diagnosticoSchema.safeParse({ ...diagBase, hallazgos: [{ ...diagBase.hallazgos[0], urgencia: "morado" }] }).success);
|
||||||
|
|
||||||
|
// ── Hechos ──
|
||||||
|
check("una cita corta se rechaza",
|
||||||
|
!hechosSchema.safeParse({ citas: [{ id: "C01", textoLiteral: "hola", quienLoDijo: "x" }], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success);
|
||||||
|
check("hechos vacios son validos (material pobre)",
|
||||||
|
hechosSchema.safeParse({ citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] }).success);
|
||||||
|
|
||||||
|
// ── Conversion a JSON Schema ──
|
||||||
|
const js = aJsonSchema(redaccionSchema);
|
||||||
|
check("el JSON Schema no lleva $schema", !("$schema" in js));
|
||||||
|
check("conserva additionalProperties", JSON.stringify(js).includes("additionalProperties"));
|
||||||
|
check("conserva las descripciones para el modelo", JSON.stringify(js).includes("description"));
|
||||||
|
check("el JSON Schema es serializable", typeof JSON.stringify(js) === "string" && JSON.stringify(js).length > 200);
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { validarPropuesta, normalizar, hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
|
||||||
|
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
|
||||||
|
import type { DatosEconomicos } from "@/lib/propuesta/economia";
|
||||||
|
|
||||||
|
let fallas = 0;
|
||||||
|
function check(n: string, ok: boolean, d = "") {
|
||||||
|
console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`);
|
||||||
|
if (!ok) fallas++;
|
||||||
|
}
|
||||||
|
const tiene = (a: Aviso[], regla: string) => a.some((x) => x.regla === regla);
|
||||||
|
|
||||||
|
const CITA = "no llevamos control de quien contesta el whatsapp el fin de semana";
|
||||||
|
|
||||||
|
const econ: DatosEconomicos = {
|
||||||
|
partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
|
||||||
|
totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
|
||||||
|
moneda: "MXN",
|
||||||
|
planBucefalo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = (): PropuestaConsultiva => ({
|
||||||
|
hechos: {
|
||||||
|
citas: [{ id: "C01", textoLiteral: CITA, quienLoDijo: "el socio" }],
|
||||||
|
hechos: [{ id: "H01", enunciado: "No hay control de la atencion", confianza: "confirmado", citas: ["C01"] }],
|
||||||
|
materialesPendientes: [{ texto: "Accesos al dominio", bloqueaEntrega: true }],
|
||||||
|
decisionesPendientes: [{ texto: "Definir responsable de atencion", quienDecide: "el socio" }],
|
||||||
|
mencionesFueraDeAlcance: [],
|
||||||
|
redFlags: [],
|
||||||
|
},
|
||||||
|
diagnostico: {
|
||||||
|
hallazgos: [{ id: "D01", urgencia: "rojo", titulo: "Prospectos sin seguimiento", descripcion: "Los mensajes del fin de semana se pierden.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }],
|
||||||
|
valorProblema: {
|
||||||
|
dimensiones: [{ tipo: "tiempo", descripcion: "Horas perdidas de atencion", factores: [{ nombre: "horas por semana", valor: 8, confianza: "confirmado" }, { nombre: "semanas", valor: 52, confianza: "confirmado" }, { nombre: "costo por hora", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }],
|
||||||
|
notaMetodologia: "Ocho horas por semana a costo cargado.",
|
||||||
|
},
|
||||||
|
resultados: [{ enunciado: "Ningun mensaje sin respuesta en mas de 24 horas", metrica: "tiempo de respuesta", lineaBase: null, periodoMedicion: "mensual" }],
|
||||||
|
},
|
||||||
|
redaccion: {
|
||||||
|
hero: { titulo: "Propuesta de digitalizacion", subtitulo: "Ordenar la atencion para dejar de perder prospectos." },
|
||||||
|
citaDestacadaId: "C01",
|
||||||
|
alcance: [{ refPartida: "P01", descripcionResultado: "Un lugar a donde mandar a los prospectos que llegan de los anuncios." }],
|
||||||
|
beneficios: [{ etiqueta: "Respuesta", texto: "Cada mensaje queda registrado y con responsable.", hallazgoId: "D01" }],
|
||||||
|
exclusiones: [{ texto: "No incluye migrar el historico de pedidos de los ultimos 4 anos.", razon: "Se evalua en fase 2." }],
|
||||||
|
backlogEvolucion: [],
|
||||||
|
notasInternas: ["El socio es quien decide."],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fuentes = { textoCliente: `El socio dijo: ${CITA}. Nada mas.`, textoInterno: "" };
|
||||||
|
|
||||||
|
check("normalizar quita acentos y puntuacion", normalizar("¿Como estas, Juan?") === "como estas juan");
|
||||||
|
check("normalizar colapsa espacios", normalizar(" a b ") === "a b");
|
||||||
|
|
||||||
|
const limpia = validarPropuesta(base(), econ, fuentes);
|
||||||
|
check("una propuesta valida no produce bloqueantes", !hayBloqueantes(limpia), JSON.stringify(limpia.slice(0, 2)));
|
||||||
|
|
||||||
|
let p = base(); p.redaccion.alcance[0].refPartida = "P99";
|
||||||
|
check("R2 detecta una partida inventada", tiene(validarPropuesta(p, econ, fuentes), "R2"));
|
||||||
|
|
||||||
|
p = base(); p.redaccion.alcance = [];
|
||||||
|
check("R2b avisa de una partida cotizada y no descrita", tiene(validarPropuesta(p, econ, fuentes), "R2b"));
|
||||||
|
|
||||||
|
p = base(); p.redaccion.citaDestacadaId = "C09";
|
||||||
|
check("R3 detecta una cita inexistente", tiene(validarPropuesta(p, econ, fuentes), "R3"));
|
||||||
|
|
||||||
|
p = base(); p.hechos.citas[0].textoLiteral = "esto nunca lo dijo nadie en la reunion";
|
||||||
|
check("R3 detecta una cita no verificable", tiene(validarPropuesta(p, econ, fuentes), "R3"));
|
||||||
|
|
||||||
|
p = base(); p.hechos.citas[0].textoLiteral = "NO LLEVAMOS CONTROL, de quien contesta... el WhatsApp el fin de semana!";
|
||||||
|
check("R3 tolera acentos, mayusculas y puntuacion", !tiene(validarPropuesta(p, econ, fuentes), "R3"));
|
||||||
|
|
||||||
|
p = base(); p.diagnostico.hallazgos[0].citas = []; p.diagnostico.hallazgos[0].hechos = [];
|
||||||
|
check("R4 degrada un confirmado sin evidencia", tiene(validarPropuesta(p, econ, fuentes), "R4"));
|
||||||
|
|
||||||
|
p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = 999999;
|
||||||
|
check("R5 detecta aritmetica que no cuadra", tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
||||||
|
|
||||||
|
p = base(); p.diagnostico.valorProblema.dimensiones[0].montoAnualMXN = null; p.diagnostico.valorProblema.dimensiones[0].factores = [];
|
||||||
|
check("R5 no se queja si no hay cifra", !tiene(validarPropuesta(p, econ, fuentes), "R5"));
|
||||||
|
|
||||||
|
p = base();
|
||||||
|
p.diagnostico.valorProblema.dimensiones[0].factores = p.diagnostico.valorProblema.dimensiones[0].factores.map((f) => ({ ...f, confianza: "estimado" as const }));
|
||||||
|
check("R5b avisa si el valor anual no tiene ni un factor confirmado", tiene(validarPropuesta(p, econ, fuentes), "R5b"));
|
||||||
|
check("R5b no avisa si hay al menos un confirmado", !tiene(validarPropuesta(base(), econ, fuentes), "R5b"));
|
||||||
|
|
||||||
|
p = base(); p.redaccion.beneficios[0].hallazgoId = "D99";
|
||||||
|
check("R6 detecta un beneficio colgante", tiene(validarPropuesta(p, econ, fuentes), "R6"));
|
||||||
|
|
||||||
|
p = base(); p.redaccion.beneficios[0].texto = "Lo integramos con HubSpot sin problema alguno.";
|
||||||
|
check("MARCA detecta otro CRM", tiene(validarPropuesta(p, econ, fuentes), "MARCA"));
|
||||||
|
|
||||||
|
p = base(); p.redaccion.hero.subtitulo = "Una inversion de 1,800 USD al mes para tu negocio.";
|
||||||
|
check("MONEDA detecta USD", tiene(validarPropuesta(p, econ, fuentes), "MONEDA"));
|
||||||
|
|
||||||
|
p = base(); p.redaccion.beneficios[0].texto = "Te garantizamos la posicion numero uno en Google.";
|
||||||
|
check("GARANTIA detecta la promesa", tiene(validarPropuesta(p, econ, fuentes), "GARANTIA"));
|
||||||
|
|
||||||
|
p = base(); p.redaccion.hero.subtitulo = "El sueldo de quien atiende sale muy caro hoy en dia.";
|
||||||
|
check("PII advierte sobre tema salarial", tiene(validarPropuesta(p, econ, fuentes), "PII"));
|
||||||
|
check("PII es advertencia, no bloqueante",
|
||||||
|
validarPropuesta(p, econ, fuentes).filter((a) => a.regla === "PII").every((a) => a.severidad === "advertencia"));
|
||||||
|
|
||||||
|
// ── R0: la fuga de notas internas, que es la razon de que exista ──
|
||||||
|
const interno = "el socio pidio descuento antes de entender el alcance del proyecto completo";
|
||||||
|
p = base(); p.redaccion.hero.subtitulo = `Contexto del proyecto: ${interno}.`;
|
||||||
|
check("R0 detecta la fuga de notas internas", tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
|
||||||
|
check("R0 es bloqueante",
|
||||||
|
validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }).filter((a) => a.regla === "R0").every((a) => a.severidad === "bloqueante"));
|
||||||
|
check("R0 no dispara si no hay fuga", !tiene(validarPropuesta(base(), econ, { ...fuentes, textoInterno: interno }), "R0"));
|
||||||
|
check("R0 sin notas internas no hace nada", !tiene(validarPropuesta(base(), econ, fuentes), "R0"));
|
||||||
|
|
||||||
|
// La fuga tambien se detecta en un hallazgo, no solo en el hero.
|
||||||
|
p = base(); p.diagnostico.hallazgos[0].descripcion = `Observado: ${interno}.`;
|
||||||
|
check("R0 mira todos los campos visibles, no solo el hero",
|
||||||
|
tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
|
||||||
|
|
||||||
|
// Las notasInternas del propio objeto NO deben disparar R0: no se imprimen.
|
||||||
|
p = base(); p.redaccion.notasInternas = [interno];
|
||||||
|
check("R0 no se queja de notasInternas (no se imprimen)",
|
||||||
|
!tiene(validarPropuesta(p, econ, { ...fuentes, textoInterno: interno }), "R0"));
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
@@ -45,6 +45,7 @@ export default async function EditarCotizacionPage({
|
|||||||
incluirBonos: cot.incluirBonos,
|
incluirBonos: cot.incluirBonos,
|
||||||
incluirFinanciamiento: cot.incluirFinanciamiento,
|
incluirFinanciamiento: cot.incluirFinanciamiento,
|
||||||
observaciones: cot.observaciones || "",
|
observaciones: cot.observaciones || "",
|
||||||
|
observacionesInternas: cot.observacionesInternas || "",
|
||||||
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
||||||
esDoble: cot.esDoble,
|
esDoble: cot.esDoble,
|
||||||
opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {},
|
opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { DeleteCotizacionButton } from "./DeleteButton";
|
|||||||
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
|
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
|
||||||
import { PreciosEditables } from "./PreciosEditables";
|
import { PreciosEditables } from "./PreciosEditables";
|
||||||
import { RegistroHorasPanel } from "./RegistroHorasPanel";
|
import { RegistroHorasPanel } from "./RegistroHorasPanel";
|
||||||
|
import PropuestaIAPanel from "@/components/PropuestaIAPanel";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -226,10 +227,31 @@ export default async function CotizacionDetailPage({
|
|||||||
|
|
||||||
{cot.observaciones && (
|
{cot.observaciones && (
|
||||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
<div className="bg-card-bg rounded-xl border border-border p-5">
|
||||||
<h3 className="font-semibold mb-2">Observaciones</h3>
|
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||||
|
Observaciones
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
|
||||||
|
Las ve el cliente
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
|
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PropuestaIAPanel cotizacionId={cot.id} numero={cot.numero} />
|
||||||
|
|
||||||
|
{/* Unico lugar donde se muestran las notas internas. No van a ningun
|
||||||
|
exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */}
|
||||||
|
{cot.observacionesInternas && (
|
||||||
|
<div className="rounded-xl border border-amber-300 bg-amber-50 p-5">
|
||||||
|
<h3 className="font-semibold mb-2 flex items-center gap-2 text-amber-900">
|
||||||
|
Notas internas
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
|
||||||
|
No sale de la app
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-amber-900/80 whitespace-pre-wrap">{cot.observacionesInternas}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export async function PUT(
|
|||||||
esDoble,
|
esDoble,
|
||||||
opciones,
|
opciones,
|
||||||
observaciones,
|
observaciones,
|
||||||
|
observacionesInternas,
|
||||||
cliente,
|
cliente,
|
||||||
servicios,
|
servicios,
|
||||||
planBucefalo,
|
planBucefalo,
|
||||||
@@ -158,6 +159,7 @@ export async function PUT(
|
|||||||
...(esDoble !== undefined && { esDoble }),
|
...(esDoble !== undefined && { esDoble }),
|
||||||
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
||||||
...(observaciones !== undefined && { observaciones }),
|
...(observaciones !== undefined && { observaciones }),
|
||||||
|
...(observacionesInternas !== undefined && { observacionesInternas }),
|
||||||
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
|
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
esDoble,
|
esDoble,
|
||||||
opciones,
|
opciones,
|
||||||
observaciones,
|
observaciones,
|
||||||
|
observacionesInternas,
|
||||||
cliente,
|
cliente,
|
||||||
asesorId,
|
asesorId,
|
||||||
servicios,
|
servicios,
|
||||||
@@ -81,6 +82,7 @@ export async function POST(request: NextRequest) {
|
|||||||
esDoble: esDoble ?? false,
|
esDoble: esDoble ?? false,
|
||||||
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
||||||
observaciones: observaciones || null,
|
observaciones: observaciones || null,
|
||||||
|
observacionesInternas: observacionesInternas || null,
|
||||||
clienteId: clienteIdFinal,
|
clienteId: clienteIdFinal,
|
||||||
asesorId,
|
asesorId,
|
||||||
estado: "borrador",
|
estado: "borrador",
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export async function GET(
|
|||||||
include: {
|
include: {
|
||||||
cliente: true,
|
cliente: true,
|
||||||
asesor: true,
|
asesor: true,
|
||||||
servicios: { include: { servicioCatalogo: true } },
|
// Mismo orderBy que el PDF: los dos documentos van en el mismo correo y
|
||||||
|
// deben listar las partidas en el mismo orden.
|
||||||
|
servicios: {
|
||||||
|
include: { servicioCatalogo: true },
|
||||||
|
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
planBucefalo: true,
|
planBucefalo: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -67,6 +72,9 @@ export async function GET(
|
|||||||
planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
|
planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
|
||||||
colorPrimario: branding.colorPrimario || "#2563eb",
|
colorPrimario: branding.colorPrimario || "#2563eb",
|
||||||
colorSecundario: branding.colorSecundario || "#1e293b",
|
colorSecundario: branding.colorSecundario || "#1e293b",
|
||||||
|
incluirIva: cot.incluirIva,
|
||||||
|
// El Excel es un documento del cliente: solo el texto del cliente.
|
||||||
|
observaciones: cot.observaciones,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buffer = await buildCotizacionExcel(data);
|
const buffer = await buildCotizacionExcel(data);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export async function POST(request: NextRequest) {
|
|||||||
planBucefaloNivel: draft.planBucefaloNivel,
|
planBucefaloNivel: draft.planBucefaloNivel,
|
||||||
colorPrimario: branding.colorPrimario || "#2563eb",
|
colorPrimario: branding.colorPrimario || "#2563eb",
|
||||||
colorSecundario: branding.colorSecundario || "#1e293b",
|
colorSecundario: branding.colorSecundario || "#1e293b",
|
||||||
|
observaciones: draft.observaciones,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buffer = await buildCotizacionExcel(data);
|
const buffer = await buildCotizacionExcel(data);
|
||||||
|
|||||||
@@ -10,18 +10,25 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [cot, config, branding] = await Promise.all([
|
const [cot, config, branding, bonos] = await Promise.all([
|
||||||
prisma.cotizacion.findUnique({
|
prisma.cotizacion.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
cliente: true,
|
cliente: true,
|
||||||
asesor: true,
|
asesor: true,
|
||||||
servicios: { include: { servicioCatalogo: true } },
|
// orderBy explicito: drawSection abre un encabezado de fase nuevo cada vez
|
||||||
|
// que cambia serv.fase, o sea que asume el array agrupado. Sin esto el orden
|
||||||
|
// lo decide Postgres y el PDF puede salir distinto del Excel del mismo envio.
|
||||||
|
servicios: {
|
||||||
|
include: { servicioCatalogo: true },
|
||||||
|
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
planBucefalo: true,
|
planBucefalo: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
getConfigBancaria(),
|
getConfigBancaria(),
|
||||||
getConfigBranding(),
|
getConfigBranding(),
|
||||||
|
prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!cot) {
|
if (!cot) {
|
||||||
@@ -61,6 +68,10 @@ export async function GET(
|
|||||||
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
||||||
planBucefaloPrecio: cot.planBucefalo?.precio || 0,
|
planBucefaloPrecio: cot.planBucefalo?.precio || 0,
|
||||||
incluirBonos: cot.incluirBonos,
|
incluirBonos: cot.incluirBonos,
|
||||||
|
bonos,
|
||||||
|
incluirIva: cot.incluirIva,
|
||||||
|
// Solo el texto del cliente. observacionesInternas no se pasa nunca.
|
||||||
|
observaciones: cot.observaciones,
|
||||||
configBancaria: config,
|
configBancaria: config,
|
||||||
...branding,
|
...branding,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { generateCotizacionPDF } from "@/lib/pdf-generator";
|
import { generateCotizacionPDF } from "@/lib/pdf-generator";
|
||||||
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
|
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
|
||||||
import { getConfigBranding } from "@/lib/config-helpers";
|
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -42,7 +43,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const fechaCot = new Date(draft.fecha);
|
const fechaCot = new Date(draft.fecha);
|
||||||
const vigencia = calcularVigencia(fechaCot);
|
const vigencia = calcularVigencia(fechaCot);
|
||||||
|
|
||||||
const branding = await getConfigBranding();
|
// El borrador leia solo el branding, asi que imprimia los datos bancarios
|
||||||
|
// hardcodeados del generador en vez de los configurados. El Excel de borrador
|
||||||
|
// si leia ambos: esto empareja los dos.
|
||||||
|
const [branding, configBancaria, bonos] = await Promise.all([
|
||||||
|
getConfigBranding(),
|
||||||
|
getConfigBancaria(),
|
||||||
|
prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }),
|
||||||
|
]);
|
||||||
const empresa = draft.clienteEmpresa || draft.clienteNombre;
|
const empresa = draft.clienteEmpresa || draft.clienteNombre;
|
||||||
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`;
|
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`;
|
||||||
|
|
||||||
@@ -63,6 +71,10 @@ export async function POST(request: NextRequest) {
|
|||||||
planBucefaloNivel: draft.planBucefaloNivel,
|
planBucefaloNivel: draft.planBucefaloNivel,
|
||||||
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
|
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
|
||||||
incluirBonos: draft.incluirBonos,
|
incluirBonos: draft.incluirBonos,
|
||||||
|
bonos,
|
||||||
|
// El cliente ya mandaba observaciones en el body; el generador nunca las recibia.
|
||||||
|
observaciones: draft.observaciones,
|
||||||
|
configBancaria,
|
||||||
...branding,
|
...branding,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getConfigBranding } from "@/lib/config-helpers";
|
||||||
|
import { sanitizeFilename } from "@/lib/calculators";
|
||||||
|
import { cargarEconomia } from "@/lib/propuesta/economia";
|
||||||
|
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
|
||||||
|
import { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
|
||||||
|
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
|
||||||
|
|
||||||
|
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente
|
||||||
|
* GET /api/propuesta-ia/:id/pdf?anexo=1 -> anexo interno (archivo aparte, a proposito) */
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const anexo = request.nextUrl.searchParams.get("anexo") === "1";
|
||||||
|
|
||||||
|
const [cot, fila, branding] = await Promise.all([
|
||||||
|
prisma.cotizacion.findUnique({ where: { id }, include: { cliente: true, asesor: true } }),
|
||||||
|
prisma.propuestaIA.findFirst({ where: { cotizacionId: id }, orderBy: { createdAt: "desc" } }),
|
||||||
|
getConfigBranding(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
||||||
|
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
|
||||||
|
|
||||||
|
// Los avisos bloqueantes tienen que bloquear DE VERDAD el documento del cliente.
|
||||||
|
// Antes esta ruta ni siquiera leia `avisos`: la severidad "bloqueante" era
|
||||||
|
// decorativa y el PDF se descargaba igual con una fuga de notas internas o un
|
||||||
|
// monto en USD dentro. El anexo interno SI se permite: es justamente el que el
|
||||||
|
// asesor necesita para entender que hay que corregir.
|
||||||
|
const avisos = Array.isArray(fila.avisos) ? (fila.avisos as unknown as Aviso[]) : [];
|
||||||
|
if (!anexo && hayBloqueantes(avisos)) {
|
||||||
|
const cuales = avisos
|
||||||
|
.filter((a) => a.severidad === "bloqueante")
|
||||||
|
.map((a) => `${a.regla}${a.ruta ? ` (${a.ruta})` : ""}: ${a.mensaje}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"La propuesta tiene avisos bloqueantes sin resolver. Corrigelos en el panel y guarda antes de descargar el documento del cliente.",
|
||||||
|
bloqueantes: cuales,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
|
||||||
|
const economia = await cargarEconomia(id);
|
||||||
|
|
||||||
|
const datos = {
|
||||||
|
propuesta,
|
||||||
|
economia,
|
||||||
|
cliente: cot.cliente.nombre,
|
||||||
|
empresa: cot.cliente.empresa || "",
|
||||||
|
asesor: cot.asesor.name,
|
||||||
|
numero: cot.numero,
|
||||||
|
fecha: cot.fecha,
|
||||||
|
vigencia: cot.vigencia,
|
||||||
|
proyecto: cot.proyecto,
|
||||||
|
branding,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = anexo ? await generarAnexoInternoPDF(datos) : await generarPropuestaPDF(datos);
|
||||||
|
const empresa = cot.cliente.empresa || cot.cliente.nombre;
|
||||||
|
const nombre = anexo
|
||||||
|
? `${sanitizeFilename(empresa)} - ${cot.numero} - INTERNO NO ENVIAR`
|
||||||
|
: `${sanitizeFilename(empresa)} - ${cot.numero} - Propuesta consultiva`;
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(buffer), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { cargarEconomia } from "@/lib/propuesta/economia";
|
||||||
|
import { generarPropuesta } from "@/lib/propuesta/pipeline";
|
||||||
|
import { validarPropuesta } from "@/lib/propuesta/validacion";
|
||||||
|
import { propuestaCompletaSchema, type PropuestaConsultiva } from "@/lib/propuesta/schemas";
|
||||||
|
|
||||||
|
/** Reune el contexto humano de la cotizacion. Distingue lo que ve el cliente de lo
|
||||||
|
* interno: el interno se manda al modelo (necesita el contexto) pero R0 comprueba
|
||||||
|
* despues que no se haya colado a la salida. */
|
||||||
|
async function contextoDe(cotizacionId: string) {
|
||||||
|
const cot = await prisma.cotizacion.findUnique({
|
||||||
|
where: { id: cotizacionId },
|
||||||
|
include: { cliente: true, asesor: true, servicios: true },
|
||||||
|
});
|
||||||
|
if (!cot) return null;
|
||||||
|
const notasPartidas = cot.servicios
|
||||||
|
.map((s) => s.notas)
|
||||||
|
.filter((n): n is string => Boolean(n && n.trim()))
|
||||||
|
.join("\n");
|
||||||
|
return { cot, notasPartidas };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const transcripcion: string = typeof body.transcripcion === "string" ? body.transcripcion : "";
|
||||||
|
|
||||||
|
const ctx = await contextoDe(id);
|
||||||
|
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
||||||
|
const { cot, notasPartidas } = ctx;
|
||||||
|
|
||||||
|
// La doble propuesta todavia no esta soportada en el documento consultivo: su
|
||||||
|
// estructura asume UNA lista de alcance y UNA caja de totales, asi que sumaria las
|
||||||
|
// dos opciones —que son alternativas excluyentes— y mostraria un total inflado.
|
||||||
|
// Mejor no generar nada que generar un documento con un total que no existe.
|
||||||
|
if (cot.esDoble) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Esta cotizacion es de doble propuesta y el documento consultivo todavia no las soporta: sumaria las dos opciones como si fueran una sola. Genera la propuesta desde una cotizacion de opcion unica.",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const economia = await cargarEconomia(id);
|
||||||
|
if (!economia.partidas.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "La cotizacion no tiene partidas seleccionadas. Agrega servicios antes de generar la propuesta." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { propuesta, traza } = await generarPropuesta({
|
||||||
|
entrada: {
|
||||||
|
transcripcion,
|
||||||
|
notas: notasPartidas,
|
||||||
|
observaciones: cot.observaciones || "",
|
||||||
|
cliente: cot.cliente.nombre,
|
||||||
|
empresa: cot.cliente.empresa || "",
|
||||||
|
proyecto: cot.proyecto,
|
||||||
|
},
|
||||||
|
economia,
|
||||||
|
});
|
||||||
|
|
||||||
|
const avisos = validarPropuesta(propuesta, economia, {
|
||||||
|
textoCliente: [transcripcion, cot.observaciones || "", notasPartidas].join("\n"),
|
||||||
|
textoInterno: cot.observacionesInternas || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fila = await prisma.propuestaIA.create({
|
||||||
|
data: {
|
||||||
|
cotizacionId: id,
|
||||||
|
contenidoIA: propuesta as unknown as object,
|
||||||
|
traza: traza as unknown as object,
|
||||||
|
avisos: avisos as unknown as object,
|
||||||
|
modelo: traza.modelo,
|
||||||
|
transcripcion: transcripcion || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ id: fila.id, propuesta, avisos, traza });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const fila = await prisma.propuestaIA.findFirst({
|
||||||
|
where: { cotizacionId: id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
if (!fila) return NextResponse.json({ propuesta: null });
|
||||||
|
return NextResponse.json({
|
||||||
|
id: fila.id,
|
||||||
|
propuesta: fila.contenidoEditado ?? fila.contenidoIA,
|
||||||
|
editada: fila.contenidoEditado !== null,
|
||||||
|
avisos: fila.avisos,
|
||||||
|
traza: fila.traza,
|
||||||
|
createdAt: fila.createdAt,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = propuestaCompletaSchema.safeParse(body.propuesta);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: z.prettifyError(parsed.error) }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fila = await prisma.propuestaIA.findFirst({
|
||||||
|
where: { cotizacionId: id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
if (!fila) return NextResponse.json({ error: "No hay propuesta generada para esta cotizacion" }, { status: 404 });
|
||||||
|
|
||||||
|
// La edicion del asesor se revalida: pudo introducir una fuga, un USD o una
|
||||||
|
// promesa de resultado sin darse cuenta.
|
||||||
|
//
|
||||||
|
// Se incluye la transcripcion GUARDADA con la propuesta. Antes solo vivia en
|
||||||
|
// memoria durante la peticion de generacion, asi que al editar R3 se quedaba sin
|
||||||
|
// fuente contra la cual comprobar la cita literal y el aviso desaparecia solo.
|
||||||
|
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
|
||||||
|
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
|
||||||
|
textoCliente: [fila.transcripcion || "", ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
|
||||||
|
textoInterno: ctx?.cot.observacionesInternas || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.propuestaIA.update({
|
||||||
|
where: { id: fila.id },
|
||||||
|
data: {
|
||||||
|
contenidoEditado: parsed.data as unknown as object,
|
||||||
|
avisos: avisos as unknown as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, avisos });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +84,7 @@ export interface ExistingData {
|
|||||||
incluirBonos: boolean;
|
incluirBonos: boolean;
|
||||||
incluirFinanciamiento: boolean;
|
incluirFinanciamiento: boolean;
|
||||||
observaciones: string;
|
observaciones: string;
|
||||||
|
observacionesInternas?: string;
|
||||||
planBucefaloNivel: string | null;
|
planBucefaloNivel: string | null;
|
||||||
estado: string;
|
estado: string;
|
||||||
servicios: ServicioSeleccionado[];
|
servicios: ServicioSeleccionado[];
|
||||||
@@ -165,6 +168,7 @@ export function CotizacionForm({
|
|||||||
store.setField("incluirBonos", existingData.incluirBonos);
|
store.setField("incluirBonos", existingData.incluirBonos);
|
||||||
store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento);
|
store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento);
|
||||||
store.setField("observaciones", existingData.observaciones);
|
store.setField("observaciones", existingData.observaciones);
|
||||||
|
store.setField("observacionesInternas", existingData.observacionesInternas ?? "");
|
||||||
store.setField("planBucefaloNivel", existingData.planBucefaloNivel);
|
store.setField("planBucefaloNivel", existingData.planBucefaloNivel);
|
||||||
store.setField("esDoble", existingData.esDoble ?? false);
|
store.setField("esDoble", existingData.esDoble ?? false);
|
||||||
store.setField("opciones", existingData.opciones ?? {});
|
store.setField("opciones", existingData.opciones ?? {});
|
||||||
@@ -333,6 +337,7 @@ export function CotizacionForm({
|
|||||||
esDoble: store.draft.esDoble,
|
esDoble: store.draft.esDoble,
|
||||||
opciones: store.draft.esDoble ? store.draft.opciones : undefined,
|
opciones: store.draft.esDoble ? store.draft.opciones : undefined,
|
||||||
observaciones: store.draft.observaciones,
|
observaciones: store.draft.observaciones,
|
||||||
|
observacionesInternas: store.draft.observacionesInternas,
|
||||||
cliente: {
|
cliente: {
|
||||||
nombre: store.draft.clienteNombre,
|
nombre: store.draft.clienteNombre,
|
||||||
empresa: store.draft.clienteEmpresa,
|
empresa: store.draft.clienteEmpresa,
|
||||||
@@ -1217,20 +1222,52 @@ export function CotizacionForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
{/* Dos campos deliberadamente distintos. El de arriba se imprime en el PDF y
|
||||||
<label className="block text-sm font-medium text-muted mb-1">
|
el Excel que recibe el cliente; el de abajo no sale de la aplicacion.
|
||||||
|
La diferencia visual es la barrera contra escribir en el equivocado. */}
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border p-5 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||||
|
<Eye className="w-4 h-4 text-primary" />
|
||||||
Observaciones
|
Observaciones
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
|
||||||
|
Las ve el cliente
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-xs text-muted mb-2">
|
||||||
|
Se imprime en el PDF y en el Excel que le envias. Condiciones, supuestos y
|
||||||
|
aclaraciones del acuerdo.
|
||||||
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={store.draft.observaciones}
|
value={store.draft.observaciones}
|
||||||
onChange={(e) =>
|
onChange={(e) => store.setField("observaciones", e.target.value)}
|
||||||
store.setField("observaciones", e.target.value)
|
|
||||||
}
|
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Notas adicionales para la cotizacion..."
|
placeholder="Ej: El anticipo es del 50%. El avance queda condicionado a la entrega de accesos."
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium mb-1 text-amber-900">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
Notas internas
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
|
||||||
|
No sale de la app
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-amber-800 mb-2">
|
||||||
|
Contexto de la reunion, con quien hablar, riesgos, recordatorios. No se imprime
|
||||||
|
en ningun documento ni se expone por el API.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={store.draft.observacionesInternas}
|
||||||
|
onChange={(e) => store.setField("observacionesInternas", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ej: El que decide es el socio, no el que vino a la reunion. Pidio descuento antes de ver el alcance."
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden lg:block w-80 shrink-0">
|
<div className="hidden lg:block w-80 shrink-0">
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Sparkles, Download, Save, AlertTriangle, Info, Lock, RefreshCw, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { useToast, useConfirm } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
|
// Panel de la propuesta consultiva: generar, revisar, editar y descargar.
|
||||||
|
//
|
||||||
|
// Regla de diseno deliberada: los avisos van ARRIBA de los botones de descarga.
|
||||||
|
// El objetivo es que revisar sea mas facil que aprobar, no al reves.
|
||||||
|
|
||||||
|
type Confianza = "confirmado" | "estimado" | "por_validar";
|
||||||
|
type Urgencia = "rojo" | "ambar" | "azul" | "verde";
|
||||||
|
|
||||||
|
interface Aviso {
|
||||||
|
regla: string;
|
||||||
|
severidad: "bloqueante" | "advertencia";
|
||||||
|
mensaje: string;
|
||||||
|
ruta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Propuesta {
|
||||||
|
hechos: {
|
||||||
|
citas: { id: string; textoLiteral: string; quienLoDijo: string }[];
|
||||||
|
hechos: { id: string; enunciado: string; confianza: Confianza; citas: string[] }[];
|
||||||
|
materialesPendientes: { texto: string; bloqueaEntrega: boolean }[];
|
||||||
|
decisionesPendientes: { texto: string; quienDecide: string }[];
|
||||||
|
mencionesFueraDeAlcance: string[];
|
||||||
|
redFlags: { senal: string; severidad: string }[];
|
||||||
|
};
|
||||||
|
diagnostico: {
|
||||||
|
hallazgos: { id: string; urgencia: Urgencia; titulo: string; descripcion: string; confianza: Confianza; citas: string[]; hechos: string[] }[];
|
||||||
|
valorProblema: {
|
||||||
|
dimensiones: { tipo: string; descripcion: string; calculo: { factores: { nombre: string; valor: number }[]; montoAnualMXN: number | null }; confianza: Confianza; hechos: string[] }[];
|
||||||
|
notaMetodologia: string;
|
||||||
|
};
|
||||||
|
resultados: { enunciado: string; metrica: string; lineaBase: string | null; periodoMedicion: string }[];
|
||||||
|
};
|
||||||
|
redaccion: {
|
||||||
|
hero: { titulo: string; subtitulo: string };
|
||||||
|
citaDestacadaId: string;
|
||||||
|
alcance: { refPartida: string; descripcionResultado: string }[];
|
||||||
|
beneficios: { etiqueta: string; texto: string; hallazgoId: string }[];
|
||||||
|
exclusiones: { texto: string; razon: string }[];
|
||||||
|
backlogEvolucion: { problema: string; momentoSugerido: string }[];
|
||||||
|
notasInternas: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_URGENCIA: Record<Urgencia, string> = {
|
||||||
|
rojo: "bg-red-500",
|
||||||
|
ambar: "bg-amber-500",
|
||||||
|
azul: "bg-blue-500",
|
||||||
|
verde: "bg-green-500",
|
||||||
|
};
|
||||||
|
const ETIQUETA_URGENCIA: Record<Urgencia, string> = {
|
||||||
|
rojo: "Problema critico",
|
||||||
|
ambar: "Area de mejora",
|
||||||
|
azul: "Oportunidad",
|
||||||
|
verde: "Ventaja existente",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT = "w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary";
|
||||||
|
|
||||||
|
export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: string; numero?: string }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const [abierto, setAbierto] = useState(false);
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
const [generando, setGenerando] = useState(false);
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
const [transcripcion, setTranscripcion] = useState("");
|
||||||
|
const [propuesta, setPropuesta] = useState<Propuesta | null>(null);
|
||||||
|
const [avisos, setAvisos] = useState<Aviso[]>([]);
|
||||||
|
const [editada, setEditada] = useState(false);
|
||||||
|
const [sucio, setSucio] = useState(false);
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`);
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.propuesta) {
|
||||||
|
setPropuesta(d.propuesta);
|
||||||
|
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
|
||||||
|
setEditada(Boolean(d.editada));
|
||||||
|
setAbierto(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
}, [cotizacionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void cargar();
|
||||||
|
}, [cargar]);
|
||||||
|
|
||||||
|
async function generar() {
|
||||||
|
if (propuesta) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: "Regenerar la propuesta",
|
||||||
|
message: "Se generara una propuesta nueva. La version actual y tus ediciones quedaran reemplazadas.",
|
||||||
|
confirmText: "Regenerar",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setGenerando(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ transcripcion }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
toast(d.error || "No se pudo generar la propuesta", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPropuesta(d.propuesta);
|
||||||
|
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
|
||||||
|
setEditada(false);
|
||||||
|
setSucio(false);
|
||||||
|
setAbierto(true);
|
||||||
|
const bloqueantes = (d.avisos as Aviso[]).filter((a) => a.severidad === "bloqueante").length;
|
||||||
|
toast(
|
||||||
|
bloqueantes > 0
|
||||||
|
? `Propuesta generada con ${bloqueantes} aviso(s) que debes resolver antes de enviarla.`
|
||||||
|
: "Propuesta generada. Revisala antes de enviarla.",
|
||||||
|
bloqueantes > 0 ? "error" : "success"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "Error al generar", "error");
|
||||||
|
} finally {
|
||||||
|
setGenerando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardar() {
|
||||||
|
if (!propuesta) return;
|
||||||
|
setGuardando(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ propuesta }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
toast(typeof d.error === "string" ? d.error : "No se pudo guardar", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
|
||||||
|
setEditada(true);
|
||||||
|
setSucio(false);
|
||||||
|
toast("Cambios guardados y revalidados.", "success");
|
||||||
|
} finally {
|
||||||
|
setGuardando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aplica un cambio al objeto y marca el panel como sucio. */
|
||||||
|
function editar(fn: (p: Propuesta) => void) {
|
||||||
|
setPropuesta((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const copia = structuredClone(prev) as Propuesta;
|
||||||
|
fn(copia);
|
||||||
|
return copia;
|
||||||
|
});
|
||||||
|
setSucio(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
|
||||||
|
const advertencias = avisos.filter((a) => a.severidad === "advertencia");
|
||||||
|
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
|
||||||
|
|
||||||
|
if (cargando) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border p-5">
|
||||||
|
<p className="text-sm text-muted">Cargando propuesta consultiva...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setAbierto((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-4 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary" />
|
||||||
|
Propuesta consultiva con IA
|
||||||
|
{editada && (
|
||||||
|
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary-light text-primary">
|
||||||
|
editada por ti
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{abierto ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{abierto && (
|
||||||
|
<div className="px-5 pb-5 space-y-5 border-t border-border pt-5">
|
||||||
|
{/* ── Entrada ── */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Transcripcion de la reunion (opcional)</label>
|
||||||
|
<p className="text-xs text-muted mb-2">
|
||||||
|
Pega aqui lo que se dijo en la reunion de discovery. Sin transcripcion, la IA trabaja
|
||||||
|
solo con las observaciones y notas de la cotizacion, y marcara mas cosas por validar.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={transcripcion}
|
||||||
|
onChange={(e) => setTranscripcion(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Pega la transcripcion..."
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={generar}
|
||||||
|
disabled={generando}
|
||||||
|
className="mt-3 flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generando ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{generando ? "Generando (tarda un par de minutos)..." : propuesta ? "Regenerar" : "Generar propuesta"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{propuesta && (
|
||||||
|
<>
|
||||||
|
{/* ── Avisos: van ARRIBA de las descargas a proposito ── */}
|
||||||
|
{bloqueantes.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-red-300 bg-red-50 p-4">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold text-red-800 mb-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
{bloqueantes.length} aviso(s) que debes resolver antes de enviar
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{bloqueantes.map((a, i) => (
|
||||||
|
<li key={i} className="text-xs text-red-800">
|
||||||
|
<span className="font-mono font-semibold">{a.regla}</span>
|
||||||
|
{a.ruta && <span className="text-red-600"> · {a.ruta}</span>} — {a.mensaje}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{advertencias.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold text-amber-900 mb-2">
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
{advertencias.length} cosa(s) que conviene revisar
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{advertencias.map((a, i) => (
|
||||||
|
<li key={i} className="text-xs text-amber-900">
|
||||||
|
<span className="font-mono font-semibold">{a.regla}</span>
|
||||||
|
{a.ruta && <span className="opacity-70"> · {a.ruta}</span>} — {a.mensaje}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{avisos.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-green-300 bg-green-50 p-3">
|
||||||
|
<p className="text-sm text-green-800">Sin avisos de validacion. Revisa el contenido de todas formas.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Edicion ── */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Titulo</label>
|
||||||
|
<input
|
||||||
|
value={propuesta.redaccion.hero.titulo}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.hero.titulo = e.target.value; })}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Subtitulo</label>
|
||||||
|
<textarea
|
||||||
|
value={propuesta.redaccion.hero.subtitulo}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.hero.subtitulo = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Hallazgos del diagnostico
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{propuesta.diagnostico.hallazgos.map((h, i) => (
|
||||||
|
<div key={h.id} className="border border-border rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full ${COLOR_URGENCIA[h.urgencia]}`} />
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
{ETIQUETA_URGENCIA[h.urgencia]} · {h.confianza.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={h.titulo}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].titulo = e.target.value; })}
|
||||||
|
className={`${INPUT} mb-2 font-medium`}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={h.descripcion}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].descripcion = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Alcance (la descripcion; los precios los pone el sistema)
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.redaccion.alcance.map((a, i) => (
|
||||||
|
<div key={a.refPartida} className="flex gap-2 items-start">
|
||||||
|
<span className="text-xs font-mono text-muted mt-2.5 shrink-0">{a.refPartida}</span>
|
||||||
|
<textarea
|
||||||
|
value={a.descripcionResultado}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.alcance[i].descripcionResultado = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">Exclusiones</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.redaccion.exclusiones.map((ex, i) => (
|
||||||
|
<textarea
|
||||||
|
key={i}
|
||||||
|
value={ex.texto}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.exclusiones[i].texto = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Por que tiene sentido
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.redaccion.beneficios.map((b, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-start">
|
||||||
|
<input
|
||||||
|
value={b.etiqueta}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].etiqueta = e.target.value; })}
|
||||||
|
className={`${INPUT} max-w-[9rem] shrink-0`}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={b.texto}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].texto = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Los grupos de abajo tambien se imprimen al cliente, asi que tambien
|
||||||
|
pueden disparar un aviso bloqueante. Si no fueran editables, un
|
||||||
|
bloqueante ahi no tendria mas salida que regenerar la propuesta. */}
|
||||||
|
|
||||||
|
{citaDestacada && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">
|
||||||
|
Cita destacada
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted mb-2">
|
||||||
|
Se imprime literal. Editala solo para quitar un dato sensible, nunca para
|
||||||
|
mejorarle la redaccion al cliente.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={citaDestacada.textoLiteral}
|
||||||
|
onChange={(e) =>
|
||||||
|
editar((p) => {
|
||||||
|
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
|
||||||
|
if (c) c.textoLiteral = e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
className={`${INPUT} mb-2`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={citaDestacada.quienLoDijo}
|
||||||
|
onChange={(e) =>
|
||||||
|
editar((p) => {
|
||||||
|
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
|
||||||
|
if (c) c.quienLoDijo = e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Quien lo dijo (usa el rol, no el nombre)"
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propuesta.diagnostico.valorProblema.dimensiones.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Lo que cuesta no resolverlo
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.diagnostico.valorProblema.dimensiones.map((dim, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-start">
|
||||||
|
<span className="text-xs text-muted mt-2.5 shrink-0 w-20">{dim.tipo}</span>
|
||||||
|
<textarea
|
||||||
|
value={dim.descripcion}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.dimensiones[i].descripcion = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={propuesta.diagnostico.valorProblema.notaMetodologia}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.notaMetodologia = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Nota de metodologia"
|
||||||
|
className={`${INPUT} mt-2`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propuesta.diagnostico.resultados.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Resultados a lograr
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.diagnostico.resultados.map((r, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<textarea
|
||||||
|
value={r.enunciado}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].enunciado = e.target.value; })}
|
||||||
|
rows={2}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={r.metrica}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].metrica = e.target.value; })}
|
||||||
|
placeholder="Metrica"
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={r.periodoMedicion}
|
||||||
|
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].periodoMedicion = e.target.value; })}
|
||||||
|
placeholder="Periodo"
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(propuesta.hechos.materialesPendientes.length > 0 ||
|
||||||
|
propuesta.hechos.decisionesPendientes.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Que necesitamos de ustedes
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.hechos.materialesPendientes.map((m, i) => (
|
||||||
|
<input
|
||||||
|
key={`mat-${i}`}
|
||||||
|
value={m.texto}
|
||||||
|
onChange={(e) => editar((p) => { p.hechos.materialesPendientes[i].texto = e.target.value; })}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{propuesta.hechos.decisionesPendientes.map((dd, i) => (
|
||||||
|
<div key={`dec-${i}`} className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={dd.texto}
|
||||||
|
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].texto = e.target.value; })}
|
||||||
|
rows={1}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={dd.quienDecide}
|
||||||
|
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].quienDecide = e.target.value; })}
|
||||||
|
placeholder="Quien decide"
|
||||||
|
className={`${INPUT} max-w-[11rem] shrink-0`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propuesta.redaccion.backlogEvolucion.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
|
||||||
|
Registrado para mas adelante
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propuesta.redaccion.backlogEvolucion.map((b, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={b.problema}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].problema = e.target.value; })}
|
||||||
|
rows={1}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={b.momentoSugerido}
|
||||||
|
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].momentoSugerido = e.target.value; })}
|
||||||
|
placeholder="Cuando"
|
||||||
|
className={`${INPUT} max-w-[11rem] shrink-0`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={guardar}
|
||||||
|
disabled={!sucio || guardando}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
|
||||||
|
invitar a un clic que va a fallar. La descarga del anexo interno
|
||||||
|
sigue disponible: es la que ayuda a entender que corregir. */}
|
||||||
|
{bloqueantes.length > 0 ? (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm text-muted bg-gray-50 cursor-not-allowed"
|
||||||
|
title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Propuesta consultiva (PDF) — bloqueada
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`/api/propuesta-ia/${cotizacionId}/pdf`}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Propuesta consultiva (PDF)
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-red-300 bg-red-50 text-red-800 hover:bg-red-100"
|
||||||
|
title="Contiene ponderacion, red flags y notas internas"
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
Anexo interno — NO ENVIAR
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{sucio && (
|
||||||
|
<span className="text-xs text-amber-700">
|
||||||
|
Tienes cambios sin guardar. El PDF se genera con lo ultimo guardado.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,69 @@ export function calcularTotalesOpcion(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Totales de una cotizacion: FUENTE DE VERDAD UNICA -----
|
||||||
|
// Antes este calculo estaba duplicado a mano con .filter().reduce() en siete
|
||||||
|
// consumidores (PDF, Excel, detalle, PreciosEditables, formulario, lista y dashboard),
|
||||||
|
// con criterios que no coincidian, y el IVA estaba hardcodeado como * 1.16 en los dos
|
||||||
|
// exportadores ignorando IVA_RATE y el flag incluirIva. Toda comparacion de totales
|
||||||
|
// (y cualquier documento que deba coincidir con el PDF) debe pasar por aqui.
|
||||||
|
//
|
||||||
|
// Nota: los subtotales son SIN IVA, que es como los exportadores presentan hoy los
|
||||||
|
// totales de una cotizacion simple ("los precios no incluyen IVA" en la nota al pie).
|
||||||
|
export interface TotalesCotizacion {
|
||||||
|
subtotalUnico: number;
|
||||||
|
subtotalMensual: number;
|
||||||
|
ivaUnico: number;
|
||||||
|
ivaMensual: number;
|
||||||
|
totalUnico: number;
|
||||||
|
totalMensual: number;
|
||||||
|
/** Desembolso real a 12 meses: unico + mensual x 12, con IVA si aplica.
|
||||||
|
* Es la base del ratio precio/valor de la propuesta consultiva. */
|
||||||
|
totalPrimerAnio: number;
|
||||||
|
incluyeIva: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcularTotalesCotizacion(
|
||||||
|
servicios: Array<{ tipoPago: string; precio: number; seleccionado?: boolean }>,
|
||||||
|
opciones?: { incluirIva?: boolean }
|
||||||
|
): TotalesCotizacion {
|
||||||
|
const incluyeIva = opciones?.incluirIva !== false;
|
||||||
|
const activos = servicios.filter((s) => s.seleccionado !== false);
|
||||||
|
|
||||||
|
const suma = (tipo: string) =>
|
||||||
|
r2(activos.filter((s) => s.tipoPago === tipo).reduce((a, s) => a + (s.precio || 0), 0));
|
||||||
|
|
||||||
|
const subtotalUnico = suma("unico");
|
||||||
|
const subtotalMensual = suma("mensual");
|
||||||
|
|
||||||
|
const ivaUnico = incluyeIva ? r2(subtotalUnico * IVA_RATE) : 0;
|
||||||
|
const ivaMensual = incluyeIva ? r2(subtotalMensual * IVA_RATE) : 0;
|
||||||
|
|
||||||
|
const totalUnico = r2(subtotalUnico + ivaUnico);
|
||||||
|
const totalMensual = r2(subtotalMensual + ivaMensual);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotalUnico,
|
||||||
|
subtotalMensual,
|
||||||
|
ivaUnico,
|
||||||
|
ivaMensual,
|
||||||
|
totalUnico,
|
||||||
|
totalMensual,
|
||||||
|
totalPrimerAnio: r2(totalUnico + totalMensual * 12),
|
||||||
|
incluyeIva,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function r2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aplica IVA a un monto respetando el flag de la cotizacion.
|
||||||
|
* Sustituye a los `* 1.16` hardcodeados que habia en pdf-generator y excel-builder. */
|
||||||
|
export function conIva(monto: number, incluirIva: boolean = true): number {
|
||||||
|
return r2((monto || 0) * (incluirIva ? 1 + IVA_RATE : 1));
|
||||||
|
}
|
||||||
|
|
||||||
export const FASES: Record<number, string> = {
|
export const FASES: Record<number, string> = {
|
||||||
0: "FASE 0 - Auditoria / Acompanamiento",
|
0: "FASE 0 - Auditoria / Acompanamiento",
|
||||||
1: "FASE 1 - Setup e Infraestructura",
|
1: "FASE 1 - Setup e Infraestructura",
|
||||||
|
|||||||
+29
-11
@@ -1,5 +1,5 @@
|
|||||||
import ExcelJS from "exceljs";
|
import ExcelJS from "exceljs";
|
||||||
import { bucefaloPrecio, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
|
import { bucefaloPrecio, conIva, detalleModelo as detalleModeloCanonico, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador
|
// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador
|
||||||
@@ -44,6 +44,11 @@ export interface ExcelData {
|
|||||||
planBucefaloPrecio?: number | null;
|
planBucefaloPrecio?: number | null;
|
||||||
colorPrimario: string;
|
colorPrimario: string;
|
||||||
colorSecundario: string;
|
colorSecundario: string;
|
||||||
|
incluirIva?: boolean;
|
||||||
|
/** Texto que SI ve el cliente. El Excel es un documento del cliente (lleva razon
|
||||||
|
* social, "En atencion a:", domicilio fiscal y nota legal), no una herramienta
|
||||||
|
* interna: por eso `observacionesInternas` NO se declara aqui. */
|
||||||
|
observaciones?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex):
|
// Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex):
|
||||||
@@ -54,15 +59,10 @@ function argb(hex: string, alpha = "FF"): string {
|
|||||||
return alpha + h.toUpperCase();
|
return alpha + h.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Texto de desglose por modelo de cobro (horas / retainer) para mostrar junto al servicio.
|
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
|
||||||
|
// omitia la rama "demanda", igual que la del PDF.
|
||||||
function detalleModelo(serv: ExcelServicio): string {
|
function detalleModelo(serv: ExcelServicio): string {
|
||||||
if (serv.modeloCobro === "retainer") {
|
return detalleModeloCanonico(serv);
|
||||||
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
|
|
||||||
}
|
|
||||||
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
|
|
||||||
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
|
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
|
||||||
@@ -374,8 +374,10 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
|
|||||||
row++;
|
row++;
|
||||||
};
|
};
|
||||||
compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont);
|
compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont);
|
||||||
compRow("Total unico (c/IVA)", formatCurrency(t1.totalUnico * 1.16), formatCurrency(t2.totalUnico * 1.16), valueFont);
|
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
|
||||||
compRow("Total mensual (c/IVA)", formatCurrency(t1.totalMensual * 1.16), formatCurrency(t2.totalMensual * 1.16), valueFont);
|
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
|
||||||
|
compRow(`Total unico${ivaLbl}`, formatCurrency(conIva(t1.totalUnico, data.incluirIva)), formatCurrency(conIva(t2.totalUnico, data.incluirIva)), valueFont);
|
||||||
|
compRow(`Total mensual${ivaLbl}`, formatCurrency(conIva(t1.totalMensual, data.incluirIva)), formatCurrency(conIva(t2.totalMensual, data.incluirIva)), valueFont);
|
||||||
compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont);
|
compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont);
|
||||||
row++;
|
row++;
|
||||||
} else {
|
} else {
|
||||||
@@ -402,6 +404,22 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
|
|||||||
ws.getCell(`B${row}`).font = smallFont;
|
ws.getCell(`B${row}`).font = smallFont;
|
||||||
setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 });
|
setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 });
|
||||||
|
|
||||||
|
// Observaciones del cliente. Solo este campo: el Excel lleva razon social,
|
||||||
|
// "En atencion a:" y domicilio fiscal, o sea que es un documento que el cliente
|
||||||
|
// recibe. observacionesInternas no existe en ExcelData a proposito.
|
||||||
|
if (data.observaciones && data.observaciones.trim()) {
|
||||||
|
row += 2;
|
||||||
|
ws.mergeCells(`B${row}:K${row}`);
|
||||||
|
ws.getCell(`B${row}`).value = "OBSERVACIONES";
|
||||||
|
ws.getCell(`B${row}`).font = { ...boldFont, color: { argb: PRIMARY } };
|
||||||
|
row++;
|
||||||
|
ws.mergeCells(`B${row}:K${row}`);
|
||||||
|
const texto = data.observaciones.trim();
|
||||||
|
ws.getCell(`B${row}`).value = texto;
|
||||||
|
ws.getCell(`B${row}`).font = smallFont;
|
||||||
|
setWrapped(ws, row, "B", texto, mergedWidth(ws, "B", "K"), { fontSize: 9 });
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// HOJAS DETALLADAS POR SERVICIO
|
// HOJAS DETALLADAS POR SERVICIO
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+64
-19
@@ -1,5 +1,5 @@
|
|||||||
import PDFDocument from "pdfkit";
|
import PDFDocument from "pdfkit";
|
||||||
import { FASES_SHORT as FASES, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
|
import { FASES_SHORT as FASES, conIva, detalleModelo, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
|
||||||
|
|
||||||
interface ServicioPDF {
|
interface ServicioPDF {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
@@ -18,14 +18,11 @@ interface ServicioPDF {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
|
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
|
||||||
|
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
|
||||||
|
// omitia la rama "demanda", asi que esa partida se imprimia sin desglose y con
|
||||||
|
// precio $0, sin explicar que se factura segun consumo.
|
||||||
function detalleModeloPDF(serv: ServicioPDF): string {
|
function detalleModeloPDF(serv: ServicioPDF): string {
|
||||||
if (serv.modeloCobro === "retainer") {
|
return detalleModelo(serv);
|
||||||
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
|
|
||||||
}
|
|
||||||
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
|
|
||||||
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CotizacionPDFData {
|
interface CotizacionPDFData {
|
||||||
@@ -45,6 +42,13 @@ interface CotizacionPDFData {
|
|||||||
planBucefaloNivel: string | null;
|
planBucefaloNivel: string | null;
|
||||||
planBucefaloPrecio: number;
|
planBucefaloPrecio: number;
|
||||||
incluirBonos: boolean;
|
incluirBonos: boolean;
|
||||||
|
/** Bonos desde la tabla Bono. Si no se pasan, se usa la lista de respaldo. */
|
||||||
|
bonos?: { numero: number; descripcion: string }[];
|
||||||
|
incluirIva?: boolean;
|
||||||
|
/** Texto que SI ve el cliente. `observacionesInternas` NO se declara aqui a
|
||||||
|
* proposito: si el generador no puede verlo, no puede filtrarlo. La garantia
|
||||||
|
* es estructural, no depende de la disciplina de quien dibuje. */
|
||||||
|
observaciones?: string | null;
|
||||||
configBancaria?: Record<string, string>;
|
configBancaria?: Record<string, string>;
|
||||||
colorPrimario?: string;
|
colorPrimario?: string;
|
||||||
colorSecundario?: string;
|
colorSecundario?: string;
|
||||||
@@ -324,9 +328,11 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
|
|||||||
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
|
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
|
||||||
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
|
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
|
||||||
y += 18;
|
y += 18;
|
||||||
|
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
|
||||||
|
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
|
||||||
const filas: [string, string, string][] = [
|
const filas: [string, string, string][] = [
|
||||||
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)],
|
[`Total unico${ivaLbl}`, fmt(conIva(t1.totalUnico, data.incluirIva)), fmt(conIva(t2.totalUnico, data.incluirIva))],
|
||||||
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)],
|
[`Total mensual${ivaLbl}`, fmt(conIva(t1.totalMensual, data.incluirIva)), fmt(conIva(t2.totalMensual, data.incluirIva))],
|
||||||
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
|
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
|
||||||
];
|
];
|
||||||
for (const [lab, v1, v2] of filas) {
|
for (const [lab, v1, v2] of filas) {
|
||||||
@@ -374,21 +380,60 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
|
|||||||
doc.rect(L, y, 3, 10).fill(PRIMARY);
|
doc.rect(L, y, 3, 10).fill(PRIMARY);
|
||||||
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
|
||||||
y += 16;
|
y += 16;
|
||||||
const bonos = [
|
// Fuente de verdad: la tabla Bono. La lista de abajo es solo respaldo por si
|
||||||
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
|
// la consulta no trajo nada; antes estaba hardcodeada aqui y su texto ya no
|
||||||
"Bono 2: Workshop Estrategico de Buyer Persona",
|
// coincidia con el del seed (bono 5).
|
||||||
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
|
const RESPALDO = [
|
||||||
"Bono 4: 1 ano de Membresia Premium",
|
"30 min mensuales en servicios Centinela (Sitio Web)",
|
||||||
"Bono 5: Un mes gratis de Bucefalo CRM",
|
"Workshop Estrategico de Buyer Persona",
|
||||||
"Bono 6: Script de Ventas con mas de 100 complementos",
|
"Workshop de Propuestas de Valor y Oferta Irresistible",
|
||||||
];
|
"1 ano de Membresia Premium",
|
||||||
|
"Un mes gratis de Bucefalo CRM, Marketing y Ventas",
|
||||||
|
"Script de Ventas con mas de 100 complementos",
|
||||||
|
].map((d, i) => `Bono ${i + 1}: ${d}`);
|
||||||
|
|
||||||
|
const bonos = data.bonos?.length
|
||||||
|
? data.bonos.map((b) => `Bono ${b.numero}: ${b.descripcion}`)
|
||||||
|
: RESPALDO;
|
||||||
|
|
||||||
for (const b of bonos) {
|
for (const b of bonos) {
|
||||||
y = need(11, y);
|
y = need(11, y);
|
||||||
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2713 ${b}`, L + 8, y, { width: W - 16 });
|
// Vinneta "\u2022" y no la palomita "\u2713": en las fuentes estandar de PDFKit
|
||||||
|
// la palomita mide 0pt de ancho, o sea que hoy salia como dos espacios.
|
||||||
|
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2022 ${b}`, L + 8, y, { width: W - 16 });
|
||||||
y += 11;
|
y += 11;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OBSERVACIONES (solo el texto del cliente) ──
|
||||||
|
// Va al final de la Hoja Resumen, junto a los totales y antes de T&C, que es
|
||||||
|
// donde el cliente espera leer un mensaje del asesor. Nunca imprime
|
||||||
|
// observacionesInternas: ese campo ni siquiera existe en CotizacionPDFData.
|
||||||
|
if (data.observaciones && data.observaciones.trim()) {
|
||||||
|
y = sectionTitle("Observaciones", y);
|
||||||
|
const usable = maxY - m.top;
|
||||||
|
const parrafos = data.observaciones
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const p of parrafos) {
|
||||||
|
const h = txtHeight(p, W - 4, 7.5);
|
||||||
|
if (h > usable) {
|
||||||
|
// Parrafo mas alto que una pagina entera: need() no sabe partir, asi que
|
||||||
|
// se deja fluir a pdfkit y se resincroniza el contador con doc.y.
|
||||||
|
y = need(20, y);
|
||||||
|
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
|
||||||
|
y = doc.y + 4;
|
||||||
|
} else {
|
||||||
|
y = need(h + 4, y);
|
||||||
|
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
|
||||||
|
y += h + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y += 4;
|
||||||
|
}
|
||||||
|
|
||||||
// ── T&C PAGE ──────────────────────────────────
|
// ── T&C PAGE ──────────────────────────────────
|
||||||
doc.addPage();
|
doc.addPage();
|
||||||
y = m.top;
|
y = m.top;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aJsonSchema } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente del proveedor de IA (MiniMax-M3 via su endpoint compatible con Anthropic)
|
||||||
|
* y runner que fuerza el contrato con tool-calling.
|
||||||
|
*
|
||||||
|
* MiniMax NO soporta structured outputs (`output_config` / `json_schema`), asi que
|
||||||
|
* el `input_schema` de la herramienta ES el contrato, y Zod valida siempre del lado
|
||||||
|
* del codigo. `tool_choice` no se envia: no esta documentado en MiniMax, y fijarlo
|
||||||
|
* solo en el reintento le daria al reintento un prefijo distinto — se pagaria el
|
||||||
|
* contexto completo justo cuando es mas grande.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UsoTokens {
|
||||||
|
entrada: number;
|
||||||
|
salida: number;
|
||||||
|
cacheLectura: number;
|
||||||
|
cacheEscritura: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza rarezas observadas en las respuestas de MiniMax antes de validar.
|
||||||
|
*
|
||||||
|
* Observado contra la API real, sobre datos de produccion: a veces envuelve los
|
||||||
|
* elementos de un array en un objeto `{item: {...}}` en vez de emitir el objeto
|
||||||
|
* directamente. Zod lo rechaza con `Unrecognized keys: "item"` y se gasta un
|
||||||
|
* reintento en algo que se puede corregir aqui sin ambiguedad.
|
||||||
|
*
|
||||||
|
* Solo desenvuelve cuando `item` es la UNICA clave: si el objeto trae mas cosas,
|
||||||
|
* podria ser un campo legitimo y no se toca.
|
||||||
|
*/
|
||||||
|
export function normalizarRespuesta(v: unknown): unknown {
|
||||||
|
if (Array.isArray(v)) return v.map(normalizarRespuesta);
|
||||||
|
if (v && typeof v === "object") {
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
const claves = Object.keys(o);
|
||||||
|
if (claves.length === 1 && claves[0] === "item") return normalizarRespuesta(o.item);
|
||||||
|
const salida: Record<string, unknown> = {};
|
||||||
|
for (const k of claves) salida[k] = normalizarRespuesta(o[k]);
|
||||||
|
return salida;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crearCliente(): Anthropic {
|
||||||
|
const apiKey = process.env.MINIMAX_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Falta MINIMAX_API_KEY. Definela en .env. No uses ANTHROPIC_API_KEY: el SDK la " +
|
||||||
|
"leeria por su cuenta y mandaria la clave de MiniMax a api.anthropic.com."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// apiKey y baseURL explicitos. Si se dejan al SDK, toma las ANTHROPIC_* del shell
|
||||||
|
// y la clave termina en el proveedor equivocado.
|
||||||
|
return new Anthropic({
|
||||||
|
apiKey,
|
||||||
|
baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/anthropic",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelo(): string {
|
||||||
|
return process.env.MINIMAX_MODEL || "MiniMax-M3";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BloqueSystem {
|
||||||
|
texto: string;
|
||||||
|
cachear: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿El fallo es del proveedor y vale la pena reintentar?
|
||||||
|
*
|
||||||
|
* Motivacion: en produccion aparecio un "unexpected doc type" que NO se pudo reproducir
|
||||||
|
* en seis corridas completas del pipeline contra los mismos datos, y la generacion que
|
||||||
|
* lo siguio completo sin problema. Todo apunta a un fallo transitorio del proveedor.
|
||||||
|
* En vez de adivinar un arreglo para un error que no se puede reproducir, se reintenta
|
||||||
|
* esa clase de fallo con espera creciente.
|
||||||
|
*/
|
||||||
|
function esTransitorio(e: unknown): boolean {
|
||||||
|
const err = e as { status?: number; message?: string };
|
||||||
|
if (typeof err?.status === "number") {
|
||||||
|
// 408 timeout, 409 conflicto, 429 rate limit, 5xx y el 529 de sobrecarga.
|
||||||
|
if ([408, 409, 429].includes(err.status) || err.status >= 500) return true;
|
||||||
|
// 400 con mensaje que no describe un problema de nuestro payload: el proveedor
|
||||||
|
// devuelve errores de parseo internos con 400. Se reintenta una vez por si acaso.
|
||||||
|
if (err.status === 400 && /unexpected|internal|parse|unknown/i.test(err.message ?? "")) return true;
|
||||||
|
}
|
||||||
|
// Fallos de red sin status.
|
||||||
|
if (!err?.status && /ECONN|ETIMEDOUT|socket|network|fetch failed/i.test(err?.message ?? "")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Llama al proveedor reintentando SOLO los fallos transitorios. Los errores de
|
||||||
|
* nuestro payload no se reintentan aqui: los corrige el bucle de schema. */
|
||||||
|
async function crearMensajeConReintentos(
|
||||||
|
cliente: Anthropic,
|
||||||
|
cuerpo: Anthropic.MessageCreateParamsNonStreaming,
|
||||||
|
maxTransitorios = 3
|
||||||
|
): Promise<Anthropic.Message> {
|
||||||
|
let ultimo: unknown;
|
||||||
|
for (let i = 1; i <= maxTransitorios; i++) {
|
||||||
|
try {
|
||||||
|
return await cliente.messages.create(cuerpo);
|
||||||
|
} catch (e) {
|
||||||
|
ultimo = e;
|
||||||
|
if (!esTransitorio(e) || i === maxTransitorios) throw e;
|
||||||
|
await esperar(1000 * 2 ** (i - 1)); // 1s, 2s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ultimo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpcionesLlamada {
|
||||||
|
system: BloqueSystem[];
|
||||||
|
mensajeUsuario: string;
|
||||||
|
herramienta: { nombre: string; descripcion: string; schema: z.ZodType };
|
||||||
|
maxIntentos: number;
|
||||||
|
maxTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function llamarConHerramienta<T>(
|
||||||
|
opts: OpcionesLlamada
|
||||||
|
): Promise<{ datos: T; uso: UsoTokens; intentos: number }> {
|
||||||
|
const cliente = crearCliente();
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: opts.herramienta.nombre,
|
||||||
|
description: opts.herramienta.descripcion,
|
||||||
|
input_schema: aJsonSchema(opts.herramienta.schema) as Anthropic.Tool["input_schema"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const system = opts.system.map((b) => ({
|
||||||
|
type: "text" as const,
|
||||||
|
text: b.texto,
|
||||||
|
...(b.cachear ? { cache_control: { type: "ephemeral" as const } } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: opts.mensajeUsuario }];
|
||||||
|
const uso: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
|
||||||
|
let ultimoError = "";
|
||||||
|
|
||||||
|
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
|
||||||
|
const res = await crearMensajeConReintentos(cliente, {
|
||||||
|
model: modelo(),
|
||||||
|
max_tokens: opts.maxTokens,
|
||||||
|
system,
|
||||||
|
tools,
|
||||||
|
messages: mensajes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const u = res.usage as {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
cache_read_input_tokens?: number;
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
};
|
||||||
|
uso.entrada += u.input_tokens ?? 0;
|
||||||
|
uso.salida += u.output_tokens ?? 0;
|
||||||
|
uso.cacheLectura += u.cache_read_input_tokens ?? 0;
|
||||||
|
uso.cacheEscritura += u.cache_creation_input_tokens ?? 0;
|
||||||
|
|
||||||
|
const bloquesTool = res.content.filter(
|
||||||
|
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
|
||||||
|
);
|
||||||
|
// El modelo a veces emite DOS tool_use en una misma respuesta (observado contra la
|
||||||
|
// API real). Se prueban todos los candidatos y gana el primero que valide, en vez
|
||||||
|
// de quedarse con el primero a secas y desperdiciar un reintento.
|
||||||
|
const candidatos = bloquesTool.filter((b) => b.name === opts.herramienta.nombre);
|
||||||
|
|
||||||
|
let correcto: Anthropic.ToolUseBlock | undefined;
|
||||||
|
for (const c of candidatos) {
|
||||||
|
const intentoParse = opts.herramienta.schema.safeParse(normalizarRespuesta(c.input));
|
||||||
|
if (intentoParse.success) {
|
||||||
|
return { datos: intentoParse.data as T, uso, intentos: intento };
|
||||||
|
}
|
||||||
|
// Se guarda el primero para reportar su error si ninguno valida.
|
||||||
|
if (!correcto) {
|
||||||
|
correcto = c;
|
||||||
|
ultimoError = z.prettifyError(intentoParse.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correcto) {
|
||||||
|
// Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar.
|
||||||
|
// Un turno de usuario plano despues de un tool_use devuelve 400.
|
||||||
|
mensajes.push(
|
||||||
|
{ role: "assistant", content: res.content },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
...bloquesTool.map((b) => ({
|
||||||
|
type: "tool_result" as const,
|
||||||
|
tool_use_id: b.id,
|
||||||
|
is_error: true,
|
||||||
|
content:
|
||||||
|
b.id === correcto.id
|
||||||
|
? ultimoError
|
||||||
|
: `Herramienta inesperada. Usa ${opts.herramienta.nombre}.`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text:
|
||||||
|
`La llamada no cumple el schema. Corrige EXACTAMENTE estos errores y vuelve a ` +
|
||||||
|
`llamar a ${opts.herramienta.nombre}:\n\n${ultimoError}\n\n` +
|
||||||
|
`Revisa que cada objeto este en el array que le corresponde y que no falte ` +
|
||||||
|
`ningun campo obligatorio. No agregues campos que el schema no declara.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No llamo a ninguna herramienta: aqui si va un turno de usuario plano.
|
||||||
|
ultimoError = "El modelo respondio con prosa en vez de llamar a la herramienta.";
|
||||||
|
mensajes.push(
|
||||||
|
{ role: "assistant", content: res.content },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Debes responder llamando a la herramienta ${opts.herramienta.nombre}. No escribas prosa suelta.`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`El paso "${opts.herramienta.nombre}" agoto ${opts.maxIntentos} intentos.\nUltimo error:\n${ultimoError}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { calcularTotalesCotizacion, type TotalesCotizacion } from "@/lib/calculators";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capa de datos economicos de la propuesta consultiva.
|
||||||
|
*
|
||||||
|
* Existe para que el dinero viva en UN solo sitio y nunca cruce hacia el prompt.
|
||||||
|
* El pipeline de IA recibe de aqui unicamente `refPartida` y `nombre`; los importes
|
||||||
|
* los inyecta el renderizador. Asi el principio "la IA no toca los numeros" lo
|
||||||
|
* garantiza el compilador y no una regla que alguien pueda olvidar validar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PartidaCanonica {
|
||||||
|
/** P01, P02... Es lo unico que identifica una partida ante el modelo. */
|
||||||
|
refPartida: string;
|
||||||
|
/** cuid real de ServicioCotizado. NUNCA sale hacia el proveedor: no es estable
|
||||||
|
* entre ediciones, porque el PUT hace deleteMany + createMany. */
|
||||||
|
servicioCotizadoId: string;
|
||||||
|
nombre: string;
|
||||||
|
fase: number;
|
||||||
|
tipoPago: string;
|
||||||
|
precio: number;
|
||||||
|
tiempoEntrega: string;
|
||||||
|
modeloCobro: string;
|
||||||
|
horas: number | null;
|
||||||
|
tarifaHora: number | null;
|
||||||
|
entregables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatosEconomicos {
|
||||||
|
partidas: PartidaCanonica[];
|
||||||
|
totales: TotalesCotizacion;
|
||||||
|
moneda: string;
|
||||||
|
/** Plan Bucefalo contratado, si lo hay. Se cobra mensual y aparece en el PDF
|
||||||
|
* economico y en el Excel; tiene que aparecer tambien en el consultivo o el
|
||||||
|
* cliente recibe dos documentos con alcances distintos. */
|
||||||
|
planBucefalo: { nivel: string; precio: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> {
|
||||||
|
const cot = await prisma.cotizacion.findUnique({
|
||||||
|
where: { id: cotizacionId },
|
||||||
|
include: {
|
||||||
|
servicios: {
|
||||||
|
include: { servicioCatalogo: true },
|
||||||
|
// Mismo orden que los exportadores: los tres documentos van en el mismo
|
||||||
|
// correo y deben listar las partidas igual.
|
||||||
|
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
planBucefalo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`);
|
||||||
|
|
||||||
|
const activos = cot.servicios.filter((s) => s.seleccionado);
|
||||||
|
const partidas: PartidaCanonica[] = activos.map((s, i) => ({
|
||||||
|
refPartida: `P${String(i + 1).padStart(2, "0")}`,
|
||||||
|
servicioCotizadoId: s.id,
|
||||||
|
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
|
||||||
|
fase: s.fase,
|
||||||
|
tipoPago: s.tipoPago,
|
||||||
|
precio: s.precio,
|
||||||
|
tiempoEntrega: s.tiempoEntrega,
|
||||||
|
modeloCobro: s.modeloCobro,
|
||||||
|
horas: s.horas,
|
||||||
|
tarifaHora: s.tarifaHora,
|
||||||
|
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const plan =
|
||||||
|
cot.planBucefalo && cot.planBucefalo.seleccionado
|
||||||
|
? { nivel: cot.planBucefalo.nivel, precio: cot.planBucefalo.precio }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// El plan Bucefalo se cobra mensual: entra a los totales como una partida mensual
|
||||||
|
// mas, igual que en el PDF economico.
|
||||||
|
const paraTotales = plan
|
||||||
|
? [...activos, { tipoPago: "mensual", precio: plan.precio, seleccionado: true }]
|
||||||
|
: activos;
|
||||||
|
|
||||||
|
return {
|
||||||
|
partidas,
|
||||||
|
totales: calcularTotalesCotizacion(paraTotales, { incluirIva: cot.incluirIva }),
|
||||||
|
moneda: cot.moneda,
|
||||||
|
planBucefalo: plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LecturaRatio = "subcotizado" | "en_rango" | "alto" | "objecion_probable";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ratio precio/valor para el anexo interno.
|
||||||
|
*
|
||||||
|
* Base: el desembolso real del primer ano CON IVA (unico + mensual x 12). Se eligio
|
||||||
|
* asi para que el numerador y el denominador tengan las mismas unidades: comparar un
|
||||||
|
* pago inicial contra un valor ANUAL del problema daria una lectura optimista falsa.
|
||||||
|
*/
|
||||||
|
export function calcularRatio(
|
||||||
|
totalPrimerAnio: number,
|
||||||
|
valorAnual: number | null
|
||||||
|
): { ratio: number; lectura: LecturaRatio } | null {
|
||||||
|
if (!valorAnual || valorAnual <= 0) return null;
|
||||||
|
const ratio = totalPrimerAnio / valorAnual;
|
||||||
|
const lectura: LecturaRatio =
|
||||||
|
ratio < 0.1
|
||||||
|
? "subcotizado"
|
||||||
|
: ratio <= 0.25
|
||||||
|
? "en_rango"
|
||||||
|
: ratio <= 0.4
|
||||||
|
? "alto"
|
||||||
|
: "objecion_probable";
|
||||||
|
return { ratio: Math.round(ratio * 1000) / 1000, lectura };
|
||||||
|
}
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import PDFDocument from "pdfkit";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/calculators";
|
||||||
|
import { calcularRatio, type DatosEconomicos } from "./economia";
|
||||||
|
import type { PropuestaConsultiva } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generador del documento consultivo.
|
||||||
|
*
|
||||||
|
* Sigue la arquitectura argumental de la plantilla de referencia (hero, diagnostico con
|
||||||
|
* semaforo, valor del problema, resultados, alcance, beneficios, exclusiones, pendientes,
|
||||||
|
* backlog) pero en tema claro y con PDFKit, como el resto del sistema: la plantilla
|
||||||
|
* original es de tema oscuro, y eso en impresion depende de una casilla del navegador
|
||||||
|
* que viene desactivada — el cliente recibiria texto crema sobre papel blanco.
|
||||||
|
*
|
||||||
|
* El anexo interno va en SU PROPIO archivo, nunca como seccion oculta del documento del
|
||||||
|
* cliente. Un display:none o una pagina extra se envian por error; un archivo con otro
|
||||||
|
* nombre, no.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DatosPropuestaPDF {
|
||||||
|
propuesta: PropuestaConsultiva;
|
||||||
|
economia: DatosEconomicos;
|
||||||
|
cliente: string;
|
||||||
|
empresa: string;
|
||||||
|
asesor: string;
|
||||||
|
numero: string;
|
||||||
|
fecha: Date;
|
||||||
|
vigencia: Date;
|
||||||
|
proyecto: string;
|
||||||
|
branding: {
|
||||||
|
colorPrimario?: string;
|
||||||
|
colorSecundario?: string;
|
||||||
|
logoBase64?: string;
|
||||||
|
logoMime?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_SEMAFORO: Record<string, string> = {
|
||||||
|
rojo: "#dc2626",
|
||||||
|
ambar: "#d97706",
|
||||||
|
azul: "#2563eb",
|
||||||
|
verde: "#16a34a",
|
||||||
|
};
|
||||||
|
const ETIQUETA_SEMAFORO: Record<string, string> = {
|
||||||
|
rojo: "Problema critico",
|
||||||
|
ambar: "Area de mejora",
|
||||||
|
azul: "Oportunidad",
|
||||||
|
verde: "Ventaja existente",
|
||||||
|
};
|
||||||
|
const ETIQUETA_CONFIANZA: Record<string, string> = {
|
||||||
|
confirmado: "Confirmado",
|
||||||
|
estimado: "Estimado",
|
||||||
|
por_validar: "Por validar",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `PUT /api/configuracion` filtra las claves permitidas pero NO valida los valores,
|
||||||
|
* asi que aqui no se confia en ellos. */
|
||||||
|
function hexSeguro(v: string | undefined, porDefecto: string): string {
|
||||||
|
return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Respaldo cuando la IA no describio una partida: se arma con los datos del
|
||||||
|
* catalogo, para que la partida nunca desaparezca del documento. */
|
||||||
|
function detalleDePartida(p: { tiempoEntrega: string; entregables: string[]; tipoPago: string }): string {
|
||||||
|
const trozos: string[] = [];
|
||||||
|
if (p.entregables.length) trozos.push(p.entregables.slice(0, 4).join(" · "));
|
||||||
|
if (p.tiempoEntrega) trozos.push(`Entrega: ${p.tiempoEntrega}`);
|
||||||
|
if (p.tipoPago === "mensual") trozos.push("Servicio mensual");
|
||||||
|
return trozos.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Lienzo {
|
||||||
|
doc: PDFKit.PDFDocument;
|
||||||
|
W: number;
|
||||||
|
L: number;
|
||||||
|
ph: number;
|
||||||
|
maxY: number;
|
||||||
|
mTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crearLienzo(): { lienzo: Lienzo; listo: Promise<Buffer> } {
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: "LETTER",
|
||||||
|
margins: { top: 50, bottom: 55, left: 50, right: 50 },
|
||||||
|
bufferPages: true,
|
||||||
|
});
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||||
|
const listo = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
|
||||||
|
const m = doc.page.margins;
|
||||||
|
return {
|
||||||
|
lienzo: {
|
||||||
|
doc,
|
||||||
|
W: doc.page.width - m.left - m.right,
|
||||||
|
L: m.left,
|
||||||
|
ph: doc.page.height,
|
||||||
|
maxY: doc.page.height - m.bottom - 5,
|
||||||
|
mTop: m.top,
|
||||||
|
},
|
||||||
|
listo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer> {
|
||||||
|
const PRIMARY = hexSeguro(d.branding.colorPrimario, "#2563eb");
|
||||||
|
const DARK = hexSeguro(d.branding.colorSecundario, "#1e293b");
|
||||||
|
const MUTED = "#64748b";
|
||||||
|
const BORDER = "#e2e8f0";
|
||||||
|
|
||||||
|
const { lienzo, listo } = crearLienzo();
|
||||||
|
const { doc, W, L, ph, maxY, mTop } = lienzo;
|
||||||
|
let y = mTop;
|
||||||
|
|
||||||
|
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
|
||||||
|
const txtH = (s: string, w: number, size: number) =>
|
||||||
|
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
|
||||||
|
|
||||||
|
function titulo(t: string) {
|
||||||
|
y = need(26, y);
|
||||||
|
doc.rect(L, y, 3, 10).fill(PRIMARY);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(t.toUpperCase(), L + 10, y);
|
||||||
|
y += 20;
|
||||||
|
}
|
||||||
|
function parrafo(t: string, size = 9, color = DARK) {
|
||||||
|
for (const p of t.split(/\r?\n/).map((x) => x.trim()).filter(Boolean)) {
|
||||||
|
const h = txtH(p, W, size);
|
||||||
|
y = need(h + 4, y);
|
||||||
|
doc.font("Helvetica").fontSize(size).fillColor(color).text(p, L, y, { width: W });
|
||||||
|
y += h + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HERO ──
|
||||||
|
if (d.branding.logoBase64) {
|
||||||
|
try {
|
||||||
|
doc.image(Buffer.from(d.branding.logoBase64, "base64"), L, y, { height: 26 });
|
||||||
|
y += 34;
|
||||||
|
} catch {
|
||||||
|
/* logo invalido: se omite, no se rompe el documento */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc.font("Helvetica-Bold").fontSize(8.5).fillColor(PRIMARY).text("PROPUESTA CONSULTIVA", L, y);
|
||||||
|
y += 16;
|
||||||
|
const hTit = txtH(d.propuesta.redaccion.hero.titulo, W, 20);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(20).fillColor(DARK).text(d.propuesta.redaccion.hero.titulo, L, y, { width: W });
|
||||||
|
y += hTit + 8;
|
||||||
|
parrafo(d.propuesta.redaccion.hero.subtitulo, 10, MUTED);
|
||||||
|
y += 8;
|
||||||
|
const meta = `${d.empresa || d.cliente} · ${d.numero} · ${formatDate(d.fecha)} · Vigencia ${formatDate(d.vigencia)} · ${d.asesor}`;
|
||||||
|
const hMeta = txtH(meta, W, 8);
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(meta, L, y, { width: W });
|
||||||
|
y += hMeta + 12;
|
||||||
|
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
|
y += 18;
|
||||||
|
|
||||||
|
// ── DIAGNOSTICO ──
|
||||||
|
titulo("Diagnostico");
|
||||||
|
const cita = d.propuesta.hechos.citas.find((c) => c.id === d.propuesta.redaccion.citaDestacadaId);
|
||||||
|
if (cita) {
|
||||||
|
const texto = `"${cita.textoLiteral}"`;
|
||||||
|
const h = doc.font("Helvetica-Oblique").fontSize(11).heightOfString(texto, { width: W - 22 });
|
||||||
|
y = need(h + 22, y);
|
||||||
|
doc.rect(L, y - 3, 2.5, h + 10).fill(PRIMARY);
|
||||||
|
doc.font("Helvetica-Oblique").fontSize(11).fillColor(DARK).text(texto, L + 12, y, { width: W - 22 });
|
||||||
|
y += h + 4;
|
||||||
|
doc.font("Helvetica").fontSize(7.5).fillColor(MUTED).text(`— ${cita.quienLoDijo}`, L + 12, y);
|
||||||
|
y += 18;
|
||||||
|
}
|
||||||
|
for (const hal of d.propuesta.diagnostico.hallazgos) {
|
||||||
|
const cuerpo = `${hal.titulo} — ${hal.descripcion}`;
|
||||||
|
const h = txtH(cuerpo, W - 24, 9);
|
||||||
|
y = need(h + 14, y);
|
||||||
|
doc.circle(L + 4.5, y + 4.5, 3.2).fill(COLOR_SEMAFORO[hal.urgencia] || MUTED);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(cuerpo, L + 16, y, { width: W - 24 });
|
||||||
|
y += h + 1;
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(`${ETIQUETA_SEMAFORO[hal.urgencia] ?? ""} · ${ETIQUETA_CONFIANZA[hal.confianza] ?? ""}`, L + 16, y);
|
||||||
|
y += 13;
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
// ── VALOR DEL PROBLEMA ──
|
||||||
|
const dims = d.propuesta.diagnostico.valorProblema.dimensiones;
|
||||||
|
if (dims.length) {
|
||||||
|
titulo("Lo que cuesta no resolverlo");
|
||||||
|
for (const dim of dims) {
|
||||||
|
const monto = dim.montoAnualMXN;
|
||||||
|
const linea = `${dim.descripcion} — ${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`;
|
||||||
|
const h = txtH(linea, W - 14, 9);
|
||||||
|
y = need(h + 14, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(linea, L + 6, y, { width: W - 14 });
|
||||||
|
y += h + 1;
|
||||||
|
doc.font("Helvetica").fontSize(7).fillColor(MUTED).text(ETIQUETA_CONFIANZA[dim.confianza] ?? "", L + 6, y);
|
||||||
|
y += 13;
|
||||||
|
}
|
||||||
|
if (d.propuesta.diagnostico.valorProblema.notaMetodologia.trim()) {
|
||||||
|
y += 2;
|
||||||
|
parrafo(d.propuesta.diagnostico.valorProblema.notaMetodologia, 8, MUTED);
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RESULTADOS ──
|
||||||
|
if (d.propuesta.diagnostico.resultados.length) {
|
||||||
|
titulo("Resultados a lograr");
|
||||||
|
for (const r of d.propuesta.diagnostico.resultados) {
|
||||||
|
const t = `• ${r.enunciado} (${r.metrica}, ${r.periodoMedicion})`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ALCANCE ──
|
||||||
|
// Se recorren las partidas de la COTIZACION, no las que la IA alcanzo a describir.
|
||||||
|
// Motivo: el schema acota `alcance` y una cotizacion con mas partidas que ese tope
|
||||||
|
// dejaria servicios cotizados fuera del documento del cliente, en silencio.
|
||||||
|
// Verificado con UJ2606UR001: 58 partidas, la IA describio 40, faltaban 18.
|
||||||
|
// La completitud la manda la base de datos; la IA solo aporta la prosa.
|
||||||
|
titulo("Alcance de la inversion");
|
||||||
|
const descPorRef = new Map(d.propuesta.redaccion.alcance.map((a) => [a.refPartida, a.descripcionResultado]));
|
||||||
|
for (const p of d.economia.partidas) {
|
||||||
|
const descripcion = descPorRef.get(p.refPartida);
|
||||||
|
const hNombre = txtH(p.nombre, W * 0.68, 9.5);
|
||||||
|
const hDesc = descripcion ? txtH(descripcion, W * 0.68, 8) : 0;
|
||||||
|
y = need(hNombre + hDesc + 14, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.fillColor(PRIMARY)
|
||||||
|
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
|
||||||
|
y += hNombre + 2;
|
||||||
|
if (descripcion) {
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(descripcion, L, y, { width: W * 0.68 });
|
||||||
|
y += hDesc + 10;
|
||||||
|
} else {
|
||||||
|
// Sin prosa de la IA: la partida SI aparece, con su detalle del catalogo.
|
||||||
|
const respaldo = detalleDePartida(p);
|
||||||
|
if (respaldo) {
|
||||||
|
const hR = txtH(respaldo, W * 0.68, 8);
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(respaldo, L, y, { width: W * 0.68 });
|
||||||
|
y += hR + 10;
|
||||||
|
} else {
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El plan Bucefalo es una partida mas del acuerdo: si no se imprime aqui, el
|
||||||
|
// cliente recibe un documento consultivo con menos alcance que su cotizacion.
|
||||||
|
if (d.economia.planBucefalo) {
|
||||||
|
const nivel = d.economia.planBucefalo.nivel;
|
||||||
|
const etiqueta = `CRM Bucefalo — plan ${nivel.charAt(0).toUpperCase() + nivel.slice(1)}`;
|
||||||
|
const hE = txtH(etiqueta, W * 0.68, 9.5);
|
||||||
|
y = need(hE + 22, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(etiqueta, L, y, { width: W * 0.68 });
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.fillColor(PRIMARY)
|
||||||
|
.text(`${formatCurrency(d.economia.planBucefalo.precio)} / mes`, L + W * 0.7, y, { width: W * 0.3, align: "right" });
|
||||||
|
y += hE + 2;
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text("Servicio mensual", L, y, { width: W * 0.68 });
|
||||||
|
y += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
y = need(58, y);
|
||||||
|
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
|
y += 10;
|
||||||
|
// ── CAJA DE TOTALES ──
|
||||||
|
// Desglose completo (subtotal / IVA / total), como la plantilla autorizada.
|
||||||
|
//
|
||||||
|
// Antes se imprimia cada partida a su precio SIN IVA y debajo un unico total CON
|
||||||
|
// IVA, rematado con la leyenda "Importes con IVA incluido": las lineas no sumaban
|
||||||
|
// el total, la leyenda contradecia a sus propias lineas, y el PDF economico que va
|
||||||
|
// en el MISMO correo dice "los precios no incluyen IVA" sobre las mismas cifras.
|
||||||
|
// Ahora las lineas siguen siendo sin IVA —igual que el otro documento— y el IVA
|
||||||
|
// aparece como renglon propio.
|
||||||
|
const t = d.economia.totales;
|
||||||
|
const fila = (etiqueta: string, valor: string, fuerte = false) => {
|
||||||
|
y = need(fuerte ? 18 : 15, y);
|
||||||
|
doc
|
||||||
|
.font(fuerte ? "Helvetica-Bold" : "Helvetica")
|
||||||
|
.fontSize(fuerte ? 10.5 : 9)
|
||||||
|
.fillColor(fuerte ? DARK : MUTED)
|
||||||
|
.text(etiqueta, L, y);
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(fuerte ? 10.5 : 9)
|
||||||
|
.fillColor(fuerte ? PRIMARY : DARK)
|
||||||
|
.text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
|
||||||
|
y += fuerte ? 18 : 15;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (t.subtotalUnico > 0) {
|
||||||
|
fila("Subtotal pago unico", formatCurrency(t.subtotalUnico));
|
||||||
|
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaUnico));
|
||||||
|
fila(t.incluyeIva ? "Total pago unico" : "Total pago unico (sin IVA)", formatCurrency(t.totalUnico), true);
|
||||||
|
}
|
||||||
|
if (t.subtotalMensual > 0) {
|
||||||
|
if (t.subtotalUnico > 0) y += 4;
|
||||||
|
fila("Subtotal mensual", formatCurrency(t.subtotalMensual));
|
||||||
|
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaMensual));
|
||||||
|
fila(t.incluyeIva ? "Total mensual" : "Total mensual (sin IVA)", formatCurrency(t.totalMensual), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
y = need(16, y);
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7.5)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(
|
||||||
|
t.incluyeIva
|
||||||
|
? "Los precios por partida son en Moneda Nacional (MXN) y no incluyen IVA; el IVA se desglosa arriba. Facturacion CFDI."
|
||||||
|
: "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.",
|
||||||
|
L,
|
||||||
|
y,
|
||||||
|
{ width: W }
|
||||||
|
);
|
||||||
|
y += 24;
|
||||||
|
|
||||||
|
// ── BENEFICIOS ──
|
||||||
|
if (d.propuesta.redaccion.beneficios.length) {
|
||||||
|
titulo("Por que tiene sentido");
|
||||||
|
for (const b of d.propuesta.redaccion.beneficios) {
|
||||||
|
const hEt = txtH(b.etiqueta, W * 0.24, 9);
|
||||||
|
const hTx = txtH(b.texto, W * 0.72, 8.5);
|
||||||
|
const h = Math.max(hEt, hTx);
|
||||||
|
y = need(h + 9, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(b.etiqueta, L, y, { width: W * 0.24 });
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(b.texto, L + W * 0.27, y, { width: W * 0.72 });
|
||||||
|
y += h + 9;
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EXCLUSIONES ──
|
||||||
|
titulo("Que no incluye esta propuesta");
|
||||||
|
for (const e of d.propuesta.redaccion.exclusiones) {
|
||||||
|
const t2 = `✕ ${e.texto}`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
|
||||||
|
// ── PENDIENTES: el canal de honestidad del documento ──
|
||||||
|
const mats = d.propuesta.hechos.materialesPendientes;
|
||||||
|
const decs = d.propuesta.hechos.decisionesPendientes;
|
||||||
|
if (mats.length || decs.length) {
|
||||||
|
titulo("Que necesitamos de ustedes");
|
||||||
|
for (const mm of mats) {
|
||||||
|
const t2 = `○ ${mm.texto}${mm.bloqueaEntrega ? " (el avance queda condicionado a esto)" : ""}`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
for (const dd of decs) {
|
||||||
|
const t2 = `· ${dd.texto} (decide: ${dd.quienDecide})`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BACKLOG ──
|
||||||
|
if (d.propuesta.redaccion.backlogEvolucion.length) {
|
||||||
|
titulo("Registrado para mas adelante");
|
||||||
|
y = need(14, y);
|
||||||
|
doc.font("Helvetica-Oblique").fontSize(7.5).fillColor(MUTED).text("No comprometido en esta propuesta.", L, y);
|
||||||
|
y += 14;
|
||||||
|
for (const b of d.propuesta.redaccion.backlogEvolucion) {
|
||||||
|
const t2 = `· ${b.problema} (${b.momentoSugerido})`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FOOTERS ──
|
||||||
|
// margins.bottom = 0 temporal: sin eso, escribir en el margen inferior dispara el
|
||||||
|
// auto-page-break de pdfkit y se generan paginas vacias.
|
||||||
|
const savedBottom = doc.page.margins.bottom;
|
||||||
|
const rango = doc.bufferedPageRange();
|
||||||
|
for (let i = rango.start; i < rango.start + rango.count; i++) {
|
||||||
|
doc.switchToPage(i);
|
||||||
|
doc.page.margins.bottom = 0;
|
||||||
|
doc.moveTo(L, ph - 42).lineTo(L + W, ph - 42).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(`${d.numero} · Propuesta consultiva · ${d.empresa || d.cliente}`, L, ph - 35, { lineBreak: false });
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(`${i - rango.start + 1} / ${rango.count}`, L, ph - 35, { width: W, align: "right", lineBreak: false });
|
||||||
|
doc.page.margins.bottom = savedBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return listo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anexo interno. Archivo SEPARADO y visualmente inconfundible: el riesgo de que una
|
||||||
|
* seccion oculta llegue al cliente es demasiado alto para un display:none. */
|
||||||
|
export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buffer> {
|
||||||
|
const { lienzo, listo } = crearLienzo();
|
||||||
|
const { doc, W, L, maxY, mTop } = lienzo;
|
||||||
|
let y = mTop;
|
||||||
|
const DARK = "#1e293b";
|
||||||
|
const MUTED = "#64748b";
|
||||||
|
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
|
||||||
|
const txtH = (s: string, w: number, size: number) =>
|
||||||
|
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
|
||||||
|
|
||||||
|
doc.rect(0, 0, doc.page.width, 36).fill("#b91c1c");
|
||||||
|
doc.font("Helvetica-Bold").fontSize(13).fillColor("#ffffff").text("USO INTERNO — NO ENVIAR AL CLIENTE", L, 11);
|
||||||
|
y = 56;
|
||||||
|
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(`${d.numero} · ${d.empresa || d.cliente} · ${d.asesor}`, L, y);
|
||||||
|
y += 24;
|
||||||
|
|
||||||
|
const valorAnual =
|
||||||
|
d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.montoAnualMXN ?? 0), 0) || null;
|
||||||
|
const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual);
|
||||||
|
|
||||||
|
const seccion = (t: string) => {
|
||||||
|
y = need(24, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(11).fillColor(DARK).text(t, L, y);
|
||||||
|
y += 18;
|
||||||
|
};
|
||||||
|
const linea = (k: string, v: string) => {
|
||||||
|
y = need(15, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(k, L, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(v, L + W * 0.5, y, { width: W * 0.5, align: "right" });
|
||||||
|
y += 15;
|
||||||
|
};
|
||||||
|
|
||||||
|
seccion("Ponderacion de la cotizacion");
|
||||||
|
linea("Inversion del primer ano", formatCurrency(d.economia.totales.totalPrimerAnio));
|
||||||
|
linea("Valor anual del problema", valorAnual ? formatCurrency(valorAnual) : "sin cuantificar");
|
||||||
|
linea("Ratio precio / valor", r ? `${(r.ratio * 100).toFixed(1)}% — ${r.lectura.replace(/_/g, " ")}` : "no calculable");
|
||||||
|
linea("Hallazgos en el diagnostico", String(d.propuesta.diagnostico.hallazgos.length));
|
||||||
|
linea("Cifras confirmadas", String(d.propuesta.diagnostico.hallazgos.filter((h) => h.confianza === "confirmado").length));
|
||||||
|
linea("Exclusiones definidas", String(d.propuesta.redaccion.exclusiones.length));
|
||||||
|
linea("Materiales pendientes", String(d.propuesta.hechos.materialesPendientes.length));
|
||||||
|
linea("Decisiones pendientes", String(d.propuesta.hechos.decisionesPendientes.length));
|
||||||
|
linea("Red flags detectadas", String(d.propuesta.hechos.redFlags.length));
|
||||||
|
y += 6;
|
||||||
|
|
||||||
|
// Desglose del valor anual. Va aqui porque es el denominador del ratio: si esa cifra
|
||||||
|
// esta inflada, el ratio miente y el asesor toma una decision de precio con un dato
|
||||||
|
// malo. Verificado en produccion que el modelo puede omitir un factor (una tasa de
|
||||||
|
// conversion) y aun asi cuadrar la aritmetica.
|
||||||
|
if (d.propuesta.diagnostico.valorProblema.dimensiones.length) {
|
||||||
|
seccion("De donde sale el valor anual");
|
||||||
|
for (const dim of d.propuesta.diagnostico.valorProblema.dimensiones) {
|
||||||
|
const monto = dim.montoAnualMXN;
|
||||||
|
const cab = `${dim.tipo}: ${monto !== null ? formatCurrency(monto) : "sin cifra"}`;
|
||||||
|
y = need(14, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(cab, L, y);
|
||||||
|
y += 13;
|
||||||
|
if (dim.factores.length) {
|
||||||
|
const desglose = dim.factores.map((f) => `${f.nombre} = ${f.valor} (${f.confianza})`).join(" x ");
|
||||||
|
const h = txtH(desglose, W - 10, 8);
|
||||||
|
y = need(h + 8, y);
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(desglose, L + 6, y, { width: W - 10 });
|
||||||
|
y += h + 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sinConfirmar = d.propuesta.diagnostico.valorProblema.dimensiones.some(
|
||||||
|
(dim) => dim.montoAnualMXN !== null && !dim.factores.some((f) => f.confianza === "confirmado")
|
||||||
|
);
|
||||||
|
if (sinConfirmar) {
|
||||||
|
const alerta =
|
||||||
|
"Ojo: hay una cifra que descansa entera en factores estimados. Antes de fiarte del ratio, " +
|
||||||
|
"revisa que no falte un factor — la aritmetica puede cuadrar y aun asi estar inflada.";
|
||||||
|
const h = txtH(alerta, W, 8.5);
|
||||||
|
y = need(h + 12, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(8.5).fillColor("#b91c1c").text(alerta, L, y, { width: W });
|
||||||
|
y += h + 14;
|
||||||
|
}
|
||||||
|
y += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r) {
|
||||||
|
const nota =
|
||||||
|
r.lectura === "subcotizado"
|
||||||
|
? "La inversion es menos del 10% de lo que el problema le cuesta al cliente cada ano. Probablemente subcotizaste."
|
||||||
|
: r.lectura === "objecion_probable"
|
||||||
|
? "La inversion supera el 40% del valor anual del problema. Prepara la conversacion de precio: se responde con alcance, no con descuento."
|
||||||
|
: r.lectura === "alto"
|
||||||
|
? "La inversion esta en la banda alta. Justificable, pero conviene anclar bien el valor antes de dar el numero."
|
||||||
|
: "La inversion cae en el rango de referencia (15-25% del valor anual).";
|
||||||
|
const h = txtH(nota, W, 8.5);
|
||||||
|
y = need(h + 12, y);
|
||||||
|
doc.font("Helvetica-Oblique").fontSize(8.5).fillColor(MUTED).text(nota, L, y, { width: W });
|
||||||
|
y += h + 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.propuesta.hechos.redFlags.length) {
|
||||||
|
seccion("Red flags");
|
||||||
|
for (const rf of d.propuesta.hechos.redFlags) {
|
||||||
|
const t = `· [${rf.severidad}] ${rf.senal}`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.propuesta.redaccion.notasInternas.length) {
|
||||||
|
seccion("Notas para el asesor");
|
||||||
|
for (const n of d.propuesta.redaccion.notasInternas) {
|
||||||
|
const t = `· ${n}`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.propuesta.hechos.mencionesFueraDeAlcance.length) {
|
||||||
|
seccion("Se menciono y quedo fuera");
|
||||||
|
for (const m of d.propuesta.hechos.mencionesFueraDeAlcance) {
|
||||||
|
const t = `· ${m}`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return listo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { llamarConHerramienta, modelo, type UsoTokens, type BloqueSystem } from "./cliente-ia";
|
||||||
|
import {
|
||||||
|
hechosSchema,
|
||||||
|
diagnosticoSchema,
|
||||||
|
redaccionSchema,
|
||||||
|
type Hechos,
|
||||||
|
type Diagnostico,
|
||||||
|
type Redaccion,
|
||||||
|
type PropuestaConsultiva,
|
||||||
|
} from "./schemas";
|
||||||
|
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "./prompts";
|
||||||
|
import type { DatosEconomicos } from "./economia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline de tres pasos.
|
||||||
|
*
|
||||||
|
* Se separa en tres porque un solo prompt monolitico produce un documento mediocre en
|
||||||
|
* todas sus partes: la extraccion determina la calidad de todo lo demas y merece su
|
||||||
|
* propia pasada. Ademas permite corregir los hechos antes de que se propaguen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Traza {
|
||||||
|
modelo: string;
|
||||||
|
pasos: { paso: string; intentos: number; uso: UsoTokens }[];
|
||||||
|
totalUso: UsoTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un solo breakpoint de cache, al final de la capa estable. El pipeline hace tres
|
||||||
|
// llamadas seguidas con el mismo prefijo, asi que el TTL de 5 minutos de MiniMax
|
||||||
|
// alcanza de sobra y las llamadas 2 y 3 deberian leer del cache.
|
||||||
|
const SYSTEM: BloqueSystem[] = [{ texto: SYSTEM_BASE, cachear: true }];
|
||||||
|
|
||||||
|
export async function generarPropuesta(opts: {
|
||||||
|
entrada: ContextoEntrada;
|
||||||
|
economia: DatosEconomicos;
|
||||||
|
}): Promise<{ propuesta: PropuestaConsultiva; traza: Traza }> {
|
||||||
|
const pasos: Traza["pasos"] = [];
|
||||||
|
const total: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
|
||||||
|
const acumular = (u: UsoTokens) => {
|
||||||
|
total.entrada += u.entrada;
|
||||||
|
total.salida += u.salida;
|
||||||
|
total.cacheLectura += u.cacheLectura;
|
||||||
|
total.cacheEscritura += u.cacheEscritura;
|
||||||
|
};
|
||||||
|
|
||||||
|
const r1 = await llamarConHerramienta<Hechos>({
|
||||||
|
system: SYSTEM,
|
||||||
|
mensajeUsuario: mensajePaso1(opts.entrada),
|
||||||
|
herramienta: {
|
||||||
|
nombre: "registrar_hechos",
|
||||||
|
descripcion:
|
||||||
|
"Registra las citas literales, los hechos, los pendientes y las red flags extraidos del material de la reunion.",
|
||||||
|
schema: hechosSchema,
|
||||||
|
},
|
||||||
|
// 5 intentos: verificado contra la API real que MiniMax se equivoca de array de
|
||||||
|
// vez en cuando con schemas anidados (mete campos de "hechos" dentro de "citas").
|
||||||
|
// Es el paso fundacional; si falla, no hay documento. El cache abarata el reintento.
|
||||||
|
maxIntentos: 5,
|
||||||
|
maxTokens: 8000,
|
||||||
|
});
|
||||||
|
pasos.push({ paso: "extraccion", intentos: r1.intentos, uso: r1.uso });
|
||||||
|
acumular(r1.uso);
|
||||||
|
|
||||||
|
const r2 = await llamarConHerramienta<Diagnostico>({
|
||||||
|
system: SYSTEM,
|
||||||
|
mensajeUsuario: mensajePaso2(opts.entrada, r1.datos, opts.economia),
|
||||||
|
herramienta: {
|
||||||
|
nombre: "registrar_diagnostico",
|
||||||
|
descripcion:
|
||||||
|
"Registra los hallazgos con su semaforo de urgencia, el valor anual del problema y los resultados de negocio a lograr.",
|
||||||
|
schema: diagnosticoSchema,
|
||||||
|
},
|
||||||
|
maxIntentos: 4,
|
||||||
|
maxTokens: 8000,
|
||||||
|
});
|
||||||
|
pasos.push({ paso: "diagnostico", intentos: r2.intentos, uso: r2.uso });
|
||||||
|
acumular(r2.uso);
|
||||||
|
|
||||||
|
// El paso 3 recibe un intento extra: es el mas largo y el que atraviesa mas
|
||||||
|
// compuertas de validacion, asi que es donde mas probable es agotar el presupuesto.
|
||||||
|
const r3 = await llamarConHerramienta<Redaccion>({
|
||||||
|
system: SYSTEM,
|
||||||
|
mensajeUsuario: mensajePaso3(opts.entrada, r1.datos, r2.datos, opts.economia),
|
||||||
|
herramienta: {
|
||||||
|
nombre: "registrar_propuesta",
|
||||||
|
descripcion: "Registra la redaccion final de la propuesta consultiva para el cliente.",
|
||||||
|
schema: redaccionSchema,
|
||||||
|
},
|
||||||
|
maxIntentos: 4,
|
||||||
|
maxTokens: 12000,
|
||||||
|
});
|
||||||
|
pasos.push({ paso: "redaccion", intentos: r3.intentos, uso: r3.uso });
|
||||||
|
acumular(r3.uso);
|
||||||
|
|
||||||
|
return {
|
||||||
|
propuesta: { hechos: r1.datos, diagnostico: r2.datos, redaccion: r3.datos },
|
||||||
|
traza: { modelo: modelo(), pasos, totalUso: total },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type { DatosEconomicos } from "./economia";
|
||||||
|
import type { Hechos, Diagnostico } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts en capas, ordenados por estabilidad.
|
||||||
|
*
|
||||||
|
* SYSTEM_BASE es la capa 1: identica en toda cotizacion, y por eso es la unica que
|
||||||
|
* se marca como cacheable. NADA dinamico puede entrar aqui — ni fechas, ni numeros
|
||||||
|
* de cotizacion, ni nombres de cliente. Un solo byte que cambie invalida el cache
|
||||||
|
* de todo lo que viene despues, y el sintoma es solo la factura.
|
||||||
|
*/
|
||||||
|
export const SYSTEM_BASE = `
|
||||||
|
# QUIEN ERES
|
||||||
|
|
||||||
|
Consultor senior de digitalizacion de negocios para PyMEs mexicanas. Redactas la parte
|
||||||
|
narrativa de las propuestas de Consultoria E3 (Queretaro, Mexico). No eres vendedor: eres
|
||||||
|
diagnosticador. Tu trabajo es explicar por que la inversion tiene sentido, nunca decidir
|
||||||
|
cuanto cuesta.
|
||||||
|
|
||||||
|
El documento que produces acompana a otro que ya existe: la cotizacion economica que el
|
||||||
|
asesor armo a mano. Ese documento manda en todo lo que sea dinero. El tuyo manda en el
|
||||||
|
porque.
|
||||||
|
|
||||||
|
# LOS PRINCIPIOS QUE NO SE NEGOCIAN
|
||||||
|
|
||||||
|
P1. NO TOCAS LOS NUMEROS. No calculas, propones ni mencionas precios, totales, IVA ni
|
||||||
|
descuentos de E3. Ni siquiera los ves. Para referirte a una partida usa su
|
||||||
|
refPartida (P01, P02...). El sistema inyecta los importes despues de ti.
|
||||||
|
Si escribes una cifra en pesos, solo puede ser el costo del problema DEL CLIENTE:
|
||||||
|
lo que le cuesta hoy no resolverlo. Nunca un precio nuestro.
|
||||||
|
|
||||||
|
P2. TODA AFIRMACION ES TRAZABLE. Cada cosa que digas sobre el cliente apunta a una cita
|
||||||
|
(C01...) o a un hecho (H01...). Si no tiene origen, no se escribe. No hay excepciones
|
||||||
|
y no importa lo razonable que suene.
|
||||||
|
|
||||||
|
P3. ETIQUETAS LA CERTEZA, y la diferencia importa:
|
||||||
|
- confirmado: el cliente lo dijo textualmente, o hay un dato duro.
|
||||||
|
- estimado: lo derivaste con un calculo explicito que puedes mostrar.
|
||||||
|
- por_validar: es hipotesis tuya y nadie la ha confirmado.
|
||||||
|
Presentar un estimado como confirmado es la forma mas rapida de perder una reunion:
|
||||||
|
basta que el cliente refute una cifra para que dude de todo el documento.
|
||||||
|
|
||||||
|
P4. LAS PALABRAS DEL CLIENTE SON SAGRADAS. Las citas van textuales, con sus muletillas si
|
||||||
|
hace falta. No las pulas ni les arregles la gramatica.
|
||||||
|
Bien: "se nos van los clientes porque nadie contesta el WhatsApp el fin de semana"
|
||||||
|
Mal: "oportunidades de mejora en la gestion omnicanal de la comunicacion"
|
||||||
|
|
||||||
|
P5. VENDES RESULTADO, NO HERRAMIENTA. Traduce toda capacidad tecnica a dinero, tiempo,
|
||||||
|
riesgo evitado o tranquilidad operativa.
|
||||||
|
Mal: "Configuracion de Google Ads con estructura SKAG y scripts de puja."
|
||||||
|
Bien: "Cada peso de pauta se dirige a las busquedas que si compran, en lugar de
|
||||||
|
repartirse entre terminos que solo generan clics."
|
||||||
|
|
||||||
|
P6. LO QUE NO INCLUYE VALE TANTO COMO LO QUE SI. Las exclusiones son especificas de ESTE
|
||||||
|
proyecto, sacadas de lo que se menciono en la reunion.
|
||||||
|
Mal: "No incluye servicios no mencionados."
|
||||||
|
Bien: "No incluye migrar el historico de 4 anos de pedidos que mencionaron; eso se
|
||||||
|
evalua como fase 2 cuando el sistema base este operando."
|
||||||
|
|
||||||
|
P7. LA INCERTIDUMBRE SE DECLARA, NO SE RELLENA. Si falta un dato, va a pendientes con su
|
||||||
|
etiqueta. Jamas lo inventas. Una propuesta con ocho pendientes honestos cierra mejor
|
||||||
|
que una con ocho cifras inventadas: el dia que el cliente refute un dato inventado, el
|
||||||
|
costo no lo paga el modelo, lo paga la marca.
|
||||||
|
|
||||||
|
P8. NO PROPONES SERVICIOS QUE EL ASESOR NO ELIGIO. Si detectas una necesidad que nuestro
|
||||||
|
catalogo cubre y no esta cotizada, va a notasInternas. Nunca al documento del cliente.
|
||||||
|
|
||||||
|
# EL SEMAFORO DEL DIAGNOSTICO
|
||||||
|
|
||||||
|
Cada hallazgo se clasifica por urgencia, y la clasificacion es parte del mensaje:
|
||||||
|
- rojo: problema critico. Le esta costando dinero hoy.
|
||||||
|
- ambar: area de mejora. Funciona, pero por debajo de lo que podria.
|
||||||
|
- azul: oportunidad. Algo que no esta haciendo y podria.
|
||||||
|
- verde: ventaja existente. Algo que el cliente YA hace bien.
|
||||||
|
|
||||||
|
El verde no es relleno ni cortesia: reconocer lo que el cliente hizo bien es lo que
|
||||||
|
convierte una propuesta en una conversacion entre pares en lugar de un regano. Buscalo
|
||||||
|
de verdad. Pero si no hay evidencia de alguno de los cuatro, omitelo antes que inventarlo.
|
||||||
|
|
||||||
|
# REGLAS DE CONTENIDO Y DE MARCA
|
||||||
|
|
||||||
|
1. Espanol de Mexico. Tono cercano y profesional. Tutea al cliente.
|
||||||
|
2. Prohibido el lenguaje corporativo vacio: sinergias, holistico, stakeholders,
|
||||||
|
ecosistema, disruptivo, robusto, potenciar, empoderar, solucion integral.
|
||||||
|
3. El CRM se llama Bucefalo. No menciones ningun otro CRM, por ningun motivo, aunque el
|
||||||
|
cliente haya nombrado uno en la reunion.
|
||||||
|
4. E3 ofrece UNICAMENTE servicios digitales. Si en la reunion pidieron marketing
|
||||||
|
tradicional, impresos o diseno para imprenta, va a exclusiones aclarando que no es un
|
||||||
|
servicio de E3.
|
||||||
|
5. Moneda MXN, IVA 16%, facturacion CFDI. Jamas montos en dolares ni en USD.
|
||||||
|
6. No prometas resultados garantizados de mercado: ni ventas, ni posicion numero uno en
|
||||||
|
Google, ni cantidad de prospectos. Prometes entregables, procesos y metricas de
|
||||||
|
seguimiento. La palabra "garantizado" no aparece sobre resultados de mercado.
|
||||||
|
7. No prometas soporte ilimitado ni mantenimiento gratuito indefinido.
|
||||||
|
8. No incluyas nombres de empleados del cliente, sueldos, ni temas legales, laborales o de
|
||||||
|
salud. Si la reunion los toco, se omiten del documento; si el asesor debe saberlo, va a
|
||||||
|
notasInternas.
|
||||||
|
|
||||||
|
# QUE HACES CUANDO EL MATERIAL ES POBRE
|
||||||
|
|
||||||
|
Este es el caso mas importante y el que peor se suele resolver.
|
||||||
|
|
||||||
|
Si la transcripcion es corta, vaga, o no tiene una sola cifra: NO inventas para rellenar.
|
||||||
|
Produces un documento honesto — pocos hallazgos, varios marcados por_validar, montos en
|
||||||
|
null, y una lista larga y util de materiales y decisiones pendientes.
|
||||||
|
|
||||||
|
Ese documento es un exito, no un fracaso. Le dice al cliente exactamente que hace falta
|
||||||
|
para avanzar, y transfiere la responsabilidad del retraso a donde corresponde. Un hueco de
|
||||||
|
informacion declarado con claridad deja de ser un defecto y se vuelve contenido de valor.
|
||||||
|
|
||||||
|
Lo que NUNCA haces con material pobre: inventar una cifra plausible, atribuir al cliente
|
||||||
|
una frase que no dijo, o describir un dolor generico de la industria como si fuera suyo.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export interface ContextoEntrada {
|
||||||
|
transcripcion: string;
|
||||||
|
notas: string;
|
||||||
|
observaciones: string;
|
||||||
|
cliente: string;
|
||||||
|
empresa: string;
|
||||||
|
proyecto: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capa 3: volatil. Cambia por cotizacion, nunca se cachea. */
|
||||||
|
export function bloqueContexto(e: ContextoEntrada): string {
|
||||||
|
const partes = [
|
||||||
|
`Cliente: ${e.cliente}${e.empresa ? ` (${e.empresa})` : ""}`,
|
||||||
|
`Proyecto: ${e.proyecto}`,
|
||||||
|
];
|
||||||
|
if (e.observaciones.trim()) {
|
||||||
|
partes.push(`\n## Observaciones que el asesor escribio para el cliente\n${e.observaciones.trim()}`);
|
||||||
|
}
|
||||||
|
if (e.notas.trim()) {
|
||||||
|
partes.push(`\n## Notas por partida\n${e.notas.trim()}`);
|
||||||
|
}
|
||||||
|
if (e.transcripcion.trim()) {
|
||||||
|
partes.push(`\n## Transcripcion de la reunion\n${e.transcripcion.trim()}`);
|
||||||
|
} else {
|
||||||
|
partes.push(
|
||||||
|
`\n## Transcripcion de la reunion\n(No se entrego transcripcion. Trabaja solo con lo de arriba, se generoso marcando por_validar, y puebla bien los pendientes.)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return partes.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mensajePaso1(e: ContextoEntrada): string {
|
||||||
|
return `${bloqueContexto(e)}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Extrae los hechos de este material y llama a registrar_hechos.
|
||||||
|
|
||||||
|
Reglas de este paso:
|
||||||
|
- Las citas van TEXTUALES. Copialas del material, no las reescribas. Si no hay nada
|
||||||
|
citable, devuelve el array vacio en vez de inventar una cita.
|
||||||
|
- Numera las citas C01, C02... y los hechos H01, H02...
|
||||||
|
- Cada hecho apunta a las citas que lo sostienen. Un hecho sin ninguna cita solo puede
|
||||||
|
ser "por_validar".
|
||||||
|
- En quienLoDijo usa el ROL ("el socio", "quien atiende el WhatsApp"), no el nombre
|
||||||
|
propio, salvo que sea el interlocutor comercial.
|
||||||
|
- Si de alguna de estas listas no hay NADA que decir, devuelvela VACIA. Un array vacio
|
||||||
|
es la respuesta correcta; no metas "N/A", "ninguno" ni un guion para rellenar.
|
||||||
|
- En redFlags anota lo que deberia preocuparle al asesor: que pidan descuento antes de
|
||||||
|
entender el alcance, que no este presente quien decide, que no haya ninguna cifra del
|
||||||
|
problema, o un "hagan todo y luego vemos".`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mensajePaso2(e: ContextoEntrada, hechos: Hechos, econ: DatosEconomicos): string {
|
||||||
|
const partidas = econ.partidas
|
||||||
|
.map((p) => `- ${p.refPartida}: ${p.nombre} (fase ${p.fase}, pago ${p.tipoPago})`)
|
||||||
|
.join("\n");
|
||||||
|
return `${bloqueContexto(e)}
|
||||||
|
|
||||||
|
## Hechos extraidos en el paso anterior
|
||||||
|
${JSON.stringify(hechos, null, 2)}
|
||||||
|
|
||||||
|
## Partidas que el asesor ya eligio
|
||||||
|
${partidas || "(ninguna)"}
|
||||||
|
|
||||||
|
(Las partidas van sin precio a proposito: tu no los necesitas y no debes mencionarlos.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Diagnostica y llama a registrar_diagnostico.
|
||||||
|
|
||||||
|
Reglas de este paso:
|
||||||
|
- Reformula al problema de NEGOCIO. El cliente describe sintomas ("quiero una pagina
|
||||||
|
web"); tu nombras la enfermedad ("pierden prospectos porque no tienen a donde mandarlos
|
||||||
|
desde los anuncios").
|
||||||
|
- Intenta cubrir el semaforo completo, incluido al menos un verde. Si no hay evidencia de
|
||||||
|
algun color, omitelo: es mejor un semaforo incompleto que un hallazgo inventado.
|
||||||
|
- En valorProblema, si declaras un montoAnualMXN tienes que mostrar al menos dos
|
||||||
|
factores que lo expliquen, y la aritmetica debe cuadrar: el monto es el producto o la
|
||||||
|
suma de sus factores. Cada factor lleva su propia confianza — si uno de ellos te lo
|
||||||
|
estas inventando (una tasa de conversion tipica, por ejemplo), marcalo por_validar
|
||||||
|
aunque los demas sean confirmados. Un solo factor inventado puede sostener toda la
|
||||||
|
cifra, y el asesor necesita saber cual es.
|
||||||
|
- Si NO tienes cifras para una dimension, OMITE montoAnualMXN y deja factores vacio.
|
||||||
|
No inventes factores de relleno para llenar el hueco: un array vacio dice la verdad,
|
||||||
|
"herramienta_actual=0 x canal=1" no dice nada.
|
||||||
|
- Recuerda P1: montoAnualMXN es lo que el problema le cuesta AL CLIENTE cada ano. No es
|
||||||
|
un precio de E3 ni tiene relacion con lo que cotizamos.
|
||||||
|
- Los resultados a lograr son de negocio y medibles. "Mejorar la presencia digital" no es
|
||||||
|
un resultado; "ningun mensaje sin respuesta en mas de 24 horas" si lo es.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mensajePaso3(
|
||||||
|
e: ContextoEntrada,
|
||||||
|
hechos: Hechos,
|
||||||
|
diag: Diagnostico,
|
||||||
|
econ: DatosEconomicos
|
||||||
|
): string {
|
||||||
|
const partidas = econ.partidas
|
||||||
|
.map(
|
||||||
|
(p) => `- ${p.refPartida}: ${p.nombre} (fase ${p.fase}, pago ${p.tipoPago}, entrega ${p.tiempoEntrega})`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `## Hechos
|
||||||
|
${JSON.stringify(hechos, null, 2)}
|
||||||
|
|
||||||
|
## Diagnostico
|
||||||
|
${JSON.stringify(diag, null, 2)}
|
||||||
|
|
||||||
|
## Partidas a describir
|
||||||
|
${partidas || "(ninguna)"}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Redacta la propuesta y llama a registrar_propuesta.
|
||||||
|
|
||||||
|
Reglas de este paso:
|
||||||
|
- citaDestacadaId debe ser el ID de una cita que YA exista en los hechos de arriba. No
|
||||||
|
escribas una cita nueva ni le corrijas la gramatica: se imprime literal.
|
||||||
|
- En alcance, una entrada por cada refPartida de la lista, traducida a resultado de
|
||||||
|
negocio. No menciones importes ni plazos de pago: el sistema los inyecta.
|
||||||
|
- Cada beneficio apunta al hallazgoId que resuelve. Si un beneficio no responde a ningun
|
||||||
|
hallazgo del diagnostico, borralo: es relleno.
|
||||||
|
- Minimo una exclusion, especifica de este proyecto y sacada de lo que se menciono.
|
||||||
|
- El backlog es lo que quedo fuera y merece registrarse. Va con la etiqueta de que NO
|
||||||
|
esta comprometido en esta propuesta.
|
||||||
|
- notasInternas es lo unico que el cliente no vera. Mete ahi las red flags, lo que el
|
||||||
|
asesor deberia confirmar antes de presentar, y cualquier servicio de nuestro catalogo
|
||||||
|
que creas que aplica y no este cotizado.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrato con el modelo. MiniMax no soporta structured outputs, asi que estos
|
||||||
|
* schemas viajan como `input_schema` de una herramienta y Zod valida la respuesta.
|
||||||
|
*
|
||||||
|
* REGLA ESTRUCTURAL: ningun schema de aqui contiene un campo de dinero DE E3.
|
||||||
|
* El modelo devuelve `refPartida` y prosa; los precios, totales e IVA los inyecta
|
||||||
|
* el codigo al renderizar. `montoAnualMXN` si existe, pero es lo que el problema
|
||||||
|
* le cuesta AL CLIENTE cada ano — no un precio nuestro.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REF_PARTIDA = /^P\d{2}$/;
|
||||||
|
const ID_CITA = /^C\d{2}$/;
|
||||||
|
const ID_HECHO = /^H\d{2}$/;
|
||||||
|
const ID_HALLAZGO = /^D\d{2}$/;
|
||||||
|
|
||||||
|
export const CONFIANZA = ["confirmado", "estimado", "por_validar"] as const;
|
||||||
|
export const URGENCIA = ["rojo", "ambar", "azul", "verde"] as const;
|
||||||
|
export const DIMENSION_COSTO = ["dinero", "tiempo", "oportunidad", "error"] as const;
|
||||||
|
|
||||||
|
// ───────────────────── Paso 1: extraccion de hechos ─────────────────────
|
||||||
|
export const hechosSchema = z
|
||||||
|
.object({
|
||||||
|
citas: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().regex(ID_CITA, "El id de cita debe ser C01, C02..."),
|
||||||
|
textoLiteral: z
|
||||||
|
.string()
|
||||||
|
.min(10)
|
||||||
|
.describe("Cita TEXTUAL del cliente, palabra por palabra. Nunca parafraseada ni corregida."),
|
||||||
|
quienLoDijo: z
|
||||||
|
.string()
|
||||||
|
.describe("Rol del interlocutor comercial ('el socio', 'quien atiende el WhatsApp'). No nombres de empleados."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(30),
|
||||||
|
hechos: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().regex(ID_HECHO, "El id de hecho debe ser H01, H02..."),
|
||||||
|
enunciado: z.string().min(10),
|
||||||
|
confianza: z.enum(CONFIANZA),
|
||||||
|
citas: z.array(z.string().regex(ID_CITA)).describe("IDs de las citas que sostienen este hecho."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(40),
|
||||||
|
materialesPendientes: z
|
||||||
|
.array(z.object({ texto: z.string().min(3), bloqueaEntrega: z.boolean() }))
|
||||||
|
.max(15),
|
||||||
|
decisionesPendientes: z
|
||||||
|
.array(z.object({ texto: z.string().min(3), quienDecide: z.string() }))
|
||||||
|
.max(15),
|
||||||
|
mencionesFueraDeAlcance: z.array(z.string().min(3)).max(15),
|
||||||
|
redFlags: z
|
||||||
|
.array(z.object({ senal: z.string().min(3), severidad: z.enum(["baja", "media", "alta"]) }))
|
||||||
|
.max(10),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// ─────────────── Paso 2: diagnostico y valoracion del problema ───────────────
|
||||||
|
export const diagnosticoSchema = z
|
||||||
|
.object({
|
||||||
|
hallazgos: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().regex(ID_HALLAZGO, "El id de hallazgo debe ser D01, D02..."),
|
||||||
|
urgencia: z
|
||||||
|
.enum(URGENCIA)
|
||||||
|
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
|
||||||
|
// 140 y no 80: con 80 el modelo se pasaba y gastaba un reintento. Observado
|
||||||
|
// en 2 de 6 corridas contra datos reales.
|
||||||
|
titulo: z.string().min(3).max(140),
|
||||||
|
descripcion: z.string().min(20),
|
||||||
|
confianza: z.enum(CONFIANZA),
|
||||||
|
citas: z.array(z.string().regex(ID_CITA)),
|
||||||
|
hechos: z.array(z.string().regex(ID_HECHO)),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(12),
|
||||||
|
valorProblema: z.object({
|
||||||
|
dimensiones: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
tipo: z.enum(DIMENSION_COSTO),
|
||||||
|
descripcion: z.string().min(10),
|
||||||
|
// Antes esto vivia dentro de un objeto `calculo`. Se aplano porque ese
|
||||||
|
// nivel extra no aportaba nada semantico y era donde el modelo se perdia:
|
||||||
|
// devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Observado
|
||||||
|
// en 3 de 6 corridas contra datos reales.
|
||||||
|
//
|
||||||
|
// Sin min(2) fijo: exigir dos factores cuando no hay cifra obliga al
|
||||||
|
// modelo a inventar relleno. La exigencia se aplica solo si hay monto.
|
||||||
|
factores: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
nombre: z.string(),
|
||||||
|
valor: z.number(),
|
||||||
|
// Sin este campo el modelo metia la incertidumbre dentro del nombre
|
||||||
|
// ("tasa_conversion (por_validar)=0.1"). Mejor dato que convencion.
|
||||||
|
confianza: z.enum(CONFIANZA),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(6),
|
||||||
|
// .default(null) y no solo .nullable(): verificado contra la API real que
|
||||||
|
// el modelo OMITE el campo en vez de mandar null, que es lo natural para
|
||||||
|
// un LLM. Exigirlo presente quemaba los tres intentos.
|
||||||
|
montoAnualMXN: z
|
||||||
|
.number()
|
||||||
|
.nullable()
|
||||||
|
.default(null)
|
||||||
|
.describe("Costo ANUAL del problema DEL CLIENTE. No es un precio de E3. Omitelo si no hay cifras."),
|
||||||
|
confianza: z.enum(CONFIANZA),
|
||||||
|
hechos: z.array(z.string().regex(ID_HECHO)),
|
||||||
|
})
|
||||||
|
.superRefine((d, ctx) => {
|
||||||
|
if (d.montoAnualMXN !== null && d.factores.length < 2) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["factores"],
|
||||||
|
message: "Si declaras montoAnualMXN, muestra al menos 2 factores que lo expliquen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(4),
|
||||||
|
notaMetodologia: z.string().describe("Como se llego a las cifras, en una o dos frases."),
|
||||||
|
}),
|
||||||
|
resultados: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
enunciado: z.string().min(15),
|
||||||
|
metrica: z.string(),
|
||||||
|
lineaBase: z.string().nullable(),
|
||||||
|
periodoMedicion: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(5),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// ───────────────────────── Paso 3: redaccion final ─────────────────────────
|
||||||
|
export const redaccionSchema = z
|
||||||
|
.object({
|
||||||
|
hero: z.object({
|
||||||
|
titulo: z.string().min(5).max(90),
|
||||||
|
subtitulo: z.string().min(20).max(400),
|
||||||
|
}),
|
||||||
|
citaDestacadaId: z
|
||||||
|
.string()
|
||||||
|
.regex(ID_CITA)
|
||||||
|
.describe("ID de una cita YA extraida en el paso 1. Se imprime literal; no la reescribas."),
|
||||||
|
alcance: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
refPartida: z.string().regex(REF_PARTIDA, "Debe ser P01, P02..."),
|
||||||
|
descripcionResultado: z
|
||||||
|
.string()
|
||||||
|
.min(20)
|
||||||
|
.describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
// 120 y no 40: una cotizacion real (UJ2606UR001) tiene 58 partidas, asi que el
|
||||||
|
// tope de 40 garantizaba un rechazo de Zod y un reintento desperdiciado en cada
|
||||||
|
// corrida. Observado en 6 de 6. El tope solo esta para acotar una respuesta
|
||||||
|
// desbocada; la completitud del documento ya no depende de este array, porque el
|
||||||
|
// generador recorre las partidas de la cotizacion.
|
||||||
|
.max(120),
|
||||||
|
beneficios: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
// 70 y no 40: se pasaba en 2 de 3 vueltas contra datos reales.
|
||||||
|
etiqueta: z.string().min(3).max(70),
|
||||||
|
texto: z.string().min(20),
|
||||||
|
hallazgoId: z
|
||||||
|
.string()
|
||||||
|
.regex(ID_HALLAZGO)
|
||||||
|
.describe("El hallazgo que este beneficio resuelve. Si no responde a ninguno, no lo incluyas."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(8),
|
||||||
|
exclusiones: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
texto: z.string().min(15).describe("Especifica de ESTE proyecto, nunca generica."),
|
||||||
|
razon: z.string().min(5),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(10),
|
||||||
|
backlogEvolucion: z
|
||||||
|
.array(z.object({ problema: z.string().min(10), momentoSugerido: z.string() }))
|
||||||
|
.max(8),
|
||||||
|
notasInternas: z
|
||||||
|
.array(z.string().min(5))
|
||||||
|
.max(10)
|
||||||
|
.describe("SOLO para el asesor. Nunca se imprime en el documento del cliente."),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type Hechos = z.infer<typeof hechosSchema>;
|
||||||
|
export type Diagnostico = z.infer<typeof diagnosticoSchema>;
|
||||||
|
export type Redaccion = z.infer<typeof redaccionSchema>;
|
||||||
|
|
||||||
|
/** El objeto completo que se persiste, se edita y se renderiza. */
|
||||||
|
export interface PropuestaConsultiva {
|
||||||
|
hechos: Hechos;
|
||||||
|
diagnostico: Diagnostico;
|
||||||
|
redaccion: Redaccion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema del objeto completo, para validar lo que el asesor edita a mano. */
|
||||||
|
export const propuestaCompletaSchema = z.object({
|
||||||
|
hechos: hechosSchema,
|
||||||
|
diagnostico: diagnosticoSchema,
|
||||||
|
redaccion: redaccionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod -> JSON Schema para el `input_schema` de la herramienta.
|
||||||
|
* zod 4.3.6 trae `z.toJSONSchema` nativo: no hace falta `zod-to-json-schema`.
|
||||||
|
* Se borra `$schema` porque el endpoint no lo espera.
|
||||||
|
*/
|
||||||
|
export function aJsonSchema(schema: z.ZodType): Record<string, unknown> {
|
||||||
|
const js = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
|
||||||
|
delete js.$schema;
|
||||||
|
return js;
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import type { PropuestaConsultiva } from "./schemas";
|
||||||
|
import type { DatosEconomicos } from "./economia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validacion post-generacion. Corre en codigo, nunca en la IA.
|
||||||
|
*
|
||||||
|
* R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son una
|
||||||
|
* ENTRADA que el modelo recibe en el prompt y puede copiar literalmente en cualquier
|
||||||
|
* campo de prosa. Comparar el bloque interno que produjo el modelo contra la salida
|
||||||
|
* no detecta eso.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Aviso {
|
||||||
|
regla: string;
|
||||||
|
severidad: "bloqueante" | "advertencia";
|
||||||
|
mensaje: string;
|
||||||
|
ruta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normaliza para comparar citas contra las fuentes: sin acentos, en minusculas, sin
|
||||||
|
* puntuacion y con espacios colapsados. Laxa a proposito, para sobrevivir al ruido de
|
||||||
|
* transcripcion automatica sin volverse inutil. */
|
||||||
|
export function normalizar(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const OTRO_CRM = /\b(hubspot|salesforce|pipedrive|zoho|go\s*high\s*level|gohighlevel|clientify|kommo|monday\s*crm)\b/i;
|
||||||
|
const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(ventas|resultados|posici)/i;
|
||||||
|
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i;
|
||||||
|
const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO el texto que termina impreso en el documento del cliente.
|
||||||
|
*
|
||||||
|
* Esta lista tiene que cubrir exactamente lo que dibuja `generarPropuestaPDF`. Es la
|
||||||
|
* unica fuente de los filtros de marca, moneda, garantias y PII: un campo que se
|
||||||
|
* imprime y no esta aqui llega al cliente sin que nadie lo mire.
|
||||||
|
*
|
||||||
|
* Faltaban seis, todos impresos: el texto y el autor de la cita destacada, la metrica
|
||||||
|
* y el periodo de cada resultado, quien decide cada pendiente, y el momento sugerido
|
||||||
|
* del backlog. Si agregas algo al PDF, agregalo aqui en el mismo cambio.
|
||||||
|
*/
|
||||||
|
export function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
|
||||||
|
const out: { ruta: string; texto: string }[] = [
|
||||||
|
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
|
||||||
|
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
|
||||||
|
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
|
||||||
|
];
|
||||||
|
// La cita destacada se imprime literal, con su autor (pdf.ts, seccion Diagnostico).
|
||||||
|
const destacada = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
|
||||||
|
if (destacada) {
|
||||||
|
out.push({ ruta: `citas.${destacada.id}.textoLiteral`, texto: destacada.textoLiteral });
|
||||||
|
out.push({ ruta: `citas.${destacada.id}.quienLoDijo`, texto: destacada.quienLoDijo });
|
||||||
|
}
|
||||||
|
p.diagnostico.hallazgos.forEach((h, i) => {
|
||||||
|
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
|
||||||
|
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
|
||||||
|
});
|
||||||
|
p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
|
||||||
|
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
|
||||||
|
);
|
||||||
|
p.diagnostico.resultados.forEach((r, i) => {
|
||||||
|
out.push({ ruta: `resultados[${i}].enunciado`, texto: r.enunciado });
|
||||||
|
out.push({ ruta: `resultados[${i}].metrica`, texto: r.metrica });
|
||||||
|
out.push({ ruta: `resultados[${i}].periodoMedicion`, texto: r.periodoMedicion });
|
||||||
|
});
|
||||||
|
p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado }));
|
||||||
|
p.redaccion.beneficios.forEach((b, i) =>
|
||||||
|
out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` })
|
||||||
|
);
|
||||||
|
p.redaccion.exclusiones.forEach((e, i) => out.push({ ruta: `exclusiones[${i}]`, texto: e.texto }));
|
||||||
|
p.redaccion.backlogEvolucion.forEach((b, i) => {
|
||||||
|
out.push({ ruta: `backlog[${i}].problema`, texto: b.problema });
|
||||||
|
out.push({ ruta: `backlog[${i}].momentoSugerido`, texto: b.momentoSugerido });
|
||||||
|
});
|
||||||
|
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto }));
|
||||||
|
p.hechos.decisionesPendientes.forEach((d, i) => {
|
||||||
|
out.push({ ruta: `decisiones[${i}].texto`, texto: d.texto });
|
||||||
|
out.push({ ruta: `decisiones[${i}].quienDecide`, texto: d.quienDecide });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ngramas(texto: string, n: number): Set<string> {
|
||||||
|
const palabras = normalizar(texto).split(" ").filter(Boolean);
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (let i = 0; i + n <= palabras.length; i++) out.add(palabras.slice(i, i + n).join(" "));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validarPropuesta(
|
||||||
|
p: PropuestaConsultiva,
|
||||||
|
econ: DatosEconomicos,
|
||||||
|
fuentes: { textoCliente: string; textoInterno: string }
|
||||||
|
): Aviso[] {
|
||||||
|
const avisos: Aviso[] = [];
|
||||||
|
const visible = textoVisible(p);
|
||||||
|
|
||||||
|
// ── R0 (bloqueante): el texto interno del asesor no puede aparecer en el documento
|
||||||
|
// del cliente. Es entrada, no salida; ninguna otra regla lo mira.
|
||||||
|
if (fuentes.textoInterno.trim()) {
|
||||||
|
const internos = ngramas(fuentes.textoInterno, 7);
|
||||||
|
if (internos.size) {
|
||||||
|
for (const { ruta, texto } of visible) {
|
||||||
|
for (const g of ngramas(texto, 7)) {
|
||||||
|
if (internos.has(g)) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R0",
|
||||||
|
severidad: "bloqueante",
|
||||||
|
ruta,
|
||||||
|
mensaje: `Texto de las notas internas aparece en el documento del cliente: "${g}"`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── R2 (bloqueante): ninguna partida inventada.
|
||||||
|
const refsReales = new Set(econ.partidas.map((x) => x.refPartida));
|
||||||
|
for (const a of p.redaccion.alcance) {
|
||||||
|
if (!refsReales.has(a.refPartida)) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R2",
|
||||||
|
severidad: "bloqueante",
|
||||||
|
ruta: `alcance ${a.refPartida}`,
|
||||||
|
mensaje: `La partida ${a.refPartida} no existe en la cotizacion.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Advertencia si el modelo se salto partidas reales.
|
||||||
|
const refsUsadas = new Set(p.redaccion.alcance.map((a) => a.refPartida));
|
||||||
|
for (const real of econ.partidas) {
|
||||||
|
if (!refsUsadas.has(real.refPartida)) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R2b",
|
||||||
|
severidad: "advertencia",
|
||||||
|
ruta: real.refPartida,
|
||||||
|
mensaje: `La partida "${real.nombre}" (${real.refPartida}) esta cotizada pero no aparece descrita.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── R3 (bloqueante): la cita destacada existe y es literal.
|
||||||
|
const cita = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
|
||||||
|
if (!cita) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R3",
|
||||||
|
severidad: "bloqueante",
|
||||||
|
mensaje: `citaDestacadaId ${p.redaccion.citaDestacadaId} no corresponde a ninguna cita extraida.`,
|
||||||
|
});
|
||||||
|
} else if (fuentes.textoCliente.trim()) {
|
||||||
|
if (!normalizar(fuentes.textoCliente).includes(normalizar(cita.textoLiteral))) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R3",
|
||||||
|
severidad: "bloqueante",
|
||||||
|
ruta: cita.id,
|
||||||
|
mensaje: `La cita destacada no aparece literal en las fuentes: "${cita.textoLiteral.slice(0, 70)}..."`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── R4 (advertencia): un hallazgo "confirmado" necesita respaldo.
|
||||||
|
p.diagnostico.hallazgos.forEach((h, i) => {
|
||||||
|
if (h.confianza === "confirmado" && h.citas.length + h.hechos.length === 0) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R4",
|
||||||
|
severidad: "advertencia",
|
||||||
|
ruta: `hallazgos[${i}]`,
|
||||||
|
mensaje: `"${h.titulo}" se declara confirmado sin citas ni hechos. Deberia ser por_validar.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R5 (bloqueante): la aritmetica del valor tiene que cuadrar.
|
||||||
|
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
||||||
|
const m = d.montoAnualMXN;
|
||||||
|
if (m === null) return;
|
||||||
|
const producto = d.factores.reduce((a, f) => a * f.valor, 1);
|
||||||
|
const suma = d.factores.reduce((a, f) => a + f.valor, 0);
|
||||||
|
const base = Math.max(Math.abs(m), 1);
|
||||||
|
const cuadra = Math.abs(producto - m) / base < 0.02 || Math.abs(suma - m) / base < 0.02;
|
||||||
|
if (!cuadra) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R5",
|
||||||
|
severidad: "bloqueante",
|
||||||
|
ruta: `valorProblema.dimensiones[${i}]`,
|
||||||
|
mensaje: `montoAnualMXN ${m} no cuadra con sus factores (producto ${producto}, suma ${suma}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R5b (advertencia): el valor anual manda sobre el ratio precio/valor, y ese ratio
|
||||||
|
// es el que te dice si subcotizaste. Una cifra que descansa solo en factores
|
||||||
|
// estimados puede estar inflada un orden de magnitud sin que R5 lo note: R5 verifica
|
||||||
|
// que los factores multipliquen al monto, no que no FALTE un factor.
|
||||||
|
// Observado contra la API real: el modelo produjo 3,120,000 omitiendo la tasa de
|
||||||
|
// conversion (asumio que cada mensaje sin contestar era un cliente perdido), y la
|
||||||
|
// aritmetica cuadraba. El ratio habria dicho "subcotizado" por un factor de 10.
|
||||||
|
p.diagnostico.valorProblema.dimensiones.forEach((d, i) => {
|
||||||
|
if (d.montoAnualMXN === null) return;
|
||||||
|
const confirmados = d.factores.filter((f) => f.confianza === "confirmado").length;
|
||||||
|
if (confirmados === 0) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R5b",
|
||||||
|
severidad: "advertencia",
|
||||||
|
ruta: `valorProblema.dimensiones[${i}]`,
|
||||||
|
mensaje:
|
||||||
|
`La cifra de ${d.montoAnualMXN} no tiene ni un factor confirmado: descansa entera en estimaciones. ` +
|
||||||
|
`Revisa el desglose antes de fiarte del ratio precio/valor.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R6 (advertencia): referencias colgantes entre pasos.
|
||||||
|
const idsHallazgo = new Set(p.diagnostico.hallazgos.map((h) => h.id));
|
||||||
|
p.redaccion.beneficios.forEach((b, i) => {
|
||||||
|
if (!idsHallazgo.has(b.hallazgoId)) {
|
||||||
|
avisos.push({
|
||||||
|
regla: "R6",
|
||||||
|
severidad: "advertencia",
|
||||||
|
ruta: `beneficios[${i}]`,
|
||||||
|
mensaje: `El beneficio apunta al hallazgo ${b.hallazgoId}, que no existe.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Filtro de contenido sobre lo visible ──
|
||||||
|
for (const { ruta, texto } of visible) {
|
||||||
|
if (OTRO_CRM.test(texto))
|
||||||
|
avisos.push({ regla: "MARCA", severidad: "bloqueante", ruta, mensaje: "Menciona un CRM que no es Bucefalo." });
|
||||||
|
if (MONTO_USD.test(texto))
|
||||||
|
avisos.push({ regla: "MONEDA", severidad: "bloqueante", ruta, mensaje: "Contiene un monto o referencia en USD." });
|
||||||
|
if (PROMESA_GARANTIA.test(texto))
|
||||||
|
avisos.push({ regla: "GARANTIA", severidad: "bloqueante", ruta, mensaje: "Promete un resultado garantizado." });
|
||||||
|
if (TEMA_SENSIBLE.test(texto))
|
||||||
|
avisos.push({ regla: "PII", severidad: "advertencia", ruta, mensaje: "Menciona un tema laboral, salarial o de salud. Revisalo antes de enviar." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return avisos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hayBloqueantes(avisos: Aviso[]): boolean {
|
||||||
|
return avisos.some((a) => a.severidad === "bloqueante");
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export const cotizacionPostSchema = z.object({
|
|||||||
esDoble: z.boolean().optional(),
|
esDoble: z.boolean().optional(),
|
||||||
opciones: opcionesSchema,
|
opciones: opcionesSchema,
|
||||||
observaciones: z.string(),
|
observaciones: z.string(),
|
||||||
|
observacionesInternas: z.string().optional(),
|
||||||
asesorId: z.string().min(1),
|
asesorId: z.string().min(1),
|
||||||
cliente: z.object({
|
cliente: z.object({
|
||||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||||
@@ -73,6 +74,7 @@ export const cotizacionPutSchema = z.object({
|
|||||||
esDoble: z.boolean().optional(),
|
esDoble: z.boolean().optional(),
|
||||||
opciones: opcionesSchema,
|
opciones: opcionesSchema,
|
||||||
observaciones: z.string(),
|
observaciones: z.string(),
|
||||||
|
observacionesInternas: z.string().optional(),
|
||||||
cliente: z.object({
|
cliente: z.object({
|
||||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||||
empresa: z.string(),
|
empresa: z.string(),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface CotizacionDraft {
|
|||||||
planBucefaloNivel: string | null;
|
planBucefaloNivel: string | null;
|
||||||
servicios: ServicioSeleccionado[];
|
servicios: ServicioSeleccionado[];
|
||||||
observaciones: string;
|
observaciones: string;
|
||||||
|
observacionesInternas: string;
|
||||||
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
|
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
|
||||||
esDoble: boolean;
|
esDoble: boolean;
|
||||||
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
|
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
|
||||||
@@ -79,6 +80,7 @@ const initialDraft: CotizacionDraft = {
|
|||||||
planBucefaloNivel: null,
|
planBucefaloNivel: null,
|
||||||
servicios: [],
|
servicios: [],
|
||||||
observaciones: "",
|
observaciones: "",
|
||||||
|
observacionesInternas: "",
|
||||||
esDoble: false,
|
esDoble: false,
|
||||||
opciones: {},
|
opciones: {},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user