configuracion de arquitectura y manejo de herramientas

This commit is contained in:
unknown
2026-06-07 18:30:29 -04:00
parent cb44779349
commit 25669f3008
2 changed files with 87 additions and 155 deletions

View File

@@ -519,10 +519,11 @@ El bot DEBE:
El worker DEBE: El worker DEBE:
1. **Escuchar en la URL que configures como `PERFIL_WEBHOOK_URL`**. 1. **Escuchar en la URL que configures como `PERFIL_WEBHOOK_URL`** (POST /perfil-completo).
2. **Recibir el payload** con `leadId`, `cliente`, `reforma`, `zonas` (con fotos "antes"). 2. **Recibir el payload** con `leadId`, `cliente`, `reforma`, `zonas` (con fotos "antes").
3. **Generar renders** para cada zona usando un modelo image-to-image. 3. **Generar renders** para cada zona: Etapa 1 (Claude Haiku → prompt), Etapa 2 (Gemini Flash → imagen), Etapa 3 (Claude Haiku Vision → validación). Todo via OpenRouter.
4. **Devolver los renders** llamando a `POST /api/leads/:id/ingesta` con: 4. **Devolver los renders** llamando a `POST /api/leads/:id/ingesta` con:
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
```json ```json
{ {
"items": [ "items": [

View File

@@ -1,6 +1,6 @@
# Reformix Luisa Bot 🤖 # Reformix Luisa Bot
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional siguiendo una máquina de estados de 7 pasos, recoge 5 datos clave (espacio, metros, estilo, urgencia, presupuesto) y cierra el flujo según el flag viable/no_viable. Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional siguiendo una máquina de estados de 7 pasos. Toda la persistencia va por **API HTTP** contra la app principal (`POST /api/leads/:id/perfil`, `conversacion`, etc.), no escribe a Postgres directamente.
--- ---
@@ -10,7 +10,7 @@ Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de
|------|-----------| |------|-----------|
| **Framework** | NestJS 10 | | **Framework** | NestJS 10 |
| **WhatsApp** | Baileys 7 (`@whiskeysockets/baileys`) + `baileys-antiban` | | **WhatsApp** | Baileys 7 (`@whiskeysockets/baileys`) + `baileys-antiban` |
| **Base de datos** | PostgreSQL via TypeORM con `synchronize: true` (dev) / migrations (prod) | | **Persistencia** | API HTTP contra `REFORMIX_API_URL` con `Authorization: Bearer` |
| **LLM** | Claude 4.5 Sonnet/Haiku + Gemini 2.5 Flash via **OpenRouter** | | **LLM** | Claude 4.5 Sonnet/Haiku + Gemini 2.5 Flash via **OpenRouter** |
| **Logging** | Pino | | **Logging** | Pino |
| **QR** | `qrcode-terminal` | | **QR** | `qrcode-terminal` |
@@ -21,37 +21,38 @@ Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de
``` ```
/ /
├── auth_info_baileys/ ← Estado de sesión de WhatsApp (se genera automáticamente) ├── auth_info_baileys/ ← Estado de sesión de WhatsApp (se genera automáticamente)
├── dist/ ← Compilación ├── dist/ ← Compilación
├── node_modules/ ├── node_modules/
├── prompts/ ← Prompts del sistema para Claude ├── prompts/ ← Prompts del sistema para Claude
│ ├── luisa_core.md ← Identidad, personalidad y máquina de estados │ ├── luisa_core.md ← Identidad, personalidad y máquina de estados
│ ├── luisa_flujo.md ← Flujo de cualificación paso a paso │ ├── luisa_flujo.md ← Flujo de cualificación paso a paso
│ └── luisa_casos.md ← Casos edge y ejemplos │ └── luisa_casos.md ← Casos edge y ejemplos
├── src/ ├── src/
│ ├── main.ts ← Punto de entrada │ ├── main.ts ← Punto de entrada
│ ├── app.module.ts ← Módulo raíz con TypeORM y Schedule │ ├── app.module.ts ← Módulo raíz
│ ├── api/
│ │ ├── api-client.service.ts ← Cliente HTTP para endpoints de la app Reformix
│ │ └── api.module.ts
│ ├── whatsapp/ │ ├── whatsapp/
│ │ ├── whatsapp.module.ts │ │ ├── whatsapp.module.ts
│ │ ├── whatsapp.service.ts ← Conexión Baileys, recepción/envío │ │ ├── whatsapp.service.ts ← Conexión Baileys, recepción/envío
│ │ └── whatsapp-debounce.service.ts ← Debounce de 3s para coalescer mensajes rápidos │ │ └── whatsapp-debounce.service.ts ← Debounce de 3s para coalescer mensajes rápidos
│ ├── leads/ │ ├── leads/
│ │ ├── leads.module.ts │ │ ├── leads.module.ts
│ │ ── lead.entity.ts ← Entidad Lead con todos los campos │ │ ── leads.service.ts ← Máquina de estados, viabilidad (sin BD)
│ │ └── leads.service.ts ← CRUD, máquina de estados, viabilidad
│ ├── conversacion/ │ ├── conversacion/
│ │ ├── conversacion.module.ts │ │ ├── conversacion.module.ts
│ │ ── conversacion.entity.ts Entidad Conversacion (historial) │ │ ── conversacion.service.ts ← Historial via API HTTP
│ │ └── conversacion.service.ts ← Guardado y recuperación de historial
│ ├── claude/ │ ├── claude/
│ │ ├── claude.module.ts │ │ ├── claude.module.ts
│ │ └── claude.service.ts ← Arquitectura de 4 capas con Claude │ │ └── claude.service.ts ← Arquitectura de 4 capas con Claude
│ ├── media/ │ ├── media/
│ │ ├── media.module.ts │ │ ├── media.module.ts
│ │ └── media.service.ts ← Transcripción de audio + análisis de imagen │ │ └── media.service.ts ← Transcripción de audio + análisis de imagen
│ └── scheduler/ │ └── webhook/
│ ├── scheduler.module.ts │ ├── webhook.module.ts
│ └── scheduler.service.ts ← Cron cada 5 min: apertura a leads nuevos + limpieza │ └── webhook-listener.ts Servidor HTTP para recibir señales de la app
├── .env.example ├── .env.example
├── nest-cli.json ├── nest-cli.json
├── package.json ├── package.json
@@ -70,68 +71,40 @@ Mensaje entrante (texto / audio / imagen)
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ PREPROCESAMIENTO │ │ PREPROCESAMIENTO │
│ • Identificar lead por teléfono │ • Verificar lead en sesión
│ (llega via webhook, no por │
│ teléfono) │
│ • Si audio → transcripción │ │ • Si audio → transcripción │
│ (Gemini 2.5 Flash via │ │ (Gemini 2.5 Flash via │
│ OpenRouter) │ │ OpenRouter) │
│ • Si imagen → Vision │ │ • Si imagen → Vision │
│ (Claude Sonnet via │ (Claude Sonnet via │
│ OpenRouter) │ OpenRouter) + enviar a
│ /ingesta │
│ • Si texto → directo │ │ • Si texto → directo │
│ • Guardar mensaje usuario en │ • Guardar mensaje en
DB /conversacion (API HTTP)
└───────────┬───────────────────┘ └───────────┬───────────────────┘
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ CAPA 1: CLASIFICADOR (Haiku) │ │ CAPA 1: CLASIFICADOR (Haiku) │
│ Extrae intención y valor │
│ del mensaje: │
│ • responde_pregunta: bool │
│ • valor_extraido: string|null │
│ • es_desvio: bool │
│ • intencion: respuesta | │
│ desvio | despedida | │
│ insulto | pregunta │
└───────────┬───────────────────┘ └───────────┬───────────────────┘
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ CAPA 2: VALIDADOR (código) │ │ CAPA 2: VALIDADOR (código) │
│ Valida contra valores │
│ permitidos del estado actual │
│ • espacios: cocina, bano, │
│ salon, integral, otro │
│ • tamaño: menos10, 10a20, │
│ 20a40, mas40 │
│ • estilo: funcional, cuidado, │
│ exclusivo │
│ • urgencia: urgente, │
│ medio_plazo, frio │
│ • presupuesto: evalúa │
│ viabilidad (>= 5000€) │
└───────────┬───────────────────┘ └───────────┬───────────────────┘
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ CAPA 3: GENERADOR (Sonnet) │ │ CAPA 3: GENERADOR (Sonnet) │
│ Construye contexto y genera │
│ borrador de respuesta │
│ • Estado del lead │
│ • Datos capturados │
│ • Historial completo │
│ • Clasificación y validación │
│ • Reintentos │
└───────────┬───────────────────┘ └───────────┬───────────────────┘
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ CAPA 4: REGLAS (Haiku) │ │ CAPA 4: REGLAS (Haiku) │
│ Corrige el borrador para │
│ cumplir identidad de Luisa: │
│ • Sin menciones a IA │
│ • Máximo 2 líneas │
│ • Sin emojis │
│ • Tono Madrid/España │
└───────────┬───────────────────┘ └───────────┬───────────────────┘
Guardar respuesta en DB Guardar respuesta en /conversacion (API HTTP)
Persistir datos en /perfil (API HTTP)
Enviar por Baileys Enviar por Baileys
``` ```
@@ -148,15 +121,17 @@ MODEL_GENERADOR=anthropic/claude-sonnet-4-5 # Modelo para generar respuestas
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 # Modelo para clasificar mensajes (Capa 1) MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 # Modelo para clasificar mensajes (Capa 1)
MODEL_REGLAS=anthropic/claude-haiku-4-5 # Modelo para aplicar reglas (Capa 4) MODEL_REGLAS=anthropic/claude-haiku-4-5 # Modelo para aplicar reglas (Capa 4)
MODEL_TRANSCRIPCION=google/gemini-2.5-flash # Modelo para transcripción de audio MODEL_TRANSCRIPCION=google/gemini-2.5-flash # Modelo para transcripción de audio
MODEL=anthropic/claude-sonnet-4-5 # Fallback general si no se especifica otro MODEL=anthropic/claude-sonnet-4-5 # Fallback general
DATABASE_URL= # (REQUERIDA) PostgreSQL connection string API_BASE_URL=https://reformix.dv3.com.es # (REQUERIDA) URL de la app Reformix
FUNNEL_API_KEY= # (REQUERIDA) API key compartida
WEBHOOK_PORT=3001 # (OPCIONAL) Puerto para webhooks entrantes
ALLOWED_NUMBER= # (OPCIONAL) Restringe el bot a un solo número ALLOWED_NUMBER= # (OPCIONAL) Restringe el bot a un solo número
``` ```
**Notas:** **Notas:**
- `ALLOWED_NUMBER` es opcional. Si se define, el bot **solo atiende mensajes de ese número**. Si se deja vacío, acepta mensajes de cualquier número de teléfono. - `API_BASE_URL` + `FUNNEL_API_KEY` reemplazan a la antigua `DATABASE_URL`. El bot ya no escribe a Postgres directamente.
- La transcripción de audio usa **Gemini 2.5 Flash** por defecto porque Claude no soporta `input_audio` en OpenRouter. El resto del pipeline sigue usando Claude. - `WEBHOOK_PORT` define dónde escucha el servidor HTTP para recibir señales de la app (`/whatsapp-start`, `/whatsapp-pdf`).
- Si `MODEL` está definido, sirve como fallback para `MODEL_GENERADOR` cuando no se especifica. - Una vez escaneado el QR, Luisa queda en espera. La app le enviará leads vía `WHATSAPP_START_WEBHOOK_URL`.
--- ---
@@ -168,35 +143,13 @@ ALLOWED_NUMBER= # (OPCIONAL) Restringe el bot a u
cp .env.example .env cp .env.example .env
``` ```
Edita `.env` con tus valores reales: Edita `.env` con tus valores reales.
```env ### 2. Prompts de Luisa
OPENROUTER_API_KEY=sk-or-v1-tu-api-key-aqui
DATABASE_URL=postgresql://user:password@localhost:5432/reformix
```
### 2. Base de datos Los 3 archivos de prompts ya están creados y contienen la configuración completa. Puedes modificarlos para ajustar el tono o comportamiento de Luisa.
El proyecto usa `synchronize: true` en modo desarrollo (definido en `src/app.module.ts`). TypeORM creará las tablas automáticamente al arrancar. ### 3. Arrancar
En producción, cambia `synchronize: false` y usa migrations:
```bash
npm run migration:generate -- src/migrations/Init
npm run migration:run
```
### 3. Prompts de Luisa
Los 3 archivos de prompts ya están creados y contienen la configuración completa:
- **`prompts/luisa_core.md`** — Identidad de Luisa, personalidad (español de Madrid, cercana, directa), máquina de estados obligatoria con mensajes exactos por estado, extracción de datos con bloque JSON `<DATOS_EXTRAIDOS>`, manejo de casos especiales (desvíos, reintentos, inactividad, multimedia, tono defensivo), y ejemplos few-shot.
- **`prompts/luisa_flujo.md`** — Secuencia de estados, campos DB con valores permitidos, y mensajes por estado (versión simplificada).
- **`prompts/luisa_casos.md`** — Casos edge: desvíos, reintentos (máx 2), inactividad (24h/48h), manejo de audio/imagen/sticker, tono defensivo, usuario que no da presupuesto.
Puedes modificarlos libremente para ajustar el tono o comportamiento de Luisa.
### 4. Arrancar
```bash ```bash
npm install npm install
@@ -205,7 +158,7 @@ npm run start:dev
Aparecerá un **código QR** en la terminal. Escanéalo con WhatsApp → **WhatsApp Web**. Aparecerá un **código QR** en la terminal. Escanéalo con WhatsApp → **WhatsApp Web**.
Luisa queda conectada y lista para recibir mensajes. Luisa queda conectada y escuchando webhooks de la app (por defecto en puerto 3001).
--- ---
@@ -214,56 +167,60 @@ Luisa queda conectada y lista para recibir mensajes.
| Estado | Descripción | | Estado | Descripción |
|--------|-------------| |--------|-------------|
| `nuevo` | Lead creado, aún no contactado | | `nuevo` | Lead creado, aún no contactado |
| `en_proceso` | El scheduler le ha enviado el primer mensaje (transición interna) |
| `apertura` | Luisa se presenta y pregunta disponibilidad | | `apertura` | Luisa se presenta y pregunta disponibilidad |
| `espacio` | Pregunta: ¿qué espacio quieres reformar? | | `espacio` | Pregunta: ¿qué espacio quieres reformar? |
| `tamano` | Pregunta: ¿rango de metros cuadrados? | | `tamano` | Pregunta: ¿rango de metros cuadrados? |
| `estilo` | Pregunta: ¿tipo de acabado? | | `estilo` | Pregunta: ¿tipo de acabado? |
| `urgencia` | Pregunta: ¿cuándo quieres empezar? | | `urgencia` | Pregunta: ¿cuándo quieres empezar? |
| `presupuesto` | Pregunta: ¿presupuesto aproximado? | | `presupuesto` | Pregunta: ¿presupuesto aproximado? |
| `fin_viable` | Transición interna → `completado` si presupuesto >= 5000€ | | `fin_viable` | Lead viable (presupuesto >= 5000€) |
| `fin_no_viable` | Transición interna → `no_viable` si presupuesto < 5000€ | | `fin_no_viable` | Lead no viable (presupuesto < 5000€) |
| `recopilando_datos` | Estado legacy, se normaliza a `apertura` |
| `completado` | Todos los datos recogidos y lead viable |
| `no_viable` | Lead descartado por presupuesto insuficiente |
| `perdido` | Sin actividad > 48h |
**Secuencia completa:** `nuevo → apertura → espacio → tamano → estilo → urgencia → presupuesto → fin_viable/fin_no_viable`
### Datos recolectados por estado ### Datos recolectados por estado
| Estado | Campo DB | Valores válidos | | Estado | Campo perfil | Valores válidos |
|--------|----------|-----------------| |--------|-------------|-----------------|
| `espacio` | `espacio` | `cocina`, `bano`, `salon`, `integral`, `otro` | | `espacio` | `espacio` | `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro` |
| `tamano` | `rango_m2` | `menos10`, `10a20`, `20a40`, `mas40` | | `tamano` | `rangoM2` | `menos10`, `10a20`, `20a40`, `mas40` |
| `estilo` | `estilo` | `funcional`, `cuidado`, `exclusivo` | | `estilo` | `estilo` | `funcional`, `cuidado`, `exclusivo` |
| `urgencia` | `urgencia` | `urgente`, `medio_plazo`, `frio` | | `urgencia` | `urgencia` | `alta`, `media`, `baja` |
| `presupuesto` | `presupuesto_declarado` | Cifra o rango en euros | | `presupuesto` | `presupuestoDeclarado` | Cifra o rango en euros |
### Evaluación de viabilidad
Un presupuesto es **viable** si su valor numérico es **>= 5000€**. Si no se puede determinar el valor, se asume viable.
--- ---
## Scheduler (cron cada 5 min) ## Cómo se conecta con la app
El servicio `SchedulerService` se ejecuta cada 5 minutos vía `@nestjs/schedule` y realiza dos tareas: ```
App Reformix Bot (este proyecto)
│ │
│ POST /webhook/whatsapp-start │
│ { leadId, telefono, nombre, empresa }────►│ Guarda sesión
│ │
│ │ Cliente escribe a Luisa
│ │
│ ◄── POST /api/leads/:id/conversacion ──── │ Guarda turno
│ ◄── POST /api/leads/:id/perfil ────────── │ Actualiza datos
│ ◄── POST /api/leads/:id/intento ───────── │ Registra contacto
│ ◄── POST /api/leads/:id/ingesta ───────── │ Sube fotos del lead
│ │
│ POST /webhook/whatsapp-pdf │
│ { leadId, telefono, pdfBase64 }──────────►│ Envía PDF al cliente
```
1. **Limpiar leads inactivos**: Marca como `perdido` los leads en `en_proceso` sin actualización > 48h. ## Flujo de webhooks
2. **Apertura de leads nuevos**: Busca leads con `estado_actual = 'nuevo'`, los marca como `en_proceso`, llama a Claude para generar el mensaje de apertura, lo guarda en el historial y lo envía por WhatsApp.
| Webhook | Dirección | Puerto por defecto |
|---------|-----------|-------------------|
| `WHATSAPP_START_WEBHOOK_URL` | App → Bot | `http://bot:3001/whatsapp-start` |
| `WHATSAPP_WEBHOOK_URL` | App → Bot | `http://bot:3001/whatsapp-pdf` |
Configurar en el `.env` de la app Reformix.
--- ---
## Debounce de mensajes ## Debounce de mensajes
El servicio `WhatsappDebounceService` agrupa mensajes rápidos de un mismo usuario en una ventana de **3 segundos**. Si el usuario envía varios mensajes cortos seguidos, se concatenan en un solo texto antes de procesarlos. Esto evita que Claude reciba múltiples requests simultáneas por el mismo lead. El servicio `WhatsappDebounceService` agrupa mensajes rápidos de un mismo usuario en una ventana de **3 segundos**. Si el usuario envía varios mensajes cortos seguidos, se concatenan en un solo texto antes de procesarlos.
---
## Restricción por número (ALLOWED_NUMBER)
Si se define `ALLOWED_NUMBER` en `.env`, el bot **ignora cualquier mensaje que no venga de ese número**. Esto es útil para pruebas o para desplegar una instancia dedicada a un solo cliente.
--- ---
@@ -272,27 +229,22 @@ Si se define `ALLOWED_NUMBER` en `.env`, el bot **ignora cualquier mensaje que n
### Audio ### Audio
- Se descarga el buffer del mensaje de audio via Baileys. - Se descarga el buffer del mensaje de audio via Baileys.
- Se detecta el formato real por magic bytes (Ogg, MP3, WAV). - Se detecta el formato real por magic bytes (Ogg, MP3, WAV).
- Se envía a OpenRouter con `input_audio` usando **Gemini 2.5 Flash** (modelo configurable via `MODEL_TRANSCRIPCION`). - Se envía a OpenRouter con `input_audio` usando **Gemini 2.5 Flash**.
- La transcripción conserva coloquialismos y jerga madrileña. - La transcripción conserva coloquialismos y jerga madrileña.
- Si falla o el buffer es muy pequeño, se responde con un mensaje de cortesía pidiendo repetición.
### Imagen ### Imagen
- Se descarga el buffer y se envía a OpenRouter con `image_url` usando **Claude Sonnet**. - Se descarga el buffer y se envía a OpenRouter con `image_url` usando **Claude Sonnet**.
- El prompt de análisis varía según el estado actual del lead: - Además se envía a `/ingesta` de la app Reformix para persistirla.
- En `espacio`/`tamano`: infiere tipo de espacio y metros cuadrados.
- En `estilo`: infiere el estilo o calidad de acabados.
- En otros estados: descripción general.
- Si la imagen tiene caption, se combina con la inferencia. - Si la imagen tiene caption, se combina con la inferencia.
--- ---
## Manejo de errores y reconexión ## Manejo de errores y reconexión
- Si la conexión de WhatsApp se cierra por cualquier motivo que **no sea logout**, se reintenta automáticamente tras 5 segundos. - Reconexión automática a WhatsApp tras 5 segundos (excepto logout).
- Si hay un **logout** (sesión cerrada), se muestra un error pidiendo eliminar `auth_info_baileys` y reiniciar. - Cada mensaje se procesa en un bloque `try/catch`.
- Cada mensaje se procesa en un bloque `try/catch`; si falla, se loguea el error y se continúa con el siguiente mensaje. - Si Claude falla al clasificar, se usa un fallback conservador.
- Si Claude falla repetidamente al clasificar, se usa un fallback conservador (desvío) para no bloquear la conversación. - Las llamadas a la API de Reformix son **best-effort** (nunca lanzan error, loguean y continúan).
- Si la respuesta generada contiene frases prohibidas ("soy un asistente", "ChatGPT", etc.), se reemplaza automáticamente con un `mensajeFallback` según el estado actual.
--- ---
@@ -302,41 +254,20 @@ Si se define `ALLOWED_NUMBER` en `.env`, el bot **ignora cualquier mensaje que n
npm run build # Compilar con NestJS npm run build # Compilar con NestJS
npm run start # Iniciar en producción npm run start # Iniciar en producción
npm run start:dev # Iniciar en desarrollo con watch npm run start:dev # Iniciar en desarrollo con watch
npm run start:debug # Iniciar en modo debug
npm run lint # Ejecutar ESLint npm run lint # Ejecutar ESLint
npm run test # Ejecutar tests con Jest npm run test # Ejecutar tests con Jest
npm run migration:generate # Generar migración de TypeORM
npm run migration:run # Ejecutar migraciones pendientes
``` ```
--- ---
## Qué NO hace este servicio
- ❌ No genera el presupuesto (lo hace otro worker externo)
- ❌ No renderiza el PDF
- ❌ No envía la URL del presupuesto (la inserta el worker en `url_presupuesto`)
- ❌ No tiene panel del reformista
- ❌ No maneja conversaciones grupales (@g.us) — se ignoran explícitamente
---
## Desarrollo ## Desarrollo
### Requisitos ### Requisitos
- Node.js >= 20 - Node.js >= 20
- PostgreSQL >= 14
- Cuenta en [OpenRouter](https://openrouter.ai) con API key - Cuenta en [OpenRouter](https://openrouter.ai) con API key
- App Reformix corriendo con `FUNNEL_API_KEY` configurada
### Tests
```bash
npm run test
npm run test:watch # Modo watch
npm run test:cov # Con cobertura
```
--- ---
Desarrollado para Reformix © 2025 Desarrollado para Reformix © 2026