Compare commits
4 Commits
d3189d7277
...
d34925cd7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d34925cd7f | ||
|
|
25669f3008 | ||
|
|
cb44779349 | ||
|
|
fec365bb57 |
573
docs/arquitectura-integracion.md
Normal file
573
docs/arquitectura-integracion.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# Arquitectura de Integración — Reformix
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Visión general del sistema](#1-visión-general-del-sistema)
|
||||
2. [Landing pública y captura del lead (sitio web)](#2-landing-pública-y-captura-del-lead-sitio-web)
|
||||
3. [El funnel B2C — pipeline de 7 pasos](#3-el-funnel-b2c--pipeline-de-7-pasos)
|
||||
4. [Elección de canal: formulario, llamada o WhatsApp](#4-elección-de-canal-formulario-llamada-o-whatsapp)
|
||||
5. [Sistema de webhooks salientes (app → bot/worker)](#5-sistema-de-webhooks-salientes-app--botworker)
|
||||
6. [Sistema de endpoints de bot (app ← bot/worker)](#6-sistema-de-endpoints-de-bot-app--botworker)
|
||||
7. [El agente de WhatsApp (Luisa)](#7-el-agente-de-whatsapp-luisa)
|
||||
8. [Workers: render de imágenes y presupuesto](#8-workers-render-de-imágenes-y-presupuesto)
|
||||
9. [El presupuesto como entregable final](#9-el-presupuesto-como-entregable-final)
|
||||
10. [Diagrama de flujo completo](#10-diagrama-de-flujo-completo)
|
||||
11. [Estado actual vs lo que falta](#11-estado-actual-vs-lo-que-falta)
|
||||
12. [Guía de conexión: cómo integrar Luisa + Workers con la app](#12-guía-de-conexión-cómo-integrar-luisa--workers-con-la-app)
|
||||
|
||||
---
|
||||
|
||||
## 1. Visión general del sistema
|
||||
|
||||
Reformix es un SaaS multi-tenant para empresas de reformas. Tiene **dos caras**:
|
||||
|
||||
- **B2B (reformista):** el reformista se registra, configura su perfil, catálogo de materiales, precios, y pone un widget en su web.
|
||||
- **B2C (cliente final):** el cliente llega a la landing del reformista, pide presupuesto, sube fotos, y recibe un presupuesto con render "antes/después" en < 7 minutos.
|
||||
|
||||
### Componentes del sistema
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SITIO WEB (Next.js) │
|
||||
│ Landing → Formulario → Elección canal → Pipeline → Entrega │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ App Reformix (mvp/b2c/) │ │
|
||||
│ │ - Landing pública /[slug] │ │
|
||||
│ │ - Funnel B2C /solicitud/[id]/* │ │
|
||||
│ │ - Panel reformista /panel/* │ │
|
||||
│ │ - API endpoints para bot (mvp/b2c/src/app/api/leads/) │ │
|
||||
│ │ - Motor de presupuesto (mvp/b2c/src/budget/) │ │
|
||||
│ │ - Generación de PDF (mvp/b2c/src/lib/pdf/) │ │
|
||||
│ │ - Envío de email (mvp/b2c/src/lib/email/) │ │
|
||||
│ │ - Webhooks salientes (mvp/b2c/src/lib/webhooks.ts) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AGENTE WHATSAPP (Luisa) — EXTERNO │
|
||||
│ (mvp/Whatsapp-bot/) │
|
||||
│ - Conexión WhatsApp via Baileys │
|
||||
│ - Pipeline Claude 4-capas para cualificar leads │
|
||||
│ - DEBE usar API HTTP de la app (hoy escribe directo a BD) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ WORKERS (Render + Análisis) — NO IMPLEMENTADO AÚN │
|
||||
│ - Generación de renders "después" (Nano Banana 2 / Image 2) │
|
||||
│ - Análisis de fotos con IA │
|
||||
│ - DEBE recibir webhook PERFIL_WEBHOOK_URL y devolver vía API │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Landing pública y captura del lead (sitio web)
|
||||
|
||||
### Flujo de captura
|
||||
|
||||
```
|
||||
Usuario llega a /[slug] (landing del reformista)
|
||||
│
|
||||
▼
|
||||
Rellena formulario Hero:
|
||||
- nombre + email + teléfono
|
||||
- consentimiento privacidad + contratación (RGPD obligatorio)
|
||||
│
|
||||
▼
|
||||
crearLead(slug, data) → Server Action
|
||||
│
|
||||
├── Valida con Zod schema
|
||||
├── Busca tenant por slug
|
||||
├── INSERT en leads (pipelineStage: 'form_completado', estado: 'nuevo')
|
||||
└── INSERT en leadPipelineEventos (stage: 'form_completado')
|
||||
```
|
||||
|
||||
### Lo que crea la base de datos
|
||||
|
||||
El lead se crea con:
|
||||
|
||||
- `id` (UUID) — **este es el leadId que usará todo el sistema**
|
||||
- `tenantId` (UUID) — referencia al reformista
|
||||
- `nombre`, `email`, `telefono` — datos del cliente
|
||||
- `pipelineStage: 'form_completado'` — dónde está en el pipeline
|
||||
- `estado: 'nuevo'` — estado comercial
|
||||
|
||||
**Importante:** El lead NO tiene aún `tipoReforma`, `m2Suelo`, `calidadGlobal`, etc. Esos se rellenan después, cuando el cliente pasa por el canal elegido.
|
||||
|
||||
---
|
||||
|
||||
## 3. El funnel B2C — pipeline de 7 pasos
|
||||
|
||||
Definido por el enum `pipelineStage` en `src/db/schema.ts`:
|
||||
|
||||
| # | Stage | Qué ocurre | Quién lo dispara |
|
||||
| --- | ---------------------- | ---------------------------------------------- | ------------------------------------------------------- |
|
||||
| 1 | `form_completado` | Lead creado con datos básicos | Server Action `crearLead()` |
|
||||
| 2 | `fotos_subidas` | Cliente describe reforma + sube fotos por zona | Server Action `guardarDetallesYFotos()` o API `ingesta` |
|
||||
| 3 | `prellamada_enviada` | Notificación SMS/WhatsApp previa a llamada | Orchestrator `procesarLead()` o bot |
|
||||
| 4 | `llamada_completada` | Agente IA (Retell) cualifica al lead | Orchestrator (simulado) o webhook Retell (real) |
|
||||
| 5 | `render_generado` | Render "después" generado por IA | Orchestrator (simulado con imagen demo) |
|
||||
| 6 | `presupuesto_generado` | Presupuesto calculado con motor real | Orchestrator `procesarLead()` |
|
||||
| 7 | `whatsapp_entregado` | PDF entregado al cliente | `finalizarYEntregar()` |
|
||||
|
||||
### Pipeline automático (`procesarLead`)
|
||||
|
||||
Cuando el cliente completa el formulario detallado (con zonas, fotos, etc.), la app ejecuta:
|
||||
|
||||
```typescript
|
||||
guardarDetallesYFotos(leadId, formData)
|
||||
├── Guarda fotos (momento: 'antes') en leadFotos
|
||||
├── Guarda notas en leadNotas
|
||||
├── Calcula tipoReforma, m2Suelo, calidadGlobal desde las zonas
|
||||
├── UPDATE lead (pipelineStage: 'fotos_subidas')
|
||||
│
|
||||
└── procesarLead(leadId) ← ORQUESTRADOR
|
||||
├── Paso 4: Evento prellamada_enviada
|
||||
├── Paso 5: Llamada Retell (real si configurado, sino simulada con transcript ficticio)
|
||||
├── Paso 6a: Render demo (imagen estática, NO IA real)
|
||||
├── Paso 6b: Presupuesto REAL con motor (computeBudget)
|
||||
├── UPDATE lead (pipelineStage: 'presupuesto_generado')
|
||||
└── Paso 7: Si envio=automatico → lead pasa a 'whatsapp_entregado'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Elección de canal: formulario, llamada o WhatsApp
|
||||
|
||||
Después de crear el lead, el cliente llega a `/solicitud/[id]/` donde elige cómo continuar:
|
||||
|
||||
### Canal formulario (`/solicitud/[id]/formulario`)
|
||||
|
||||
El cliente rellena un formulario multi-zona: tipo de reforma, m², calidad, notas, sube fotos. Esto dispara `guardarDetallesYFotos()` que ejecuta el pipeline completo (incluyendo llamada simulada, render demo, presupuesto real).
|
||||
|
||||
### Canal llamada (`/solicitud/[id]/llamada`)
|
||||
|
||||
El cliente pide que le llamen (ahora o programado). Se le envía un email con enlace para subir fotos después. Dispara `pedirLlamada()` que inicia llamada Retell saliente (si configurado). El cliente recibe la llamada del agente IA, y después puede subir fotos via el enlace del email.
|
||||
|
||||
### Canal WhatsApp (`/solicitud/[id]/whatsapp`)
|
||||
|
||||
El cliente elige continuar por WhatsApp. La app dispara `iniciarWhatsapp()` que:
|
||||
|
||||
```typescript
|
||||
iniciarWhatsapp(leadId)
|
||||
└── POST a WHATSAPP_START_WEBHOOK_URL
|
||||
Payload: { leadId, telefono, nombre, empresa }
|
||||
→ El bot de WhatsApp (Luisa) recibe esto y empieza la conversación
|
||||
```
|
||||
|
||||
**Este es el punto de entrada del bot de WhatsApp.** El bot recibe el `leadId` y a partir de ahí debe escribir los datos extraídos de la conversación usando los endpoints de la app.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sistema de webhooks salientes (app → bot/worker)
|
||||
|
||||
La app envía 3 señales HTTP a sistemas externos. Todas son **best-effort** (nunca lanzan error, devuelven boolean).
|
||||
|
||||
### 5.1 WHATSAPP_START_WEBHOOK_URL — Arranque de conversación WhatsApp
|
||||
|
||||
**Disparado por:** `iniciarWhatsapp()` (cuando el lead elige canal WhatsApp)
|
||||
|
||||
```json
|
||||
POST {url}
|
||||
{
|
||||
"leadId": "uuid",
|
||||
"telefono": "+34...",
|
||||
"nombre": "...",
|
||||
"empresa": "Reformas Ejemplo"
|
||||
}
|
||||
```
|
||||
|
||||
**La app espera que:** el bot de WhatsApp reciba esto y comience la conversación con el lead. El bot debe usar el `leadId` para escribir los datos vía los endpoints de la app.
|
||||
|
||||
### 5.2 PERFIL_WEBHOOK_URL — Perfil completo para generar renders
|
||||
|
||||
**Disparado por:**
|
||||
|
||||
- `señalarPerfilCompleto()` en `guardarDetallesYFotos()` (formulario)
|
||||
- `señalarPerfilCompleto()` en `subirFotos()` (fotos por email)
|
||||
- API `ingesta` con flag `perfilCompleto: true` (bot/worker)
|
||||
|
||||
```json
|
||||
POST {url}
|
||||
{
|
||||
"leadId": "uuid",
|
||||
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
|
||||
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
|
||||
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
|
||||
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
|
||||
"zonas": [
|
||||
{ "zona": "cocina",
|
||||
"notas": ["encimera de cuarzo"],
|
||||
"fotos": { "antes": ["data:image/..."], "despues": [] } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes", y los devuelva haciendo POST al endpoint `/api/leads/:id/ingesta` con `momento: "despues"` y opcionalmente `finalizar: true`.
|
||||
|
||||
### 5.3 WHATSAPP_WEBHOOK_URL — Entrega del PDF
|
||||
|
||||
**Disparado por:** `finalizarYEntregar()` (cuando el PDF está listo)
|
||||
|
||||
```json
|
||||
POST {url}
|
||||
{
|
||||
"leadId": "uuid",
|
||||
"telefono": "+34...",
|
||||
"nombre": "...",
|
||||
"empresa": "Reformas Ejemplo",
|
||||
"pdfBase64": "JVBERi0xLj...",
|
||||
"filename": "presupuesto-nombre.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
**La app espera que:** el bot de WhatsApp reciba esto y envíe el PDF al cliente por WhatsApp.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sistema de endpoints de bot (app ← bot/worker)
|
||||
|
||||
La app expone 5 endpoints bajo `/api/leads/:id/`. Todos requieren `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||
|
||||
| Endpoint | Qué hace | Tabla que escribe |
|
||||
| ---------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `POST /api/leads/:id/conversacion` | Guarda un turno del chat | `conversacion_whatsapp` |
|
||||
| `POST /api/leads/:id/perfil` | Actualiza datos extraídos del lead | `leads` (campos: espacio, rangoM2, estilo, tipoReforma, m2Suelo, etc.) |
|
||||
| `POST /api/leads/:id/calificacion` | Upsert de calificación | `lead_calificacion` |
|
||||
| `POST /api/leads/:id/intento` | Registra intento de contacto | `intentos_contacto` |
|
||||
| `POST /api/leads/:id/ingesta` | Sube fotos/notas + flags de perfilCompleto/finalizar | `lead_fotos`, `lead_notas` |
|
||||
|
||||
### Flujo de uso típico del bot
|
||||
|
||||
```
|
||||
1. Bot recibe WHATSAPP_START_WEBHOOK → leadId, telefono, nombre
|
||||
2. Bot inicia conversación por WhatsApp
|
||||
3. Por cada interacción:
|
||||
a. Bot llama POST /conversacion (guarda el turno)
|
||||
b. Bot llama POST /perfil (actualiza datos extraídos: espacio, m2, estilo, etc.)
|
||||
c. Cuando tiene datos suficientes, llama POST /calificacion
|
||||
d. Cuando pide fotos, el cliente las envía, bot las guarda vía POST /ingesta
|
||||
4. Cuando el perfil está completo:
|
||||
- Bot marca perfilCompleto: true en POST /ingesta
|
||||
- Esto dispara PERFIL_WEBHOOK_URL → worker genera renders
|
||||
- Worker devuelve renders vía POST /ingesta con momento: "despues" + finalizar: true
|
||||
- finalizar:true dispara WHATSAPP_WEBHOOK_URL → bot recibe PDF y lo envía al cliente
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. El agente de WhatsApp (Luisa)
|
||||
|
||||
### Estado actual (código en `mvp/Whatsapp-bot/`)
|
||||
|
||||
El bot de Luisa es un servicio NestJS independiente que:
|
||||
|
||||
1. **Se conecta a WhatsApp** usando la librería Baileys (WebSocket no oficial)
|
||||
2. **Orquesta un pipeline Claude de 4 capas:**
|
||||
- Capa 1 — Clasificador (Haiku): extrae intención y valor del mensaje
|
||||
- Capa 2 — Validador: valida contra valores permitidos
|
||||
- Capa 3 — Generador (Sonnet): produce el borrador de respuesta
|
||||
- Capa 4 — Reglas (Haiku): corrige tono e identidad
|
||||
3. **Mantiene una máquina de estados** de 7 pasos para cualificar al lead:
|
||||
`nuevo → apertura → espacio → tamano → estilo → urgencia → presupuesto → fin`
|
||||
4. **Tiene un scheduler** que cada 5 minutos busca leads "nuevos" en BD y les envía el mensaje de apertura
|
||||
5. **Soporta multimedia:** transcripción de audio (Gemini), análisis de imágenes (Claude Vision)
|
||||
|
||||
### Problema actual
|
||||
|
||||
El bot **escribe directamente a Postgres** usando TypeORM con sus propias entidades (`Lead` y `Conversacion`), en lugar de usar los endpoints HTTP de la app. Esto causa:
|
||||
|
||||
- Incompatibilidad de IDs: el bot usa `id` numérico autoincremental, la app usa UUID
|
||||
- Tablas duplicadas: el bot tiene su propia tabla `conversacion`, la app tiene `conversacion_whatsapp`
|
||||
- Enums desalineados: el bot usa `urgente/medio_plazo/frio`, la app usa `alta/media/baja`
|
||||
- `synchronize: true` en TypeORM puede alterar el schema de la BD real
|
||||
- El scheduler crea leads desde cero, pero según la arquitectura los leads ya existen (creados desde el form web)
|
||||
|
||||
### Lo que DEBE hacer el bot
|
||||
|
||||
1. **No crear leads.** Recibirlos vía `WHATSAPP_START_WEBHOOK_URL` con el `leadId` UUID.
|
||||
2. **No escribir a BD directamente.** Usar los 5 endpoints HTTP (`conversacion`, `perfil`, `calificacion`, `intento`, `ingesta`).
|
||||
3. **No tener scheduler propio.** El arranque lo hace la app vía webhook.
|
||||
4. **Usar los enums correctos** según la app (`alta/media/baja`, `cocina/bano/salon/comedor/integral/otro`, etc.).
|
||||
5. **No mantener estado propio.** El estado de la conversación (`botStep`) se persiste vía `/perfil`.
|
||||
|
||||
### Dónde está el código
|
||||
|
||||
| Elemento | Ruta |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| Código del bot (NestJS) | `mvp/Whatsapp-bot/src/` |
|
||||
| Prompts de Luisa | `mvp/Whatsapp-bot/prompts/` |
|
||||
| Configuración (.env) | `mvp/Whatsapp-bot/.env` |
|
||||
| Documentación del bot | `mvp/Whatsapp-bot/README.md` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Workers: render de imágenes y presupuesto
|
||||
|
||||
### Estado actual
|
||||
|
||||
Los workers **no están implementados**. Existe:
|
||||
|
||||
- La **tabla `worker_jobs`** en la BD (`mvp/b2c/src/db/schema.ts:515-537`) con tipos: `analisis_fotos`, `render`, `presupuesto_ia`
|
||||
- El **webhook `PERFIL_WEBHOOK_URL`** listo para enviar el perfil completo al worker
|
||||
- El **endpoint `ingesta`** listo para recibir los renders de vuelta
|
||||
- **Renders simulados** con imágenes estáticas en `procesarLead()` (usa `/despues.webp`, `/despues-bano.webp`, etc.)
|
||||
|
||||
### Lo que DEBE hacer el worker de renders
|
||||
|
||||
```
|
||||
1. Recibe POST a PERFIL_WEBHOOK_URL con:
|
||||
{ leadId, cliente, reforma, empresa, zonas: [{ zona, notas, fotos: { antes: [...], despues: [] } }] }
|
||||
|
||||
2. Para cada zona:
|
||||
a. Toma las fotos "antes" del cliente
|
||||
b. Genera render "después" usando modelo de IA (Nano Banana 2, Image 2, Stable Diffusion, etc.)
|
||||
c. Convierte el render a data URI base64
|
||||
|
||||
3. Devuelve los renders haciendo POST a /api/leads/:id/ingesta:
|
||||
{ items: [{ tipo: "foto", zona: "cocina", momento: "despues", imagen: "data:image/..." }],
|
||||
finalizar: true }
|
||||
(finalizar:true dispara la construcción del PDF + email + señal WhatsApp)
|
||||
```
|
||||
|
||||
### Stack planeado para renders
|
||||
|
||||
Según la documentación:
|
||||
|
||||
- **Nano Banana 2** o **Image 2** (Google Gemini) — modelos image-to-image
|
||||
- Alternativa: **Replicate SDXL + ControlNet** (~0,02€/imagen)
|
||||
- Alternativa: **DALL-E 3 HD** (~0,08€/imagen)
|
||||
|
||||
### Dónde implementar los workers
|
||||
|
||||
Los workers son servicios externos independientes. Pueden ser:
|
||||
|
||||
- **n8n workflows** (orquestación visual)
|
||||
- **Un worker Node.js/Python** que escucha webhooks y se comunica con APIs de IA
|
||||
- **Cloud Functions** (Vercel, Cloudflare Workers, AWS Lambda)
|
||||
|
||||
No hay código de workers en este repositorio. El repositorio solo define el contrato (webhooks + API).
|
||||
|
||||
---
|
||||
|
||||
## 9. El presupuesto como entregable final
|
||||
|
||||
El entregable final es un **PDF de presupuesto** que incluye:
|
||||
|
||||
1. **Cabecera** con datos del reformista (logo, nombre, CIF, dirección)
|
||||
2. **Datos del cliente** y tipo de reforma
|
||||
3. **Tabla de presupuesto** con partidas calculadas por el motor:
|
||||
- Demolición, impermeabilización, alicatado, fontanería, electricidad, carpintería, mano de obra, extras, licencia
|
||||
4. **Render "después"** generado por IA
|
||||
5. **Galería por zona** con fotos "antes" y "después"
|
||||
6. **Footer legal**
|
||||
|
||||
### El motor de presupuesto (`mvp/b2c/src/budget/`)
|
||||
|
||||
**Es real, no simulado.** Calcula presupuestos basado en:
|
||||
|
||||
- Catálogo de materiales del reformista (precios por calidad: básica/media/premium)
|
||||
- Configuración de precios (factores por zona, mano de obra, extras fijos)
|
||||
- Inputs del lead: tipo de reforma, m², calidad, urgencia, estructural, provincia
|
||||
|
||||
### Flujo de entrega
|
||||
|
||||
```
|
||||
finalizarYEntregar(leadId)
|
||||
├── construirPresupuestoPdf(leadId)
|
||||
│ ├── Carga lead completo + fotos + notas + catálogo + config
|
||||
│ ├── Renderiza PDF con @react-pdf
|
||||
│ └── Devuelve buffer PDF + filename
|
||||
│
|
||||
├── UPDATE lead (pdfUrl)
|
||||
│
|
||||
├── enviarPresupuestoEmail() → SMTP (opcional)
|
||||
│
|
||||
├── notificarFlujoWhatsapp() → WHATSAPP_WEBHOOK_URL
|
||||
│ └── Bot recibe pdfBase64 y lo envía por WhatsApp
|
||||
│
|
||||
└── UPDATE lead (pipelineStage: 'whatsapp_entregado', estado: 'presupuesto_enviado')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Diagrama de flujo completo
|
||||
|
||||
```
|
||||
LANDING PÚBLICA /[slug]
|
||||
│
|
||||
crearLead()
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Lead CREADO │
|
||||
│ pipelineStage: │
|
||||
│ form_completado │
|
||||
│ estado: nuevo │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ ELECCIÓN DE CANAL │
|
||||
│ /solicitud/[id]/ │
|
||||
└──────┬────────┬───────┘
|
||||
│ │
|
||||
┌────────┘ └────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ FORMULARIO │ │ WHATSAPP │
|
||||
│ + fotos │ │ │
|
||||
└──────┬───────┘ │ iniciar │
|
||||
│ │ Whatsapp() │
|
||||
│ └──────┬───────┘
|
||||
│ guardarDetalles │
|
||||
│ YFotos() │ POST WHATSAPP
|
||||
│ │ START WEBHOOK
|
||||
├── Guarda fotos │
|
||||
├── Guarda notas ▼
|
||||
│ ┌──────────────┐
|
||||
└── procesarLead() │ BOT WHATSAPP │
|
||||
│ │ (Luisa) │
|
||||
│ │ │
|
||||
├── Simula │ Inicia │
|
||||
│ llamada │ conversación │
|
||||
│ │ │
|
||||
├── Render │ Por cada msg: │
|
||||
│ demo │ POST /perfil │
|
||||
│ (img fija)│ POST /conv. │
|
||||
│ │ │
|
||||
├── Presup. │ Cuando listo: │
|
||||
│ REAL │ POST /ingesta │
|
||||
│ │ perfilCompleto│
|
||||
│ │ │
|
||||
└── Estado └──────┬────────┘
|
||||
intermedio │
|
||||
│ │
|
||||
▼ │
|
||||
┌────────────────────┘
|
||||
│ PERFIL_WEBHOOK_URL
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ WORKER RENDER │
|
||||
│ Genera imágenes │
|
||||
│ "después" │
|
||||
└────────┬─────────┘
|
||||
│ POST /api/leads/:id/ingesta
|
||||
│ { items: [{tipo:"foto", momento:"despues",...}],
|
||||
│ finalizar: true }
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ finalizarYEntregar() │
|
||||
│ - Construye PDF │
|
||||
│ - Envía email │
|
||||
│ - WHATSAPP_WEBHOOK_URL │
|
||||
└────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ BOT WHATSAPP │
|
||||
│ Recibe pdfBase64│
|
||||
│ y lo envía al │
|
||||
│ cliente │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Estado actual vs lo que falta
|
||||
|
||||
| Componente | Estado | Notas |
|
||||
| ---------------------------- | ------------------------------- | -------------------------------------------------- |
|
||||
| Landing pública / formulario | ✅ Implementado | Multi-zona, fotos, notas |
|
||||
| Motor de presupuesto | ✅ Implementado | Real, con catálogo y config |
|
||||
| PDF | ✅ Implementado | Con @react-pdf |
|
||||
| Email | ✅ Implementado | SMTP, best-effort |
|
||||
| Endpoints API del bot | ✅ Implementado y en producción | 5 endpoints en `dv3.com.es` |
|
||||
| Webhooks salientes | ✅ Implementado | 3 webhooks listos |
|
||||
| Autenticación bot | ✅ Implementado | Bearer token con FUNNEL_API_KEY |
|
||||
| Bot WhatsApp (Luisa) | ❌ Por reconectar | Hoy escribe directo a BD, debe usar APIs HTTP |
|
||||
| Workers render | ❌ No implementado | Existe solo tabla y webhook, sin worker real |
|
||||
| Renders IA | ❌ Simulado | Usa imágenes estáticas `/despues.webp` |
|
||||
| Llamada Retell real | ⚠️ Parcial | Código listo, depende de config de vars de entorno |
|
||||
| n8n workflows | ❌ No existe | Mencionado en docs pero sin implementar |
|
||||
|
||||
---
|
||||
|
||||
## 12. Guía de conexión: cómo integrar Luisa + Workers con la app
|
||||
|
||||
### 12.1 Lo que necesita el bot de WhatsApp (Luisa)
|
||||
|
||||
El bot DEBE:
|
||||
|
||||
1. **Recibir leads por webhook** (`WHATSAPP_START_WEBHOOK_URL`), no buscarlos en BD.
|
||||
2. **Usar los endpoints HTTP de la app** en lugar de TypeORM:
|
||||
- `POST /api/leads/:id/conversacion` — para cada turno del chat
|
||||
- `POST /api/leads/:id/perfil` — para actualizar datos extraídos
|
||||
- `POST /api/leads/:id/calificacion` — para calificar al lead
|
||||
- `POST /api/leads/:id/intento` — para registrar intentos
|
||||
- `POST /api/leads/:id/ingesta` — para subir fotos y marcar perfil completo
|
||||
3. **Usar los enums correctos** de la app:
|
||||
- `urgencia`: `alta`, `media`, `baja`
|
||||
- `tipoReforma`: `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro`
|
||||
- `calidadGlobal`: `basica`, `media`, `premium`
|
||||
- `estadoWa`: `sin_enviar`, `enviado`, `entregado`, `leido`, `fallido`
|
||||
- `canalOrigen`: `formulario_web`, `whatsapp`, `llamada`, `referido`, `anuncio`
|
||||
4. **Trabajar con UUIDs** (el `leadId` que recibe del webhook).
|
||||
5. **No tener scheduler interno** — la app controla cuándo arrancar.
|
||||
|
||||
### 12.2 Lo que necesita el worker de renders
|
||||
|
||||
El worker DEBE:
|
||||
|
||||
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").
|
||||
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:
|
||||
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"tipo": "foto",
|
||||
"zona": "cocina",
|
||||
"momento": "despues",
|
||||
"imagen": "data:image/..."
|
||||
}
|
||||
],
|
||||
"finalizar": true
|
||||
}
|
||||
```
|
||||
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||
|
||||
### 12.3 Variables de entorno necesarias en la app
|
||||
|
||||
```
|
||||
# Para que el bot pueda escribir en la BD:
|
||||
FUNNEL_API_KEY=<clave-compartida>
|
||||
|
||||
# URLs donde escuchan el bot y el worker:
|
||||
WHATSAPP_START_WEBHOOK_URL=https://url-del-bot/whatsapp-start
|
||||
PERFIL_WEBHOOK_URL=https://url-del-worker/perfil-completo
|
||||
WHATSAPP_WEBHOOK_URL=https://url-del-bot/whatsapp-pdf
|
||||
```
|
||||
|
||||
### 12.4 Secuencia de integración recomendada
|
||||
|
||||
**Paso 1 — Reconectar Luisa a los endpoints HTTP (prioridad alta)**
|
||||
|
||||
- Eliminar TypeORM y las entidades propias del bot
|
||||
- Implementar llamadas HTTP a los 5 endpoints de la app
|
||||
- Ajustar enums y tipos para que coincidan con la app
|
||||
- Eliminar el scheduler interno
|
||||
- Configurar las 3 URLs de webhook en la app
|
||||
|
||||
**Paso 2 — Implementar worker de renders (prioridad media)**
|
||||
|
||||
- Crear servicio que escuche `PERFIL_WEBHOOK_URL`
|
||||
- Elegir modelo de IA para image-to-image
|
||||
- Integrar con el endpoint `ingesta` para devolver resultados
|
||||
|
||||
**Paso 3 — Conectar llamada Retell real (prioridad baja)**
|
||||
|
||||
- Configurar variables de entorno de Retell
|
||||
- El bot de WhatsApp o el formulario pueden complementar la llamada
|
||||
BIN
docs/flujo-usuario/flujo_reformix.webp
Normal file
BIN
docs/flujo-usuario/flujo_reformix.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
334
docs/flujo-usuario/flujo_reformix.xml
Normal file
334
docs/flujo-usuario/flujo_reformix.xml
Normal file
@@ -0,0 +1,334 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net">
|
||||
<diagram name="Página-1" id="DRtktbz_MXrh0E5vmsnP">
|
||||
<mxGraphModel dx="611" dy="239" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-1" parent="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" value="Reformix — Flujo del sistema" vertex="1">
|
||||
<mxGeometry height="40" width="500" x="600" y="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-2" parent="1" style="ellipse;whiteSpace=wrap;html=1;fontSize=13;fontStyle=1;" value="Cliente" vertex="1">
|
||||
<mxGeometry height="50" width="120" x="800" y="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-4" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Formulario web" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="540" y="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-5" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="WhatsApp directo" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="785" y="157" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-6" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Llamada" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="1030" y="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-7" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-4">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-8" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-5">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-9" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-6">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-10" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Crear lead en DB
(canal_origen registrado)" vertex="1">
|
||||
<mxGeometry height="50" width="250" x="735" y="280" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-11" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-4" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-12" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-5" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-13" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-6" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-14" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Registrar intento
(intentos_contacto)" vertex="1">
|
||||
<mxGeometry height="50" width="250" x="735" y="370" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-15" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-10" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-14">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-16" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Formulario" vertex="1">
|
||||
<mxGeometry height="20" width="150" x="400" y="450" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-17" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Cliente sube fotos
(lead_fotos · antes)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="390" y="480" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-18" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-17">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="735" y="395" />
|
||||
<mxPoint x="475" y="395" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-19" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
analiza + clasifica fotos
(lead_fotos · render_url)" vertex="1">
|
||||
<mxGeometry height="60" width="170" x="390" y="570" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-20" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-17" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-19">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-21" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||
<mxGeometry height="40" width="120" x="415" y="665" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-22" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-19" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-21">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-23" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="WhatsApp — Luisa" vertex="1">
|
||||
<mxGeometry height="20" width="200" x="710" y="450" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-24" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Scheduler 5 min
→ Bot Luisa
(estado_wa = nuevo)" vertex="1">
|
||||
<mxGeometry height="60" width="180" x="720" y="480" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-25" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-26" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Luisa cualifica (7 estados)
apertura→espacio→tamaño
→estilo→urgencia→presupuesto
[conversacion_whatsapp]" vertex="1">
|
||||
<mxGeometry height="70" width="200" x="710" y="575" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-27" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-24" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-26">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-28" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Viable?
(≥ 5000€)" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="740" y="675" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-29" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-26" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-28">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-30" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#fff2cc;strokeColor=#d6b656;" value="Lead no_viable
(descartado)" vertex="1">
|
||||
<mxGeometry height="50" width="130" x="580" y="690" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-31" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-30" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-32" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Pide fotos por WA
(leads · fotos_solicitadas_at)" vertex="1">
|
||||
<mxGeometry height="50" width="180" x="720" y="780" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-33" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-32" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-34" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos
recibidas?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="740" y="860" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-35" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-32" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-36" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático
↻ vuelve a preguntar" vertex="1">
|
||||
<mxGeometry height="50" width="140" x="530" y="920" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-37" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-36" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-38" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-36" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="650" y="935" />
|
||||
<mxPoint x="650" y="895" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-39" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
(lead_fotos · antes)" vertex="1">
|
||||
<mxGeometry height="50" width="180" x="720" y="965" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-40" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-39" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-41" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="735" y="1045" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-42" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-39" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-41">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-43" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Llamada" vertex="1">
|
||||
<mxGeometry height="20" width="150" x="1030" y="450" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-44" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;dashed=1;" value="Bot de llamada
(compañero — pendiente)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1030" y="480" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-45" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-44">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="985" y="395" />
|
||||
<mxPoint x="1115" y="395" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-46" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Llamada
completada?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="1045" y="560" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-47" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-44" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-46">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-48" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="WA de continuación
retoma desde estado_wa" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1210" y="573" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-49" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-48" value="No / cortada">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-50" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-48" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1295" y="440" />
|
||||
<mxPoint x="870" y="440" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-51" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Contactado?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="1045" y="660" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-52" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-51" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-53" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24" value="No">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1045" y="695" />
|
||||
<mxPoint x="960" y="695" />
|
||||
<mxPoint x="960" y="510" />
|
||||
<mxPoint x="860" y="510" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-54" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Enviar WA pidiendo fotos
(leads · fotos_solicitadas_at)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1030" y="765" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-55" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-54" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-56" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos
recibidas?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="1045" y="845" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-57" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-54" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-58" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático
↻ vuelve a preguntar" vertex="1">
|
||||
<mxGeometry height="50" width="150" x="1250" y="895" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-59" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-58" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-60" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-58" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1360" y="885" />
|
||||
<mxPoint x="1360" y="880" />
|
||||
<mxPoint x="1185" y="880" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-61" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
(lead_fotos · antes)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1030" y="945" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-62" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-61" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-63" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="1045" y="1025" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-64" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-61" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-63">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-65" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Todas las ramas convergen aquí" vertex="1">
|
||||
<mxGeometry height="20" width="400" x="660" y="1110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-66" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;strokeWidth=2;" value="Calificación del lead
score + nivel frío/tibio/caliente
(lead_calificacion)" vertex="1">
|
||||
<mxGeometry height="60" width="300" x="710" y="1140" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-67" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-21" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="475" y="1170" />
|
||||
<mxPoint x="710" y="1170" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-68" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-41" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="810" y="1170" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-69" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-63" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1120" y="1170" />
|
||||
<mxPoint x="1010" y="1170" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-70" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="CRM — Agente revisa lead
(leads · estado = contactado)" vertex="1">
|
||||
<mxGeometry height="50" width="300" x="710" y="1240" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-71" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-66" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-70">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-72" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Visita agendada
(visitas)" vertex="1">
|
||||
<mxGeometry height="50" width="300" x="710" y="1330" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-73" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-70" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-72">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-74" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
genera render antes/después
(lead_fotos · render_url)" vertex="1">
|
||||
<mxGeometry height="60" width="300" x="710" y="1420" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-75" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-72" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-74">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-76" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Generación presupuesto
render + PDF
(leads · pdf_url)" vertex="1">
|
||||
<mxGeometry height="60" width="300" x="710" y="1520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-77" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-74" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-76">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-78" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Envío al cliente
WhatsApp / email" vertex="1">
|
||||
<mxGeometry height="50" width="300" x="710" y="1620" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-79" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-76" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-78">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-80" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Acepta?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="790" y="1700" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-81" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-78" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-80">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-82" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead GANADO
(leads · estado = ganado)" vertex="1">
|
||||
<mxGeometry height="50" width="220" x="680" y="1810" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-83" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-82" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-84" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead PERDIDO
(leads · estado = perdido)" vertex="1">
|
||||
<mxGeometry height="50" width="220" x="970" y="1718" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-85" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-84" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-86" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Solicitar testimonio
(testimonios)" vertex="1">
|
||||
<mxGeometry height="50" width="220" x="680" y="1900" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-87" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-82" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-86">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-88" parent="1" style="text;html=1;fontSize=13;fontStyle=1;" value="Leyenda" vertex="1">
|
||||
<mxGeometry height="20" width="100" x="1300" y="1140" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-89" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Flujo principal" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1170" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-90" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Bot externo / pendiente" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1210" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-91" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1250" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-92" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead ganado / éxito" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1290" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-93" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead perdido / descartado" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1330" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
10
mvp/Whatsapp-bot/.dockerignore
Normal file
10
mvp/Whatsapp-bot/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
auth_info_baileys
|
||||
*.tsbuildinfo
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
@@ -4,5 +4,6 @@ MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5
|
||||
MODEL_REGLAS=anthropic/claude-haiku-4-5
|
||||
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
|
||||
MODEL=
|
||||
DATABASE_URL=
|
||||
ALLOWED_NUMBER=
|
||||
API_BASE_URL=http://localhost:3000
|
||||
FUNNEL_API_KEY=
|
||||
|
||||
22
mvp/Whatsapp-bot/Dockerfile
Normal file
22
mvp/Whatsapp-bot/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# Agente WhatsApp Luisa (NestJS). El proceso escucha en dos puertos:
|
||||
# - PORT (3000): app NestJS
|
||||
# - WEBHOOK_PORT (3001): servidor HTTP de webhooks entrantes (/whatsapp-start, /whatsapp-pdf)
|
||||
# La sesión de Baileys se persiste en /app/auth_info_baileys → montar un volumen ahí.
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV WEBHOOK_PORT=3001
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/prompts ./prompts
|
||||
EXPOSE 3000 3001
|
||||
CMD ["node", "dist/main"]
|
||||
@@ -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 |
|
||||
| **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** |
|
||||
| **Logging** | Pino |
|
||||
| **QR** | `qrcode-terminal` |
|
||||
@@ -30,28 +30,29 @@ Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de
|
||||
│ └── luisa_casos.md ← Casos edge y ejemplos
|
||||
├── src/
|
||||
│ ├── 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.module.ts
|
||||
│ │ ├── whatsapp.service.ts ← Conexión Baileys, recepción/envío
|
||||
│ │ └── whatsapp-debounce.service.ts ← Debounce de 3s para coalescer mensajes rápidos
|
||||
│ ├── leads/
|
||||
│ │ ├── leads.module.ts
|
||||
│ │ ├── lead.entity.ts ← Entidad Lead con todos los campos
|
||||
│ │ └── leads.service.ts ← CRUD, máquina de estados, viabilidad
|
||||
│ │ └── leads.service.ts ← Máquina de estados, viabilidad (sin BD)
|
||||
│ ├── conversacion/
|
||||
│ │ ├── conversacion.module.ts
|
||||
│ │ ├── conversacion.entity.ts ← Entidad Conversacion (historial)
|
||||
│ │ └── conversacion.service.ts ← Guardado y recuperación de historial
|
||||
│ │ └── conversacion.service.ts ← Historial via API HTTP
|
||||
│ ├── claude/
|
||||
│ │ ├── claude.module.ts
|
||||
│ │ └── claude.service.ts ← Arquitectura de 4 capas con Claude
|
||||
│ ├── media/
|
||||
│ │ ├── media.module.ts
|
||||
│ │ └── media.service.ts ← Transcripción de audio + análisis de imagen
|
||||
│ └── scheduler/
|
||||
│ ├── scheduler.module.ts
|
||||
│ └── scheduler.service.ts ← Cron cada 5 min: apertura a leads nuevos + limpieza
|
||||
│ └── webhook/
|
||||
│ ├── webhook.module.ts
|
||||
│ └── webhook-listener.ts ← Servidor HTTP para recibir señales de la app
|
||||
├── .env.example
|
||||
├── nest-cli.json
|
||||
├── package.json
|
||||
@@ -70,68 +71,40 @@ Mensaje entrante (texto / audio / imagen)
|
||||
↓
|
||||
┌───────────────────────────────┐
|
||||
│ PREPROCESAMIENTO │
|
||||
│ • Identificar lead por teléfono│
|
||||
│ • Verificar lead en sesión │
|
||||
│ (llega via webhook, no por │
|
||||
│ teléfono) │
|
||||
│ • Si audio → transcripción │
|
||||
│ (Gemini 2.5 Flash via │
|
||||
│ OpenRouter) │
|
||||
│ • Si imagen → Vision │
|
||||
│ (Claude Sonnet via │
|
||||
│ OpenRouter) │
|
||||
│ OpenRouter) + enviar a │
|
||||
│ /ingesta │
|
||||
│ • Si texto → directo │
|
||||
│ • Guardar mensaje usuario en │
|
||||
│ DB │
|
||||
│ • Guardar mensaje en │
|
||||
│ /conversacion (API HTTP) │
|
||||
└───────────┬───────────────────┘
|
||||
↓
|
||||
┌───────────────────────────────┐
|
||||
│ 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) │
|
||||
│ 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) │
|
||||
│ Construye contexto y genera │
|
||||
│ borrador de respuesta │
|
||||
│ • Estado del lead │
|
||||
│ • Datos capturados │
|
||||
│ • Historial completo │
|
||||
│ • Clasificación y validación │
|
||||
│ • Reintentos │
|
||||
└───────────┬───────────────────┘
|
||||
↓
|
||||
┌───────────────────────────────┐
|
||||
│ 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
|
||||
```
|
||||
@@ -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_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=anthropic/claude-sonnet-4-5 # Fallback general si no se especifica otro
|
||||
DATABASE_URL= # (REQUERIDA) PostgreSQL connection string
|
||||
MODEL=anthropic/claude-sonnet-4-5 # Fallback general
|
||||
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
|
||||
```
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
- Si `MODEL` está definido, sirve como fallback para `MODEL_GENERADOR` cuando no se especifica.
|
||||
- `API_BASE_URL` + `FUNNEL_API_KEY` reemplazan a la antigua `DATABASE_URL`. El bot ya no escribe a Postgres directamente.
|
||||
- `WEBHOOK_PORT` define dónde escucha el servidor HTTP para recibir señales de la app (`/whatsapp-start`, `/whatsapp-pdf`).
|
||||
- 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
|
||||
```
|
||||
|
||||
Edita `.env` con tus valores reales:
|
||||
Edita `.env` con tus valores reales.
|
||||
|
||||
```env
|
||||
OPENROUTER_API_KEY=sk-or-v1-tu-api-key-aqui
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/reformix
|
||||
```
|
||||
### 2. Prompts de Luisa
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
### 3. Arrancar
|
||||
|
||||
```bash
|
||||
npm install
|
||||
@@ -205,7 +158,7 @@ npm run start:dev
|
||||
|
||||
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 |
|
||||
|--------|-------------|
|
||||
| `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 |
|
||||
| `espacio` | Pregunta: ¿qué espacio quieres reformar? |
|
||||
| `tamano` | Pregunta: ¿rango de metros cuadrados? |
|
||||
| `estilo` | Pregunta: ¿tipo de acabado? |
|
||||
| `urgencia` | Pregunta: ¿cuándo quieres empezar? |
|
||||
| `presupuesto` | Pregunta: ¿presupuesto aproximado? |
|
||||
| `fin_viable` | Transición interna → `completado` si presupuesto >= 5000€ |
|
||||
| `fin_no_viable` | Transición interna → `no_viable` si 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`
|
||||
| `fin_viable` | Lead viable (presupuesto >= 5000€) |
|
||||
| `fin_no_viable` | Lead no viable (presupuesto < 5000€) |
|
||||
|
||||
### Datos recolectados por estado
|
||||
|
||||
| Estado | Campo DB | Valores válidos |
|
||||
|--------|----------|-----------------|
|
||||
| `espacio` | `espacio` | `cocina`, `bano`, `salon`, `integral`, `otro` |
|
||||
| `tamano` | `rango_m2` | `menos10`, `10a20`, `20a40`, `mas40` |
|
||||
| Estado | Campo perfil | Valores válidos |
|
||||
|--------|-------------|-----------------|
|
||||
| `espacio` | `espacio` | `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro` |
|
||||
| `tamano` | `rangoM2` | `menos10`, `10a20`, `20a40`, `mas40` |
|
||||
| `estilo` | `estilo` | `funcional`, `cuidado`, `exclusivo` |
|
||||
| `urgencia` | `urgencia` | `urgente`, `medio_plazo`, `frio` |
|
||||
| `presupuesto` | `presupuesto_declarado` | 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.
|
||||
| `urgencia` | `urgencia` | `alta`, `media`, `baja` |
|
||||
| `presupuesto` | `presupuestoDeclarado` | Cifra o rango en euros |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
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.
|
||||
## Flujo de webhooks
|
||||
|
||||
| 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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -272,27 +229,22 @@ Si se define `ALLOWED_NUMBER` en `.env`, el bot **ignora cualquier mensaje que n
|
||||
### Audio
|
||||
- Se descarga el buffer del mensaje de audio via Baileys.
|
||||
- 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.
|
||||
- Si falla o el buffer es muy pequeño, se responde con un mensaje de cortesía pidiendo repetición.
|
||||
|
||||
### Imagen
|
||||
- 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:
|
||||
- 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.
|
||||
- Además se envía a `/ingesta` de la app Reformix para persistirla.
|
||||
- Si la imagen tiene caption, se combina con la inferencia.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- 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`; si falla, se loguea el error y se continúa con el siguiente mensaje.
|
||||
- Si Claude falla repetidamente al clasificar, se usa un fallback conservador (desvío) para no bloquear la conversación.
|
||||
- Si la respuesta generada contiene frases prohibidas ("soy un asistente", "ChatGPT", etc.), se reemplaza automáticamente con un `mensajeFallback` según el estado actual.
|
||||
- Reconexión automática a WhatsApp tras 5 segundos (excepto logout).
|
||||
- Cada mensaje se procesa en un bloque `try/catch`.
|
||||
- Si Claude falla al clasificar, se usa un fallback conservador.
|
||||
- Las llamadas a la API de Reformix son **best-effort** (nunca lanzan error, loguean y continúan).
|
||||
|
||||
---
|
||||
|
||||
@@ -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 start # Iniciar en producción
|
||||
npm run start:dev # Iniciar en desarrollo con watch
|
||||
npm run start:debug # Iniciar en modo debug
|
||||
npm run lint # Ejecutar ESLint
|
||||
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
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Node.js >= 20
|
||||
- PostgreSQL >= 14
|
||||
- Cuenta en [OpenRouter](https://openrouter.ai) con API key
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
npm run test:watch # Modo watch
|
||||
npm run test:cov # Con cobertura
|
||||
```
|
||||
- App Reformix corriendo con `FUNNEL_API_KEY` configurada
|
||||
|
||||
---
|
||||
|
||||
Desarrollado para Reformix © 2025
|
||||
Desarrollado para Reformix © 2026
|
||||
|
||||
3542
mvp/Whatsapp-bot/package-lock.json
generated
3542
mvp/Whatsapp-bot/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reformix-luisa-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Agente WhatsApp Luisa para Reformix – cualificacion de leads de reforma",
|
||||
"description": "Agente WhatsApp Luisa para Reformix – cualificacion de leads de reforma via API HTTP",
|
||||
"author": "Reformix",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
@@ -15,28 +15,20 @@
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts"
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc10",
|
||||
"axios": "^1.7.0",
|
||||
"baileys-antiban": "^3.9.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"form-data": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
"pino": "^9.3.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
@@ -56,16 +48,10 @@
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
|
||||
137
mvp/Whatsapp-bot/src/api/api-client.service.ts
Normal file
137
mvp/Whatsapp-bot/src/api/api-client.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface LeadState {
|
||||
id: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
botStep: string;
|
||||
estadoWa: string;
|
||||
espacio: string;
|
||||
rangoM2: string;
|
||||
estilo: string;
|
||||
presupuestoDeclarado: string;
|
||||
viable: boolean | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiClient {
|
||||
private readonly logger = new Logger(ApiClient.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
||||
this.apiKey = process.env.FUNNEL_API_KEY || '';
|
||||
}
|
||||
|
||||
private get headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async getLead(leadId: string): Promise<LeadState | null> {
|
||||
try {
|
||||
const { data } = await axios.get(`${this.baseUrl}/api/leads/${leadId}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) return null;
|
||||
this.logger.error(`Error fetching lead ${leadId}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async guardarConversacion(
|
||||
leadId: string,
|
||||
rol: 'user' | 'assistant' | 'system',
|
||||
mensaje: string,
|
||||
options?: { estadoWa?: string; botStep?: string; mediaType?: string; mediaUrl?: string; transcripcionAudio?: string },
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/conversacion`, {
|
||||
rol,
|
||||
mensaje,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async actualizarPerfil(
|
||||
leadId: string,
|
||||
datos: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/perfil`, datos);
|
||||
}
|
||||
|
||||
async obtenerHistorial(leadId: string): Promise<Array<{ role: string; content: string }>> {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${this.baseUrl}/api/leads/${leadId}/conversacion`,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((m: any) => ({ role: m.rol || m.role, content: m.mensaje || m.content }));
|
||||
}
|
||||
return [];
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) return [];
|
||||
this.logger.error(`Error fetching historial for ${leadId}: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async calificarLead(
|
||||
leadId: string,
|
||||
score: number,
|
||||
nivel: 'A' | 'B' | 'C' | 'D',
|
||||
criterios?: Record<string, unknown>,
|
||||
notasAgente?: string,
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/calificacion`, {
|
||||
score,
|
||||
nivel,
|
||||
criterios,
|
||||
notasAgente,
|
||||
});
|
||||
}
|
||||
|
||||
async registrarIntento(
|
||||
leadId: string,
|
||||
canal: string,
|
||||
numeroIntento: number,
|
||||
resultado?: string,
|
||||
completado?: boolean,
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/intento`, {
|
||||
canal,
|
||||
numeroIntento,
|
||||
resultado,
|
||||
completado,
|
||||
});
|
||||
}
|
||||
|
||||
async enviarIngesta(
|
||||
leadId: string,
|
||||
items: Array<Record<string, unknown>>,
|
||||
flags?: { perfilCompleto?: boolean; finalizar?: boolean },
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/ingesta`, {
|
||||
items,
|
||||
...flags,
|
||||
});
|
||||
}
|
||||
|
||||
private async post(path: string, body: unknown): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await axios.post(`${this.baseUrl}${path}`, body, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return status === 200;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`POST ${path} error: ${err.response?.status} ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
mvp/Whatsapp-bot/src/api/api.module.ts
Normal file
9
mvp/Whatsapp-bot/src/api/api.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ApiClient } from './api-client.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ApiClient],
|
||||
exports: [ApiClient],
|
||||
})
|
||||
export class ApiModule {}
|
||||
@@ -1,34 +1,22 @@
|
||||
import 'dotenv/config';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ApiModule } from './api/api.module';
|
||||
import { LeadsModule } from './leads/leads.module';
|
||||
import { ConversacionModule } from './conversacion/conversacion.module';
|
||||
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
||||
import { ClaudeModule } from './claude/claude.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
||||
import { Lead } from './leads/lead.entity';
|
||||
import { Conversacion } from './conversacion/conversacion.entity';
|
||||
import { WebhookModule } from './webhook/webhook.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL,
|
||||
entities: [Lead, Conversacion],
|
||||
synchronize: true, // En produccion usar migrations en lugar de synchronize
|
||||
ssl: process.env.DATABASE_URL?.includes('sslmode=require')
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
}),
|
||||
ApiModule,
|
||||
LeadsModule,
|
||||
ConversacionModule,
|
||||
WhatsappModule,
|
||||
ClaudeModule,
|
||||
MediaModule,
|
||||
SchedulerModule,
|
||||
WebhookModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
import { Lead } from '../leads/lead.entity';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT =
|
||||
@@ -34,9 +33,23 @@ export interface ValidacionResultado {
|
||||
viable?: boolean;
|
||||
}
|
||||
|
||||
export interface LeadBasico {
|
||||
id: string;
|
||||
telefono: string;
|
||||
nombre: string;
|
||||
estado_actual: string;
|
||||
espacio: string | null;
|
||||
rango_m2: string | null;
|
||||
estilo: string | null;
|
||||
urgencia: string | null;
|
||||
presupuesto_declarado: string | null;
|
||||
viable: boolean | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeResponse {
|
||||
respuesta: string;
|
||||
entidad?: Partial<Lead>;
|
||||
entidad?: Partial<LeadBasico>;
|
||||
viable?: boolean;
|
||||
nuevoEstado?: string;
|
||||
}
|
||||
@@ -47,73 +60,39 @@ export class ClaudeService implements OnModuleInit {
|
||||
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
||||
private systemPromptCache = '';
|
||||
private reglasPromptCache = '';
|
||||
private readonly reintentosPorLead = new Map<
|
||||
string,
|
||||
{ estado: string; count: number }
|
||||
>();
|
||||
private readonly reintentosPorLead = new Map<string, { estado: string; count: number }>();
|
||||
|
||||
constructor(private readonly leadsService: LeadsService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.systemPromptCache = this.cargarPrompts([
|
||||
'luisa_core.md',
|
||||
'luisa_flujo.md',
|
||||
'luisa_casos.md',
|
||||
]);
|
||||
this.systemPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md']);
|
||||
this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
|
||||
this.logger.log(
|
||||
`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`,
|
||||
);
|
||||
this.logger.log(`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`);
|
||||
}
|
||||
|
||||
private cargarPrompts(archivos: string[]): string {
|
||||
const partes: string[] = [];
|
||||
|
||||
for (const archivo of archivos) {
|
||||
const rutaCompleta = path.join(this.promptsDir, archivo);
|
||||
try {
|
||||
if (!fs.existsSync(rutaCompleta)) {
|
||||
this.logger.warn(`Prompt no encontrado: ${archivo}`);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(rutaCompleta)) { this.logger.warn(`Prompt no encontrado: ${archivo}`); continue; }
|
||||
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
|
||||
if (contenido.trim()) {
|
||||
partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||
if (contenido.trim()) partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||
} catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); }
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
|
||||
}
|
||||
}
|
||||
|
||||
const concatenado = partes.join('\n').trim();
|
||||
return concatenado || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
private leerPromptsSistema(): string {
|
||||
return this.systemPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
private leerPromptsReglas(): string {
|
||||
return this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||
return partes.join('\n').trim() || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string {
|
||||
const defaults = {
|
||||
clasificador: 'anthropic/claude-haiku-4-5',
|
||||
generador: 'anthropic/claude-sonnet-4-5',
|
||||
reglas: 'anthropic/claude-haiku-4-5',
|
||||
};
|
||||
|
||||
const envMap = {
|
||||
const envMap: Record<string, string | undefined> = {
|
||||
clasificador: process.env.MODEL_CLASIFICADOR,
|
||||
generador: process.env.MODEL_GENERADOR || process.env.MODEL,
|
||||
reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR,
|
||||
};
|
||||
|
||||
return envMap[clave] || defaults[clave];
|
||||
return envMap[clave] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5');
|
||||
}
|
||||
|
||||
private serializarLead(lead: Lead): string {
|
||||
private serializarLead(lead: LeadBasico): string {
|
||||
return [
|
||||
`- ID: ${lead.id}`,
|
||||
`- Telefono: ${lead.telefono}`,
|
||||
@@ -129,10 +108,6 @@ export class ClaudeService implements OnModuleInit {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter requiere system dentro de messages[] para modelos OpenAI.
|
||||
* El campo system en la raiz del payload no siempre se aplica.
|
||||
*/
|
||||
private async llamarOpenRouter(
|
||||
model: string,
|
||||
system: string,
|
||||
@@ -140,95 +115,47 @@ export class ClaudeService implements OnModuleInit {
|
||||
options: { temperature?: number; jsonMode?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const { temperature = 0.7, jsonMode = false } = options;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages: [{ role: 'system', content: system }, ...messages],
|
||||
max_tokens: 1024,
|
||||
temperature,
|
||||
};
|
||||
if (jsonMode) payload.response_format = { type: 'json_object' };
|
||||
|
||||
if (jsonMode) {
|
||||
payload.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
payload,
|
||||
{
|
||||
const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Luisa Bot',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const contenido = response.data.choices?.[0]?.message?.content || '';
|
||||
const modeloUsado = response.data.model || model;
|
||||
|
||||
if (!contenido.trim()) {
|
||||
this.logger.warn(
|
||||
`OpenRouter devolvio contenido vacio (modelo=${modeloUsado})`,
|
||||
);
|
||||
}
|
||||
|
||||
return contenido;
|
||||
});
|
||||
return response.data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
private parsearJson<T>(texto: string): T | null {
|
||||
const limpio = texto
|
||||
.replace(/```json\s*/gi, '')
|
||||
.replace(/```\s*/g, '')
|
||||
.trim();
|
||||
|
||||
const limpio = texto.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
|
||||
const inicio = limpio.indexOf('{');
|
||||
const fin = limpio.lastIndexOf('}');
|
||||
if (inicio === -1 || fin === -1) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(limpio.slice(inicio, fin + 1)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try { return JSON.parse(limpio.slice(inicio, fin + 1)) as T; } catch { return null; }
|
||||
}
|
||||
|
||||
private normalizarClasificacion(
|
||||
raw: Partial<ClasificacionResultado>,
|
||||
): ClasificacionResultado | null {
|
||||
const intenciones = [
|
||||
'respuesta',
|
||||
'desvio',
|
||||
'despedida',
|
||||
'insulto',
|
||||
'pregunta',
|
||||
] as const;
|
||||
|
||||
private normalizarClasificacion(raw: Partial<ClasificacionResultado>): ClasificacionResultado | null {
|
||||
const intenciones = ['respuesta', 'desvio', 'despedida', 'insulto', 'pregunta'] as const;
|
||||
if (!raw || typeof raw.responde_pregunta !== 'boolean') return null;
|
||||
|
||||
const intencion = intenciones.includes(raw.intencion as typeof intenciones[number])
|
||||
? (raw.intencion as ClasificacionResultado['intencion'])
|
||||
: 'respuesta';
|
||||
|
||||
? (raw.intencion as ClasificacionResultado['intencion']) : 'respuesta';
|
||||
return {
|
||||
responde_pregunta: raw.responde_pregunta,
|
||||
valor_extraido:
|
||||
raw.valor_extraido === null || raw.valor_extraido === undefined
|
||||
? null
|
||||
: String(raw.valor_extraido),
|
||||
valor_extraido: raw.valor_extraido === null || raw.valor_extraido === undefined ? null : String(raw.valor_extraido),
|
||||
es_desvio: Boolean(raw.es_desvio),
|
||||
intencion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 1 — Clasificador (Haiku): extrae intencion y valor del mensaje.
|
||||
*/
|
||||
private async clasificar(
|
||||
mensaje: string,
|
||||
estadoActual: string,
|
||||
): Promise<ClasificacionResultado> {
|
||||
private async clasificar(mensaje: string, estadoActual: string): Promise<ClasificacionResultado> {
|
||||
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
|
||||
const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas.
|
||||
Responde UNICAMENTE con un objeto JSON valido. Sin markdown, sin texto antes ni despues.
|
||||
@@ -247,132 +174,63 @@ Formato exacto:
|
||||
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
|
||||
|
||||
Reglas para valor_extraido:
|
||||
- espacio: cocina, bano, salon, integral, otro
|
||||
- espacio: cocina, bano, salon, comedor, integral, otro
|
||||
- tamano: menos10, 10a20, 20a40, mas40
|
||||
- estilo: funcional, cuidado, exclusivo
|
||||
- urgencia: urgente, medio_plazo, frio
|
||||
- urgencia: alta, media, baja
|
||||
- presupuesto: numero o rango en euros tal como lo dijo el usuario
|
||||
- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona
|
||||
- Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false
|
||||
- Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true
|
||||
- Usuarios de Madrid/Espana: interpreta coloquialismos, jerga y dialecto peninsular (vale, tio, mola, guay, etc.) como respuesta valida si aportan el dato del estado
|
||||
- Extrae el valor semantico aunque venga en lenguaje coloquial ("pa la cocina" -> espacio cocina, "unos 15 mil" -> presupuesto)`;
|
||||
|
||||
const intentos = [
|
||||
{ jsonMode: true, temperature: 0.1 },
|
||||
{ jsonMode: true, temperature: 0 },
|
||||
];
|
||||
- Extrae el valor semantico aunque venga en lenguaje coloquial`;
|
||||
|
||||
const intentos = [{ jsonMode: true, temperature: 0.1 }, { jsonMode: true, temperature: 0 }];
|
||||
for (const opts of intentos) {
|
||||
const contenido = await this.llamarOpenRouter(
|
||||
this.getModelo('clasificador'),
|
||||
system,
|
||||
[{ role: 'user', content: mensaje }],
|
||||
opts,
|
||||
);
|
||||
|
||||
const parsed = this.normalizarClasificacion(
|
||||
this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {},
|
||||
);
|
||||
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('clasificador'), system, [{ role: 'user', content: mensaje }], opts);
|
||||
const parsed = this.normalizarClasificacion(this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {});
|
||||
if (parsed) return parsed;
|
||||
|
||||
this.logger.warn(
|
||||
`Clasificador JSON invalido (intento, modelo=${this.getModelo('clasificador')}): ${contenido.slice(0, 200)}`,
|
||||
);
|
||||
this.logger.warn(`Clasificador JSON invalido (intento): ${contenido.slice(0, 200)}`);
|
||||
}
|
||||
return { responde_pregunta: false, valor_extraido: null, es_desvio: true, intencion: 'desvio' };
|
||||
}
|
||||
|
||||
this.logger.warn('Clasificador agotado reintentos, usando fallback conservador');
|
||||
return {
|
||||
responde_pregunta: false,
|
||||
valor_extraido: null,
|
||||
es_desvio: true,
|
||||
intencion: 'desvio',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 2 — Validador en codigo: valida valor_extraido contra valores permitidos.
|
||||
*/
|
||||
private validar(
|
||||
clasificacion: ClasificacionResultado,
|
||||
estadoActual: string,
|
||||
): ValidacionResultado {
|
||||
private validar(clasificacion: ClasificacionResultado, estadoActual: string): ValidacionResultado {
|
||||
const estado = this.leadsService.normalizarEstadoFlujo(estadoActual);
|
||||
|
||||
if (
|
||||
clasificacion.es_desvio ||
|
||||
clasificacion.intencion === 'desvio' ||
|
||||
clasificacion.intencion === 'pregunta' ||
|
||||
clasificacion.intencion === 'insulto' ||
|
||||
clasificacion.intencion === 'despedida'
|
||||
) {
|
||||
if (clasificacion.es_desvio || clasificacion.intencion === 'desvio' || clasificacion.intencion === 'pregunta' ||
|
||||
clasificacion.intencion === 'insulto' || clasificacion.intencion === 'despedida') {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
if (estado === 'nuevo') {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
if (estado === 'nuevo') return { valido: false, valorNormalizado: null };
|
||||
if (estado === 'apertura') {
|
||||
const valido =
|
||||
clasificacion.responde_pregunta &&
|
||||
clasificacion.intencion === 'respuesta' &&
|
||||
!clasificacion.es_desvio;
|
||||
return { valido, valorNormalizado: clasificacion.valor_extraido };
|
||||
return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, valorNormalizado: clasificacion.valor_extraido };
|
||||
}
|
||||
|
||||
if (estado === 'presupuesto') {
|
||||
const valor = clasificacion.valor_extraido?.trim();
|
||||
if (!valor || !this.leadsService.esPresupuestoValido(valor)) {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null };
|
||||
return { valido: true, valorNormalizado: valor, viable: this.leadsService.evaluarViabilidad(valor) };
|
||||
}
|
||||
const viable = this.leadsService.evaluarViabilidad(valor);
|
||||
return { valido: true, valorNormalizado: valor, viable };
|
||||
}
|
||||
|
||||
const valoresPermitidos = this.leadsService.getValoresPermitidos(estado);
|
||||
const valor = this.normalizarTexto(clasificacion.valor_extraido ?? '');
|
||||
|
||||
if (!valor) {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
const coincide = valoresPermitidos.some(
|
||||
(v) => v === valor || valor.includes(v) || v.includes(valor),
|
||||
);
|
||||
|
||||
if (!coincide) {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
const valorNormalizado =
|
||||
valoresPermitidos.find(
|
||||
(v) => v === valor || valor.includes(v) || v.includes(valor),
|
||||
) ?? valor;
|
||||
|
||||
if (!valor) return { valido: false, valorNormalizado: null };
|
||||
const coincide = valoresPermitidos.some((v) => v === valor || valor.includes(v) || v.includes(valor));
|
||||
if (!coincide) return { valido: false, valorNormalizado: null };
|
||||
const valorNormalizado = valoresPermitidos.find((v) => v === valor || valor.includes(v) || v.includes(valor)) ?? valor;
|
||||
return { valido: true, valorNormalizado };
|
||||
}
|
||||
|
||||
private normalizarTexto(valor: string): string {
|
||||
return valor
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '');
|
||||
return valor.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
private claveReintento(leadId: number, estado: string): string {
|
||||
return `${leadId}:${estado}`;
|
||||
}
|
||||
private claveReintento(leadId: string, estado: string): string { return `${leadId}:${estado}`; }
|
||||
|
||||
private obtenerReintentos(leadId: number, estado: string): number {
|
||||
const clave = this.claveReintento(leadId, estado);
|
||||
const entry = this.reintentosPorLead.get(clave);
|
||||
private obtenerReintentos(leadId: string, estado: string): number {
|
||||
const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado));
|
||||
return entry?.estado === estado ? entry.count : 0;
|
||||
}
|
||||
|
||||
private incrementarReintentos(leadId: number, estado: string): number {
|
||||
private incrementarReintentos(leadId: string, estado: string): number {
|
||||
const clave = this.claveReintento(leadId, estado);
|
||||
const actual = this.obtenerReintentos(leadId, estado);
|
||||
const count = actual + 1;
|
||||
@@ -380,15 +238,12 @@ Reglas para valor_extraido:
|
||||
return count;
|
||||
}
|
||||
|
||||
private resetearReintentos(leadId: number, estado: string): void {
|
||||
private resetearReintentos(leadId: string, estado: string): void {
|
||||
this.reintentosPorLead.delete(this.claveReintento(leadId, estado));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 3 — Generador (Sonnet): produce el borrador del mensaje de Luisa.
|
||||
*/
|
||||
private async generar(
|
||||
lead: Lead,
|
||||
lead: LeadBasico,
|
||||
historial: Array<{ role: string; content: string }>,
|
||||
mensajeActual: string,
|
||||
clasificacion: ClasificacionResultado,
|
||||
@@ -398,11 +253,7 @@ Reglas para valor_extraido:
|
||||
siguienteEstado: string | null,
|
||||
forzarApertura = false,
|
||||
): Promise<string> {
|
||||
const systemPrompt = this.leerPromptsSistema();
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
||||
lead.estado_actual,
|
||||
);
|
||||
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||
const contextoGeneracion = `
|
||||
## Contexto del lead
|
||||
${this.serializarLead(lead)}
|
||||
@@ -419,11 +270,10 @@ ${this.serializarLead(lead)}
|
||||
|
||||
## Instrucciones de respuesta
|
||||
Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra.
|
||||
Habla espanol de Espana; suena natural y cercana. Adapta el registro al usuario (coloquial si el, formal si el).
|
||||
Habla espanol de Espana; suena natural y cercana.
|
||||
Devuelve SOLO el texto del mensaje a enviar por WhatsApp.
|
||||
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
|
||||
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
|
||||
Si preguntan tu nombre, di que eres Luisa de Reformix.
|
||||
|
||||
Si forzar mensaje de apertura es true, envia el mensaje de APERTURA del flujo.
|
||||
Si validacion valida es false y reintentos < 2, pide amablemente que aclare su respuesta.
|
||||
@@ -432,32 +282,16 @@ Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y r
|
||||
Si avanzar estado es true, haz la pregunta correspondiente al siguiente estado.
|
||||
Si el siguiente estado es fin_viable o fin_no_viable, usa el mensaje de cierre correspondiente.`;
|
||||
|
||||
const messages = [
|
||||
...historial,
|
||||
{ role: 'user', content: mensajeActual },
|
||||
];
|
||||
|
||||
const contenido = await this.llamarOpenRouter(
|
||||
this.getModelo('generador'),
|
||||
`${systemPrompt}\n${contextoGeneracion}`,
|
||||
messages,
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
|
||||
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
|
||||
[...historial, { role: 'user', content: mensajeActual }],
|
||||
{ temperature: 0.7 },
|
||||
);
|
||||
|
||||
return contenido.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 4 — Reglas (Haiku): corrige el borrador para cumplir identidad y tono de Luisa.
|
||||
*/
|
||||
private async aplicarReglas(
|
||||
borrador: string,
|
||||
lead: Lead,
|
||||
estadoFlujo: string,
|
||||
clasificacion: ClasificacionResultado,
|
||||
): Promise<string> {
|
||||
const reglas = this.leerPromptsReglas();
|
||||
|
||||
private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise<string> {
|
||||
const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||
const system = `${reglas}
|
||||
|
||||
## Tu tarea
|
||||
@@ -471,19 +305,16 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
|
||||
## Reglas de correccion obligatorias
|
||||
- Debe sonar como Luisa de Reformix (Madrid), nunca como un asistente generico
|
||||
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja con el tono del usuario
|
||||
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja
|
||||
- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos
|
||||
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
||||
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
||||
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
|
||||
|
||||
const contenido = await this.llamarOpenRouter(
|
||||
this.getModelo('reglas'),
|
||||
system,
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
|
||||
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
|
||||
{ temperature: 0.3 },
|
||||
);
|
||||
|
||||
return contenido.trim() || borrador;
|
||||
}
|
||||
|
||||
@@ -491,7 +322,7 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
|
||||
}
|
||||
|
||||
private mensajeFallback(estadoFlujo: string, lead: Lead): string {
|
||||
private mensajeFallback(estadoFlujo: string, lead: LeadBasico): string {
|
||||
const nombre = lead.nombre ? lead.nombre : '';
|
||||
const fallbacks: Record<string, string> = {
|
||||
nuevo: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
|
||||
@@ -502,91 +333,23 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
urgencia: 'Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?',
|
||||
presupuesto: 'Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?',
|
||||
};
|
||||
|
||||
return (
|
||||
fallbacks[estadoFlujo] ??
|
||||
'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.'
|
||||
);
|
||||
return fallbacks[estadoFlujo] ?? 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Orquesta las 4 capas: clasificar, validar, generar y aplicar reglas.
|
||||
*/
|
||||
async llamarClaude(
|
||||
lead: Lead,
|
||||
lead: LeadBasico,
|
||||
historial: Array<{ role: string; content: string }>,
|
||||
mensajeActual: string,
|
||||
): Promise<ClaudeResponse> {
|
||||
const esAperturaScheduler =
|
||||
historial.length === 0 && mensajeActual.startsWith('APERTURA:');
|
||||
|
||||
if (esAperturaScheduler) {
|
||||
const borrador = await this.generar(
|
||||
lead,
|
||||
historial,
|
||||
mensajeActual,
|
||||
{
|
||||
responde_pregunta: true,
|
||||
valor_extraido: null,
|
||||
es_desvio: false,
|
||||
intencion: 'respuesta',
|
||||
},
|
||||
{ valido: true, valorNormalizado: null },
|
||||
0,
|
||||
false,
|
||||
'apertura',
|
||||
true,
|
||||
);
|
||||
const respuesta = await this.aplicarReglas(
|
||||
borrador,
|
||||
lead,
|
||||
'apertura',
|
||||
{
|
||||
responde_pregunta: true,
|
||||
valor_extraido: null,
|
||||
es_desvio: false,
|
||||
intencion: 'respuesta',
|
||||
},
|
||||
);
|
||||
return {
|
||||
respuesta: this.contieneFraseProhibida(respuesta)
|
||||
? this.mensajeFallback('apertura', lead)
|
||||
: respuesta,
|
||||
};
|
||||
}
|
||||
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
||||
lead.estado_actual,
|
||||
);
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||
|
||||
if (estadoFlujo === 'nuevo') {
|
||||
const clasificacion: ClasificacionResultado = {
|
||||
responde_pregunta: false,
|
||||
valor_extraido: null,
|
||||
es_desvio: false,
|
||||
intencion: 'respuesta',
|
||||
};
|
||||
const borrador = await this.generar(
|
||||
lead,
|
||||
historial,
|
||||
mensajeActual,
|
||||
clasificacion,
|
||||
{ valido: false, valorNormalizado: null },
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
);
|
||||
const respuesta = await this.aplicarReglas(
|
||||
borrador,
|
||||
lead,
|
||||
'nuevo',
|
||||
clasificacion,
|
||||
);
|
||||
const clasificacion: ClasificacionResultado = { responde_pregunta: false, valor_extraido: null, es_desvio: false, intencion: 'respuesta' };
|
||||
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion,
|
||||
{ valido: false, valorNormalizado: null }, 0, false, null, true);
|
||||
const respuesta = await this.aplicarReglas(borrador, lead, 'nuevo', clasificacion);
|
||||
return {
|
||||
respuesta: this.contieneFraseProhibida(respuesta)
|
||||
? this.mensajeFallback('nuevo', lead)
|
||||
: respuesta,
|
||||
respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta,
|
||||
nuevoEstado: 'apertura',
|
||||
};
|
||||
}
|
||||
@@ -597,72 +360,38 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
|
||||
let avanzarEstado = false;
|
||||
let siguienteEstado: string | null = null;
|
||||
let entidad: Partial<Lead> = {};
|
||||
let entidad: Partial<LeadBasico> = {};
|
||||
let viable: boolean | undefined;
|
||||
|
||||
const puedeAvanzar =
|
||||
validacion.valido &&
|
||||
!clasificacion.es_desvio &&
|
||||
clasificacion.intencion === 'respuesta';
|
||||
const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta';
|
||||
|
||||
if (puedeAvanzar) {
|
||||
avanzarEstado = true;
|
||||
this.resetearReintentos(lead.id, estadoFlujo);
|
||||
|
||||
if (validacion.valorNormalizado) {
|
||||
const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
|
||||
if (campo) {
|
||||
entidad = { [campo]: validacion.valorNormalizado };
|
||||
} else if (
|
||||
estadoFlujo === 'apertura' &&
|
||||
clasificacion.valor_extraido?.trim()
|
||||
) {
|
||||
entidad = { nombre: clasificacion.valor_extraido.trim() };
|
||||
(entidad as any)[campo] = validacion.valorNormalizado;
|
||||
} else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) {
|
||||
entidad.nombre = clasificacion.valor_extraido.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (estadoFlujo === 'presupuesto') {
|
||||
viable = validacion.viable;
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(
|
||||
estadoFlujo,
|
||||
viable,
|
||||
);
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo, viable);
|
||||
} else {
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
||||
}
|
||||
} else if (
|
||||
!validacion.valido &&
|
||||
clasificacion.responde_pregunta &&
|
||||
!clasificacion.es_desvio
|
||||
) {
|
||||
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
|
||||
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
||||
if (reintentos > 2) {
|
||||
reintentos = 2;
|
||||
}
|
||||
if (reintentos > 2) reintentos = 2;
|
||||
}
|
||||
|
||||
const borrador = await this.generar(
|
||||
lead,
|
||||
historial,
|
||||
mensajeActual,
|
||||
clasificacion,
|
||||
validacion,
|
||||
reintentos,
|
||||
avanzarEstado,
|
||||
siguienteEstado,
|
||||
);
|
||||
|
||||
let respuesta = await this.aplicarReglas(
|
||||
borrador,
|
||||
lead,
|
||||
estadoFlujo,
|
||||
clasificacion,
|
||||
);
|
||||
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion, validacion, reintentos, avanzarEstado, siguienteEstado);
|
||||
let respuesta = await this.aplicarReglas(borrador, lead, estadoFlujo, clasificacion);
|
||||
|
||||
if (this.contieneFraseProhibida(respuesta)) {
|
||||
this.logger.warn(
|
||||
`Respuesta final viola reglas de identidad, usando fallback para estado=${estadoFlujo}`,
|
||||
);
|
||||
this.logger.warn(`Respuesta final viola reglas, usando fallback para estado=${estadoFlujo}`);
|
||||
respuesta = this.mensajeFallback(estadoFlujo, lead);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Lead } from '../leads/lead.entity';
|
||||
|
||||
export type RolMensaje = 'user' | 'assistant' | 'system';
|
||||
|
||||
@Entity('conversacion')
|
||||
export class Conversacion {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
lead_id: number;
|
||||
|
||||
@ManyToOne(() => Lead, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'lead_id' })
|
||||
lead: Lead;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
rol: RolMensaje;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
mensaje: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Conversacion } from './conversacion.entity';
|
||||
import { ConversacionService } from './conversacion.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Conversacion])],
|
||||
providers: [ConversacionService],
|
||||
exports: [ConversacionService],
|
||||
})
|
||||
|
||||
@@ -1,41 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Conversacion, RolMensaje } from './conversacion.entity';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class ConversacionService {
|
||||
constructor(
|
||||
@InjectRepository(Conversacion)
|
||||
private readonly convRepo: Repository<Conversacion>,
|
||||
) {}
|
||||
private readonly logger = new Logger(ConversacionService.name);
|
||||
|
||||
constructor(private readonly api: ApiClient) {}
|
||||
|
||||
async guardarMensaje(
|
||||
leadId: number,
|
||||
rol: RolMensaje,
|
||||
leadId: string,
|
||||
rol: 'user' | 'assistant' | 'system',
|
||||
mensaje: string,
|
||||
): Promise<Conversacion> {
|
||||
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
|
||||
return this.convRepo.save(entry);
|
||||
options?: { estadoWa?: string; botStep?: string },
|
||||
): Promise<boolean> {
|
||||
const ok = await this.api.guardarConversacion(leadId, rol, mensaje, options);
|
||||
if (!ok) {
|
||||
this.logger.warn(`No se pudo guardar mensaje ${rol} para lead ${leadId}`);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async obtenerHistorial(leadId: number): Promise<Conversacion[]> {
|
||||
return this.convRepo.find({
|
||||
where: { lead_id: leadId },
|
||||
order: { created_at: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve el historial en formato OpenAI/Claude messages array.
|
||||
*/
|
||||
async obtenerHistorialComoMessages(
|
||||
leadId: number,
|
||||
): Promise<Array<{ role: string; content: string }>> {
|
||||
const historial = await this.obtenerHistorial(leadId);
|
||||
return historial.map((h) => ({
|
||||
role: h.rol,
|
||||
content: h.mensaje,
|
||||
}));
|
||||
async obtenerHistorialComoMessages(leadId: string): Promise<Array<{ role: string; content: string }>> {
|
||||
return this.api.obtenerHistorial(leadId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type EstadoLead =
|
||||
| 'nuevo'
|
||||
| 'en_proceso'
|
||||
| 'apertura'
|
||||
| 'espacio'
|
||||
| 'tamano'
|
||||
| 'estilo'
|
||||
| 'urgencia'
|
||||
| 'presupuesto'
|
||||
| 'fin_viable'
|
||||
| 'fin_no_viable'
|
||||
| 'recopilando_datos'
|
||||
| 'completado'
|
||||
| 'no_viable'
|
||||
| 'perdido';
|
||||
|
||||
@Entity('leads')
|
||||
export class Lead {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
nombre: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
telefono: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
espacio: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
rango_m2: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
estilo: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
urgencia: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
presupuesto_declarado: string;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
viable: boolean;
|
||||
|
||||
@Column({ type: 'text', default: 'nuevo' })
|
||||
estado_actual: EstadoLead;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
url_presupuesto: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Lead } from './lead.entity';
|
||||
import { LeadsService } from './leads.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Lead])],
|
||||
providers: [LeadsService],
|
||||
exports: [LeadsService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { Lead, EstadoLead } from './lead.entity';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
|
||||
const SECUENCIA_ESTADOS = [
|
||||
'nuevo',
|
||||
@@ -14,200 +12,83 @@ const SECUENCIA_ESTADOS = [
|
||||
] as const;
|
||||
|
||||
const VALORES_POR_ESTADO: Record<string, string[]> = {
|
||||
espacio: ['cocina', 'bano', 'salon', 'integral', 'otro'],
|
||||
espacio: ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'],
|
||||
tamano: ['menos10', '10a20', '20a40', 'mas40'],
|
||||
estilo: ['funcional', 'cuidado', 'exclusivo'],
|
||||
urgencia: ['urgente', 'medio_plazo', 'frio'],
|
||||
urgencia: ['alta', 'media', 'baja'],
|
||||
};
|
||||
|
||||
const CAMPO_POR_ESTADO: Record<string, keyof Lead> = {
|
||||
const CAMPO_POR_ESTADO_NOMBRE: Record<string, string> = {
|
||||
espacio: 'espacio',
|
||||
tamano: 'rango_m2',
|
||||
tamano: 'rangoM2',
|
||||
estilo: 'estilo',
|
||||
urgencia: 'urgencia',
|
||||
presupuesto: 'presupuesto_declarado',
|
||||
presupuesto: 'presupuestoDeclarado',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LeadsService {
|
||||
private readonly logger = new Logger(LeadsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Lead)
|
||||
private readonly leadRepo: Repository<Lead>,
|
||||
) {}
|
||||
constructor(private readonly api: ApiClient) {}
|
||||
|
||||
/**
|
||||
* Normaliza estados legacy del scheduler/DB al flujo de cualificacion.
|
||||
*/
|
||||
normalizarEstadoFlujo(estado: string): string {
|
||||
if (estado === 'en_proceso' || estado === 'recopilando_datos') {
|
||||
return 'apertura';
|
||||
}
|
||||
if (estado === 'en_proceso' || estado === 'recopilando_datos') return 'apertura';
|
||||
return estado;
|
||||
}
|
||||
|
||||
getSiguienteEstado(estadoActual: string, viable?: boolean): string {
|
||||
const estado = this.normalizarEstadoFlujo(estadoActual);
|
||||
|
||||
if (estado === 'presupuesto') {
|
||||
return viable === false ? 'fin_no_viable' : 'fin_viable';
|
||||
}
|
||||
|
||||
const idx = SECUENCIA_ESTADOS.indexOf(
|
||||
estado as (typeof SECUENCIA_ESTADOS)[number],
|
||||
);
|
||||
if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) {
|
||||
return estado;
|
||||
}
|
||||
if (estado === 'presupuesto') return viable === false ? 'fin_no_viable' : 'fin_viable';
|
||||
const idx = SECUENCIA_ESTADOS.indexOf(estado as typeof SECUENCIA_ESTADOS[number]);
|
||||
if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) return estado;
|
||||
return SECUENCIA_ESTADOS[idx + 1];
|
||||
}
|
||||
|
||||
getValoresPermitidos(estado: string): string[] {
|
||||
const estadoNorm = this.normalizarEstadoFlujo(estado);
|
||||
return VALORES_POR_ESTADO[estadoNorm] ?? [];
|
||||
return VALORES_POR_ESTADO[this.normalizarEstadoFlujo(estado)] ?? [];
|
||||
}
|
||||
|
||||
getCampoParaEstado(estado: string): keyof Lead | null {
|
||||
const estadoNorm = this.normalizarEstadoFlujo(estado);
|
||||
return CAMPO_POR_ESTADO[estadoNorm] ?? null;
|
||||
getCampoParaEstado(estado: string): string | null {
|
||||
return CAMPO_POR_ESTADO_NOMBRE[this.normalizarEstadoFlujo(estado)] ?? null;
|
||||
}
|
||||
|
||||
esPresupuestoValido(valor: string): boolean {
|
||||
const normalizado = valor.trim().toLowerCase();
|
||||
if (!normalizado) return false;
|
||||
return /\d/.test(normalizado);
|
||||
return /\d/.test(valor.trim().toLowerCase());
|
||||
}
|
||||
|
||||
evaluarViabilidad(presupuesto: string): boolean {
|
||||
const numeros = presupuesto.match(/\d[\d.]*/g);
|
||||
if (!numeros?.length) return true;
|
||||
|
||||
const valor = parseInt(numeros[0].replace(/\./g, ''), 10);
|
||||
if (Number.isNaN(valor)) return true;
|
||||
|
||||
return valor >= 5000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca un lead por número de teléfono.
|
||||
* Si no existe, lo crea con estado 'nuevo'.
|
||||
*/
|
||||
async findOrCreate(telefono: string): Promise<Lead> {
|
||||
let lead = await this.leadRepo.findOne({ where: { telefono } });
|
||||
if (!lead) {
|
||||
lead = this.leadRepo.create({ telefono, estado_actual: 'nuevo' });
|
||||
lead = await this.leadRepo.save(lead);
|
||||
this.logger.log(`Lead nuevo creado: telefono=${telefono}, id=${lead.id}`);
|
||||
}
|
||||
return lead;
|
||||
}
|
||||
|
||||
async findByTelefono(telefono: string): Promise<Lead | null> {
|
||||
return this.leadRepo.findOne({ where: { telefono } });
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Lead | null> {
|
||||
return this.leadRepo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByEstado(estado: EstadoLead): Promise<Lead[]> {
|
||||
return this.leadRepo.find({ where: { estado_actual: estado } });
|
||||
}
|
||||
|
||||
async updateEstado(lead: Lead, estado: EstadoLead | string): Promise<Lead> {
|
||||
await this.leadRepo.update(lead.id, {
|
||||
estado_actual: estado as EstadoLead,
|
||||
});
|
||||
this.logger.log(`Lead id=${lead.id} estado_actual=${estado}`);
|
||||
return this.leadRepo.findOne({ where: { id: lead.id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza campos del lead según el estado actual del flujo.
|
||||
* Solo actualiza los campos que se pasan en el partial.
|
||||
*/
|
||||
async updateDatos(leadId: number, datos: Partial<Lead>): Promise<Lead> {
|
||||
const campos = Object.keys(datos).filter(
|
||||
(k) => datos[k as keyof Lead] !== undefined,
|
||||
);
|
||||
if (campos.length === 0) {
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
|
||||
await this.leadRepo.update(leadId, datos);
|
||||
this.logger.log(
|
||||
`Lead id=${leadId} datos guardados: ${JSON.stringify(datos)}`,
|
||||
);
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
|
||||
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
|
||||
const estado = viable ? 'completado' : 'no_viable';
|
||||
await this.leadRepo.update(lead.id, { viable, estado_actual: estado });
|
||||
this.logger.log(`Lead id=${lead.id} viable=${viable}, estado=${estado}`);
|
||||
return this.leadRepo.findOne({ where: { id: lead.id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste datos del lead y cambio de estado en una sola operacion.
|
||||
*/
|
||||
async persistirTurno(
|
||||
leadId: number,
|
||||
datos: Partial<Lead>,
|
||||
leadId: string,
|
||||
datos: Record<string, unknown>,
|
||||
options?: { nuevoEstado?: string; viable?: boolean },
|
||||
): Promise<Lead> {
|
||||
const patch: Partial<Lead> = { ...datos };
|
||||
): Promise<boolean> {
|
||||
const perfil: Record<string, unknown> = { ...datos };
|
||||
|
||||
if (options?.nuevoEstado === 'fin_viable') {
|
||||
patch.viable = true;
|
||||
patch.estado_actual = 'completado';
|
||||
perfil.viable = true;
|
||||
perfil.botStep = 'presupuesto';
|
||||
} else if (options?.nuevoEstado === 'fin_no_viable') {
|
||||
patch.viable = false;
|
||||
patch.estado_actual = 'no_viable';
|
||||
perfil.viable = false;
|
||||
perfil.botStep = 'presupuesto';
|
||||
} else if (options?.nuevoEstado) {
|
||||
patch.estado_actual = options.nuevoEstado as EstadoLead;
|
||||
} else if (options?.viable !== undefined && options?.viable !== null) {
|
||||
patch.viable = options.viable;
|
||||
patch.estado_actual = options.viable ? 'completado' : 'no_viable';
|
||||
perfil.botStep = options.nuevoEstado;
|
||||
} else if (options?.viable !== undefined) {
|
||||
perfil.viable = options.viable;
|
||||
}
|
||||
|
||||
const campos = Object.keys(patch).filter(
|
||||
(k) => patch[k as keyof Lead] !== undefined,
|
||||
);
|
||||
if (campos.length === 0) {
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
const campos = Object.keys(perfil).filter((k) => perfil[k] !== undefined);
|
||||
if (campos.length === 0) return true;
|
||||
|
||||
await this.leadRepo.update(leadId, patch);
|
||||
this.logger.log(
|
||||
`Lead id=${leadId} persistido: ${JSON.stringify(patch)}`,
|
||||
);
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca como perdido cualquier lead en_proceso sin actividad en más de 48h.
|
||||
*/
|
||||
async marcarLeadsPerdidos(): Promise<void> {
|
||||
const hace48h = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||||
const leadsSinActividad = await this.leadRepo.find({
|
||||
where: {
|
||||
estado_actual: 'en_proceso',
|
||||
updated_at: LessThan(hace48h),
|
||||
},
|
||||
});
|
||||
|
||||
for (const lead of leadsSinActividad) {
|
||||
lead.estado_actual = 'perdido';
|
||||
await this.leadRepo.save(lead);
|
||||
this.logger.warn(
|
||||
`Lead id=${lead.id} marcado como perdido por inactividad > 48h`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async save(lead: Lead): Promise<Lead> {
|
||||
return this.leadRepo.save(lead);
|
||||
const ok = await this.api.actualizarPerfil(leadId, perfil);
|
||||
this.logger.log(`Lead ${leadId} persistido via API: ${JSON.stringify(perfil)} → ${ok ? 'ok' : 'fallo'}`);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,268 +1,99 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import axios from "axios";
|
||||
import { EstadoLead } from "../leads/lead.entity";
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
|
||||
private readonly OPENROUTER_URL =
|
||||
"https://openrouter.ai/api/v1/chat/completions";
|
||||
private readonly OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
private get headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://reformix.es",
|
||||
"X-Title": "Reformix Luisa Bot",
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Luisa Bot',
|
||||
};
|
||||
}
|
||||
|
||||
private getModeloTranscripcion(): string {
|
||||
return (
|
||||
process.env.MODEL_TRANSCRIPCION || "google/gemini-2.5-flash"
|
||||
);
|
||||
return process.env.MODEL_TRANSCRIPCION || 'google/gemini-2.5-flash';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte mimetype de WhatsApp al formato que espera OpenRouter input_audio.
|
||||
*/
|
||||
mimeToAudioFormat(mimeType: string): string {
|
||||
const base = mimeType.toLowerCase().split(";")[0].trim();
|
||||
const map: Record<string, string> = {
|
||||
"audio/ogg": "ogg",
|
||||
"audio/opus": "ogg",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/aac": "aac",
|
||||
"audio/wav": "wav",
|
||||
"audio/webm": "webm",
|
||||
"audio/flac": "flac",
|
||||
};
|
||||
return map[base] ?? "ogg";
|
||||
const base = mimeType.toLowerCase().split(';')[0].trim();
|
||||
const map: Record<string, string> = { 'audio/ogg': 'ogg', 'audio/opus': 'ogg', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/mp4': 'm4a', 'audio/aac': 'aac', 'audio/wav': 'wav', 'audio/webm': 'webm', 'audio/flac': 'flac' };
|
||||
return map[base] ?? 'ogg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina encabezados y formato que el modelo pueda añadir a la transcripcion.
|
||||
*/
|
||||
limpiarTranscripcion(texto: string): string {
|
||||
return texto
|
||||
.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, "")
|
||||
.replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, "")
|
||||
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, "")
|
||||
.replace(/^```[\s\S]*?\n/g, "")
|
||||
.replace(/\n```$/g, "")
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.trim();
|
||||
return texto.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||
.replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, '')
|
||||
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||
.replace(/^```[\s\S]*?\n/g, '').replace(/\n```$/g, '')
|
||||
.replace(/^["']|["']$/g, '').trim();
|
||||
}
|
||||
|
||||
private detectarFormatoPorMagicBytes(buffer: Buffer): string | null {
|
||||
if (
|
||||
buffer.length >= 4 &&
|
||||
buffer.subarray(0, 4).toString("ascii") === "OggS"
|
||||
) {
|
||||
return "ogg";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 3 &&
|
||||
buffer[0] === 0xff &&
|
||||
(buffer[1] & 0xe0) === 0xe0
|
||||
) {
|
||||
return "mp3";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
||||
buffer.subarray(8, 12).toString("ascii") === "WAVE"
|
||||
) {
|
||||
return "wav";
|
||||
}
|
||||
if (buffer.length >= 4 && buffer.subarray(0, 4).toString('ascii') === 'OggS') return 'ogg';
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WAVE') return 'wav';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe un audio via OpenRouter input_audio (Gemini por defecto).
|
||||
* Claude no soporta audio en OpenRouter; Luisa sigue usando Claude en el resto del pipeline.
|
||||
*/
|
||||
async transcribirAudio(
|
||||
audioBuffer: Buffer,
|
||||
mimeType = "audio/ogg; codecs=opus",
|
||||
): Promise<string> {
|
||||
const FALLBACK =
|
||||
"No te he oido bien, me lo repites?";
|
||||
|
||||
async transcribirAudio(audioBuffer: Buffer, mimeType = 'audio/ogg; codecs=opus'): Promise<string> {
|
||||
const FALLBACK = 'No te he oido bien, me lo repites?';
|
||||
const formatFromMime = this.mimeToAudioFormat(mimeType);
|
||||
const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer);
|
||||
const format = formatFromMagic ?? formatFromMime;
|
||||
const base64Audio = audioBuffer.toString("base64");
|
||||
const base64Audio = audioBuffer.toString('base64');
|
||||
const model = this.getModeloTranscripcion();
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 2/4] MediaService.transcribirAudio — buffer=${audioBuffer.length} bytes, mime=${mimeType}, format=${format}, magic=${formatFromMagic ?? "no detectado"}, base64=${base64Audio.length} chars, modelo=${model}`,
|
||||
);
|
||||
if (audioBuffer.length < 100) return FALLBACK;
|
||||
|
||||
if (audioBuffer.length < 100) {
|
||||
this.logger.warn(
|
||||
`[AUDIO 2/4] Buffer demasiado pequeno (${audioBuffer.length} bytes), abortando transcripcion`,
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
const systemPrompt =
|
||||
"Eres un transcriptor de voz para usuarios de Madrid y Espana. " +
|
||||
"Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, " +
|
||||
"muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. " +
|
||||
"Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.";
|
||||
|
||||
const userPrompt =
|
||||
"Transcribe exactamente lo que dice la persona en este audio. " +
|
||||
"Es espanol de Espana, posiblemente con tono coloquial madrileño. " +
|
||||
"Devuelve solo las palabras habladas, tal cual, nada mas.";
|
||||
const systemPrompt = 'Eres un transcriptor de voz para usuarios de Madrid y Espana. Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.';
|
||||
const userPrompt = 'Transcribe exactamente lo que dice la persona en este audio. Es espanol de Espana, posiblemente con tono coloquial madrileño. Devuelve solo las palabras habladas, tal cual, nada mas.';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
const response = await axios.post(this.OPENROUTER_URL, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: userPrompt },
|
||||
{
|
||||
type: "input_audio",
|
||||
input_audio: {
|
||||
data: base64Audio,
|
||||
format,
|
||||
},
|
||||
},
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: [{ type: 'text', text: userPrompt }, { type: 'input_audio', input_audio: { data: base64Audio, format } }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
this.logger.debug(
|
||||
`[AUDIO 3/4] Enviando a OpenRouter — endpoint=${this.OPENROUTER_URL}, content_type=input_audio, format=${format}`,
|
||||
);
|
||||
|
||||
const response = await axios.post(this.OPENROUTER_URL, payload, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
const raw: string =
|
||||
response.data.choices?.[0]?.message?.content?.trim() ?? "";
|
||||
const modeloUsado = response.data.model ?? model;
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 3/4] Respuesta OpenRouter — modelo=${modeloUsado}, raw_length=${raw.length}, raw_preview="${raw.slice(0, 120).replace(/\n/g, "\\n")}"`,
|
||||
);
|
||||
|
||||
if (!raw) {
|
||||
this.logger.warn(
|
||||
"[AUDIO 4/4] Modelo devolvio respuesta vacia para el audio",
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
const transcripcion = this.limpiarTranscripcion(raw);
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 4/4] Transcripcion final — length=${transcripcion.length}, texto="${transcripcion.slice(0, 200).replace(/\n/g, "\\n")}"`,
|
||||
);
|
||||
|
||||
if (!transcripcion) {
|
||||
this.logger.warn(
|
||||
"[AUDIO 4/4] Transcripcion vacia tras limpieza, usando fallback",
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
return transcripcion;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[AUDIO 3/4] Error transcribiendo audio: ${error.message}`,
|
||||
error.response?.data,
|
||||
);
|
||||
max_tokens: 512, temperature: 0,
|
||||
}, { headers: this.headers });
|
||||
const raw: string = response.data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||
if (!raw) return FALLBACK;
|
||||
return this.limpiarTranscripcion(raw) || FALLBACK;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error transcribiendo audio: ${error.message}`);
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infiere informacion de una imagen segun el estado actual del lead.
|
||||
*/
|
||||
async inferirImagen(
|
||||
imagenBuffer: Buffer,
|
||||
mimeType = "image/jpeg",
|
||||
estadoActual: EstadoLead = "en_proceso",
|
||||
): Promise<string> {
|
||||
const FALLBACK =
|
||||
"Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?";
|
||||
|
||||
async inferirImagen(imagenBuffer: Buffer, mimeType = 'image/jpeg', estadoActual = 'en_proceso'): Promise<string> {
|
||||
const FALLBACK = 'Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?';
|
||||
const promptPorEstado: Record<string, string> = {
|
||||
nuevo:
|
||||
"Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.",
|
||||
en_proceso:
|
||||
"Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.",
|
||||
recopilando_datos:
|
||||
"Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.",
|
||||
completado:
|
||||
"Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.",
|
||||
no_viable: "Describe brevemente que muestra esta imagen.",
|
||||
perdido: "Describe brevemente que muestra esta imagen.",
|
||||
nuevo: 'Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.',
|
||||
en_proceso: 'Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.',
|
||||
recopilando_datos: 'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.',
|
||||
completado: 'Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.',
|
||||
no_viable: 'Describe brevemente que muestra esta imagen.',
|
||||
perdido: 'Describe brevemente que muestra esta imagen.',
|
||||
};
|
||||
|
||||
const promptDeVision =
|
||||
promptPorEstado[estadoActual] ||
|
||||
"Describe que ves en esta imagen en el contexto de una reforma de hogar.";
|
||||
const promptDeVision = promptPorEstado[estadoActual] || 'Describe que ves en esta imagen en el contexto de una reforma de hogar.';
|
||||
|
||||
try {
|
||||
const base64Imagen = imagenBuffer.toString("base64");
|
||||
|
||||
const response = await axios.post(
|
||||
this.OPENROUTER_URL,
|
||||
{
|
||||
model:
|
||||
process.env.MODEL_GENERADOR ||
|
||||
process.env.MODEL ||
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: promptDeVision },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${base64Imagen}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
const base64Imagen = imagenBuffer.toString('base64');
|
||||
const response = await axios.post(this.OPENROUTER_URL, {
|
||||
model: process.env.MODEL_GENERADOR || process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: promptDeVision }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Imagen}` } }] }],
|
||||
max_tokens: 512,
|
||||
},
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
const inferencia: string =
|
||||
response.data.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!inferencia) {
|
||||
this.logger.warn("Claude devolvio respuesta vacia para la imagen");
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Imagen inferida correctamente (${inferencia.length} chars)`,
|
||||
);
|
||||
return inferencia;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error analizando imagen: ${error.message}`,
|
||||
error.response?.data,
|
||||
);
|
||||
}, { headers: this.headers });
|
||||
const inferencia: string = response.data.choices?.[0]?.message?.content?.trim();
|
||||
return inferencia || FALLBACK;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error analizando imagen: ${error.message}`);
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchedulerService } from './scheduler.service';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
||||
import { ClaudeModule } from '../claude/claude.module';
|
||||
|
||||
@Module({
|
||||
imports: [LeadsModule, ConversacionModule, WhatsappModule, ClaudeModule],
|
||||
providers: [SchedulerService],
|
||||
})
|
||||
export class SchedulerModule {}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
||||
import { ClaudeService } from '../claude/claude.service';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulerService {
|
||||
private readonly logger = new Logger(SchedulerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly leadsService: LeadsService,
|
||||
private readonly conversacionService: ConversacionService,
|
||||
private readonly whatsappService: WhatsappService,
|
||||
private readonly claudeService: ClaudeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Cada 5 minutos:
|
||||
* 1. Busca leads con estado_actual = 'nuevo'
|
||||
* 2. Los marca como 'en_proceso'
|
||||
* 3. Les envía el mensaje de APERTURA de Luisa
|
||||
*
|
||||
* También marca como perdidos los leads en_proceso sin actividad > 48h.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async procesarLeadsNuevos(): Promise<void> {
|
||||
this.logger.log('[Scheduler] Buscando leads nuevos...');
|
||||
|
||||
// Primero limpiar leads inactivos
|
||||
await this.leadsService.marcarLeadsPerdidos();
|
||||
|
||||
// Obtener leads nuevos
|
||||
const leadsNuevos = await this.leadsService.findByEstado('nuevo');
|
||||
|
||||
if (leadsNuevos.length === 0) {
|
||||
this.logger.log('[Scheduler] No hay leads nuevos.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Scheduler] Procesando ${leadsNuevos.length} lead(s) nuevo(s).`,
|
||||
);
|
||||
|
||||
for (const lead of leadsNuevos) {
|
||||
try {
|
||||
// Marcar como en_proceso antes de hacer nada
|
||||
await this.leadsService.updateEstado(lead, 'en_proceso');
|
||||
this.logger.log(
|
||||
`[Scheduler] Lead id=${lead.id} marcado como en_proceso.`,
|
||||
);
|
||||
|
||||
// Generar mensaje de apertura con Claude usando contexto mínimo
|
||||
const historialVacio: Array<{ role: string; content: string }> = [];
|
||||
const mensajeDeApertura =
|
||||
'APERTURA: Este es el primer mensaje. Preséntate y comienza el flujo de cualificación.';
|
||||
|
||||
const { respuesta } = await this.claudeService.llamarClaude(
|
||||
lead,
|
||||
historialVacio,
|
||||
mensajeDeApertura,
|
||||
);
|
||||
|
||||
// Guardar el mensaje de apertura en historial (como assistant)
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
'assistant',
|
||||
respuesta,
|
||||
);
|
||||
|
||||
// Enviar por WhatsApp
|
||||
await this.whatsappService.enviarApertura(lead.telefono, respuesta);
|
||||
|
||||
this.logger.log(
|
||||
`[Scheduler] Apertura enviada a lead id=${lead.id} (${lead.telefono}).`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[Scheduler] Error procesando lead id=${lead.id}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
mvp/Whatsapp-bot/src/webhook/webhook-listener.ts
Normal file
85
mvp/Whatsapp-bot/src/webhook/webhook-listener.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import * as http from 'http';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class WebhookListener implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(WebhookListener.name);
|
||||
private server: http.Server | null = null;
|
||||
|
||||
constructor(private readonly api: ApiClient) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
const port = parseInt(process.env.WEBHOOK_PORT || '3001', 10);
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
this.server.listen(port, () => {
|
||||
this.logger.log(`Webhook listener en puerto ${port}`);
|
||||
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
|
||||
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405).end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', async () => {
|
||||
let payload: any;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'JSON invalido' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url || '';
|
||||
try {
|
||||
if (url === '/whatsapp-start') {
|
||||
await this.handleWhatsappStart(payload);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||
} else if (url === '/whatsapp-pdf') {
|
||||
await this.handleWhatsappPdf(payload);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||
} else {
|
||||
res.writeHead(404).end('Not Found');
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error handling ${url}: ${err.message}`);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
private leadSessions = new Map<string, { leadId: string; telefono: string; nombre: string; jid: string | null }>();
|
||||
|
||||
private async handleWhatsappStart(payload: { leadId: string; telefono: string; nombre: string; empresa: string }) {
|
||||
const { leadId, telefono, nombre } = payload;
|
||||
this.logger.log(`[START] leadId=${leadId}, telefono=${telefono}, nombre=${nombre}`);
|
||||
|
||||
this.leadSessions.set(telefono, { leadId, telefono, nombre, jid: null });
|
||||
this.logger.log(`Lead ${leadId} registrado en sesiones.`);
|
||||
}
|
||||
|
||||
getLeadIdByTelefono(telefono: string): string | null {
|
||||
return this.leadSessions.get(telefono)?.leadId ?? null;
|
||||
}
|
||||
|
||||
registerJid(telefono: string, jid: string) {
|
||||
const session = this.leadSessions.get(telefono);
|
||||
if (session) {
|
||||
session.jid = jid;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWhatsappPdf(payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) {
|
||||
this.logger.log(`[PDF] leadId=${payload.leadId}, filename=${payload.filename}`);
|
||||
const { pdfEmitter } = await import('../whatsapp/whatsapp.service');
|
||||
const telefono = payload.telefono.startsWith('+') ? payload.telefono.slice(1) : payload.telefono;
|
||||
pdfEmitter.emit('pdf', { ...payload, telefono });
|
||||
}
|
||||
}
|
||||
10
mvp/Whatsapp-bot/src/webhook/webhook.module.ts
Normal file
10
mvp/Whatsapp-bot/src/webhook/webhook.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebhookListener } from './webhook-listener';
|
||||
import { ApiModule } from '../api/api.module';
|
||||
|
||||
@Module({
|
||||
imports: [ApiModule],
|
||||
providers: [WebhookListener],
|
||||
exports: [WebhookListener],
|
||||
})
|
||||
export class WebhookModule {}
|
||||
@@ -5,9 +5,10 @@ import { LeadsModule } from '../leads/leads.module';
|
||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||
import { ClaudeModule } from '../claude/claude.module';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { WebhookModule } from '../webhook/webhook.module';
|
||||
|
||||
@Module({
|
||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
|
||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule, WebhookModule],
|
||||
providers: [WhatsappService, WhatsappDebounceService],
|
||||
exports: [WhatsappService],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from "@nestjs/common";
|
||||
} from '@nestjs/common';
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
useMultiFileAuthState,
|
||||
@@ -11,33 +12,42 @@ import makeWASocket, {
|
||||
WASocket,
|
||||
downloadMediaMessage,
|
||||
normalizeMessageContent,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { Boom } from "@hapi/boom";
|
||||
import * as path from "path";
|
||||
const pino = require("pino");
|
||||
const QRCode = require("qrcode-terminal");
|
||||
import { LeadsService } from "../leads/leads.service";
|
||||
import { ConversacionService } from "../conversacion/conversacion.service";
|
||||
import { ClaudeService } from "../claude/claude.service";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { WhatsappDebounceService } from "./whatsapp-debounce.service";
|
||||
import { wrapSocket } from "baileys-antiban";
|
||||
} from '@whiskeysockets/baileys';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import * as path from 'path';
|
||||
const pino = require('pino');
|
||||
const QRCode = require('qrcode-terminal');
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||
import { ClaudeService } from '../claude/claude.service';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { WhatsappDebounceService } from './whatsapp-debounce.service';
|
||||
import { WebhookListener } from '../webhook/webhook-listener';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
import { wrapSocket } from 'baileys-antiban';
|
||||
|
||||
const ESTADOS_TERMINALES = [
|
||||
"completado",
|
||||
"no_viable",
|
||||
"perdido",
|
||||
"fin_viable",
|
||||
"fin_no_viable",
|
||||
];
|
||||
export const pdfEmitter = new EventEmitter();
|
||||
|
||||
interface LeadContext {
|
||||
leadId: string;
|
||||
telefono: string;
|
||||
nombre: string;
|
||||
botStep: string;
|
||||
viable: boolean | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WhatsappService.name);
|
||||
private sock: WASocket | null = null;
|
||||
private authDir = path.join(process.cwd(), "auth_info_baileys");
|
||||
private authDir = path.join(process.cwd(), 'auth_info_baileys');
|
||||
private readonly ultimoMsgPorJid = new Map<string, any>();
|
||||
private baileysLogger = pino({ level: "info" });
|
||||
private baileysLogger = pino({ level: 'info' });
|
||||
|
||||
// leadId por JID
|
||||
private readonly jidToLeadId = new Map<string, string>();
|
||||
// contexto de lead por leadId
|
||||
private readonly leadCache = new Map<string, LeadContext>();
|
||||
|
||||
constructor(
|
||||
private readonly leadsService: LeadsService,
|
||||
@@ -45,20 +55,51 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly claudeService: ClaudeService,
|
||||
private readonly mediaService: MediaService,
|
||||
private readonly debounceService: WhatsappDebounceService,
|
||||
private readonly webhookListener: WebhookListener,
|
||||
private readonly api: ApiClient,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.conectar();
|
||||
this.escucharPdf();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.sock) {
|
||||
this.sock.end(undefined);
|
||||
if (this.sock) this.sock.end(undefined);
|
||||
}
|
||||
|
||||
private escucharPdf() {
|
||||
pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => {
|
||||
this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`);
|
||||
// Buscar JID por teléfono
|
||||
let jid: string | null = null;
|
||||
for (const [j, lid] of this.jidToLeadId) {
|
||||
if (lid === payload.leadId) {
|
||||
jid = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!jid) {
|
||||
jid = `${payload.telefono}@s.whatsapp.net`;
|
||||
}
|
||||
if (!this.sock) return;
|
||||
try {
|
||||
const safeSock = wrapSocket(this.sock);
|
||||
await safeSock.sendMessage(jid, {
|
||||
document: Buffer.from(payload.pdfBase64, 'base64'),
|
||||
mimetype: 'application/pdf',
|
||||
fileName: payload.filename,
|
||||
caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.',
|
||||
});
|
||||
this.logger.log(`PDF enviado a ${jid}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private normalizarTelefono(jid: string): string {
|
||||
return jid.split("@")[0].replace(/\D/g, "");
|
||||
return jid.split('@')[0].replace(/\D/g, '');
|
||||
}
|
||||
|
||||
private calcularDelayEscritura(longitudTexto: number): number {
|
||||
@@ -76,7 +117,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
this.baileysLogger = pino({ level: "info" }) as any;
|
||||
this.baileysLogger = pino({ level: 'info' }) as any;
|
||||
|
||||
this.sock = makeWASocket({
|
||||
version,
|
||||
@@ -88,56 +129,37 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
syncFullHistory: false,
|
||||
});
|
||||
|
||||
this.sock.ev.on("creds.update", saveCreds);
|
||||
this.sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
this.sock.ev.on("connection.update", (update) => {
|
||||
this.sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
QRCode.generate(qr, { small: true });
|
||||
console.log("\n📲 Escanea este QR con WhatsApp\n");
|
||||
console.log('\n📲 Escanea este QR con WhatsApp\n');
|
||||
}
|
||||
|
||||
if (connection === "close") {
|
||||
if (connection === 'close') {
|
||||
const shouldReconnect =
|
||||
(lastDisconnect?.error as Boom)?.output?.statusCode !==
|
||||
DisconnectReason.loggedOut;
|
||||
|
||||
this.logger.warn(
|
||||
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
|
||||
);
|
||||
|
||||
if (shouldReconnect) {
|
||||
setTimeout(() => this.conectar(), 5000);
|
||||
} else {
|
||||
this.logger.error(
|
||||
"Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.",
|
||||
);
|
||||
}
|
||||
} else if (connection === "open") {
|
||||
this.logger.log(
|
||||
"✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.",
|
||||
);
|
||||
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
|
||||
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
|
||||
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
|
||||
else this.logger.error('Sesion cerrada (logged out).');
|
||||
} else if (connection === 'open') {
|
||||
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
|
||||
}
|
||||
});
|
||||
|
||||
this.sock.ev.on("messages.upsert", async ({ messages, type }) => {
|
||||
if (type !== "notify") return;
|
||||
|
||||
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
for (const msg of messages) {
|
||||
if (msg.key.fromMe) continue;
|
||||
if (!msg.key.remoteJid) continue;
|
||||
if (msg.key.remoteJid.includes("@g.us")) continue;
|
||||
if (msg.key.remoteJid.includes('@g.us')) continue;
|
||||
|
||||
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
|
||||
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, "");
|
||||
|
||||
if (allowedNumber && telefonoNormalizado !== allowedNumber) {
|
||||
this.logger.debug(
|
||||
`Mensaje ignorado: ${telefonoNormalizado} no coincide con ALLOWED_NUMBER`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
|
||||
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
|
||||
|
||||
await this.encolarMensaje(msg);
|
||||
}
|
||||
@@ -147,21 +169,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private extraerTextoPlano(msg: any): string | null {
|
||||
const msgContent = msg.message;
|
||||
if (!msgContent) return null;
|
||||
|
||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||
const texto =
|
||||
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
||||
const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||
return texto.trim() ? texto : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private crearMsgConTexto(msg: any, texto: string): any {
|
||||
return {
|
||||
...msg,
|
||||
message: { conversation: texto },
|
||||
};
|
||||
return { ...msg, message: { conversation: texto } };
|
||||
}
|
||||
|
||||
private async encolarMensaje(msg: any): Promise<void> {
|
||||
@@ -174,7 +190,6 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
this.ultimoMsgPorJid.set(jid, msg);
|
||||
|
||||
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
|
||||
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
|
||||
this.ultimoMsgPorJid.delete(jid);
|
||||
@@ -182,179 +197,184 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private async procesarMensaje(msg: any): Promise<void> {
|
||||
const jid = msg.key.remoteJid!;
|
||||
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
|
||||
const leadId = this.webhookListener.getLeadIdByTelefono(telefono);
|
||||
|
||||
if (jid.includes("@g.us")) return;
|
||||
|
||||
const telefono = jid.split("@")[0];
|
||||
|
||||
try {
|
||||
let lead = await this.leadsService.findOrCreate(telefono);
|
||||
|
||||
if (ESTADOS_TERMINALES.includes(lead.estado_actual)) {
|
||||
this.logger.log(
|
||||
`Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`,
|
||||
);
|
||||
return;
|
||||
if (!leadId) {
|
||||
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let textoNormalizado = "";
|
||||
const msgContent = normalizeMessageContent(msg.message);
|
||||
this.webhookListener.registerJid(telefono, jid);
|
||||
this.jidToLeadId.set(jid, leadId);
|
||||
|
||||
let ctx = this.leadCache.get(leadId);
|
||||
if (!ctx) {
|
||||
const lead = await this.api.getLead(leadId);
|
||||
ctx = {
|
||||
leadId,
|
||||
telefono,
|
||||
nombre: lead?.nombre || '',
|
||||
botStep: lead?.botStep || 'nuevo',
|
||||
viable: lead?.viable ?? null,
|
||||
};
|
||||
this.leadCache.set(leadId, ctx);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private async procesarMensaje(msg: any): Promise<void> {
|
||||
const jid = msg.key.remoteJid!;
|
||||
if (jid.includes('@g.us')) return;
|
||||
|
||||
const telefono = jid.split('@')[0];
|
||||
|
||||
try {
|
||||
const ctx = await this.getOrCreateContext(telefono, jid);
|
||||
if (!ctx) return;
|
||||
|
||||
const primerMensajeDeUsuario = !this.jidToLeadId.has(jid);
|
||||
|
||||
let textoNormalizado = '';
|
||||
const msgContent = normalizeMessageContent(msg.message);
|
||||
if (!msgContent) return;
|
||||
|
||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||
textoNormalizado =
|
||||
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
||||
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||
} else if (msgContent.audioMessage) {
|
||||
const audioMeta = msgContent.audioMessage;
|
||||
const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus";
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 1/4] Recibido — lead=${lead.id}, ptt=${audioMeta.ptt ?? false}, seconds=${audioMeta.seconds ?? "?"}, mimetype=${mimeType}, fileLength=${audioMeta.fileLength ?? "?"}, url=${audioMeta.url ? "si" : "no"}`,
|
||||
);
|
||||
|
||||
if (!this.sock) {
|
||||
this.logger.error("[AUDIO 1/4] Socket no disponible para descargar audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await downloadMediaMessage(
|
||||
msg as any,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
},
|
||||
);
|
||||
|
||||
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
const magicHex = audioBuffer.subarray(0, 4).toString("hex");
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 1/4] Buffer descargado — size=${audioBuffer.length} bytes, magic_hex=${magicHex}, esperado_ogg=4f676753`,
|
||||
);
|
||||
|
||||
textoNormalizado = await this.mediaService.transcribirAudio(
|
||||
audioBuffer,
|
||||
mimeType,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 1/4] Transcripcion recibida en procesarMensaje — "${textoNormalizado.slice(0, 200).replace(/\n/g, "\\n")}"`,
|
||||
);
|
||||
} else if (msgContent.imageMessage) {
|
||||
this.logger.log(
|
||||
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
|
||||
);
|
||||
|
||||
const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
|
||||
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
|
||||
if (!this.sock) return;
|
||||
|
||||
const buffer = await downloadMediaMessage(
|
||||
msg as any,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
},
|
||||
);
|
||||
const mimeType = msgContent.imageMessage.mimetype || "image/jpeg";
|
||||
});
|
||||
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType);
|
||||
} else if (msgContent.imageMessage) {
|
||||
this.logger.log(`Imagen recibida de lead ${ctx.leadId}`);
|
||||
if (!this.sock) return;
|
||||
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
});
|
||||
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
||||
textoNormalizado = await this.mediaService.inferirImagen(
|
||||
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
|
||||
mimeType,
|
||||
lead.estado_actual,
|
||||
'en_proceso',
|
||||
);
|
||||
if (msgContent.imageMessage.caption) {
|
||||
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
|
||||
}
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
|
||||
);
|
||||
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textoNormalizado.trim()) return;
|
||||
|
||||
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
|
||||
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
"user",
|
||||
textoNormalizado,
|
||||
);
|
||||
if (primerMensajeDeUsuario) {
|
||||
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
|
||||
}
|
||||
|
||||
const historial =
|
||||
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
|
||||
if (msgContent.imageMessage) {
|
||||
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
});
|
||||
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
|
||||
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
||||
await this.api.enviarIngesta(ctx.leadId, [{
|
||||
tipo: 'foto',
|
||||
imagen: `data:${mimeType};base64,${base64}`,
|
||||
zona: 'otro',
|
||||
momento: 'antes',
|
||||
}]);
|
||||
}
|
||||
|
||||
const { respuesta, entidad, viable, nuevoEstado } =
|
||||
await this.claudeService.llamarClaude(
|
||||
lead,
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
|
||||
botStep: ctx.botStep,
|
||||
});
|
||||
|
||||
const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId);
|
||||
|
||||
const leadParaClaude = {
|
||||
id: ctx.leadId,
|
||||
telefono: ctx.telefono,
|
||||
nombre: ctx.nombre,
|
||||
estado_actual: ctx.botStep || 'nuevo',
|
||||
espacio: null as string | null,
|
||||
rango_m2: null as string | null,
|
||||
estilo: null as string | null,
|
||||
urgencia: null as string | null,
|
||||
presupuesto_declarado: null as string | null,
|
||||
viable: ctx.viable as boolean | null,
|
||||
email: null as string | null,
|
||||
};
|
||||
|
||||
const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude(
|
||||
leadParaClaude as any,
|
||||
historial.slice(0, -1),
|
||||
textoNormalizado,
|
||||
);
|
||||
|
||||
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
|
||||
|
||||
if (
|
||||
(entidad && Object.keys(entidad).length > 0) ||
|
||||
nuevoEstado ||
|
||||
(viable !== undefined && viable !== null)
|
||||
) {
|
||||
lead = await this.leadsService.persistirTurno(lead.id, entidad ?? {}, {
|
||||
nuevoEstado,
|
||||
viable,
|
||||
});
|
||||
this.logger.log(
|
||||
`Lead id=${lead.id} en DB — estado=${lead.estado_actual}, espacio=${lead.espacio ?? "-"}, rango_m2=${lead.rango_m2 ?? "-"}, estilo=${lead.estilo ?? "-"}, urgencia=${lead.urgencia ?? "-"}, presupuesto=${lead.presupuesto_declarado ?? "-"}`,
|
||||
);
|
||||
if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
|
||||
const entidadMap: Record<string, unknown> = {};
|
||||
if (entidad) {
|
||||
for (const [k, v] of Object.entries(entidad)) {
|
||||
const mapped = this.mapearCampoALegacy(k);
|
||||
entidadMap[mapped] = v;
|
||||
}
|
||||
}
|
||||
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
|
||||
if (nuevoEstado) ctx.botStep = nuevoEstado;
|
||||
if (viable !== undefined) ctx.viable = viable;
|
||||
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
|
||||
}
|
||||
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
"assistant",
|
||||
respuesta,
|
||||
);
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
|
||||
botStep: ctx.botStep,
|
||||
});
|
||||
await this.enviarMensaje(jid, respuesta);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error procesando mensaje de ${telefono}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
private mapearCampoALegacy(campo: string): string {
|
||||
const map: Record<string, string> = {
|
||||
espacio: 'espacio',
|
||||
rango_m2: 'rangoM2',
|
||||
estilo: 'estilo',
|
||||
urgencia: 'urgencia',
|
||||
presupuesto_declarado: 'presupuestoDeclarado',
|
||||
nombre: 'nombre',
|
||||
};
|
||||
return map[campo] || campo;
|
||||
}
|
||||
|
||||
async enviarMensaje(jid: string, texto: string): Promise<void> {
|
||||
if (!this.sock) return;
|
||||
|
||||
try {
|
||||
const jidPresencia = jid.includes("@lid")
|
||||
? `${jid.split("@")[0]}@s.whatsapp.net`
|
||||
const jidPresencia = jid.includes('@lid')
|
||||
? `${jid.split('@')[0]}@s.whatsapp.net`
|
||||
: jid;
|
||||
|
||||
await this.sock.sendPresenceUpdate("composing", jidPresencia);
|
||||
await this.sock.sendPresenceUpdate('composing', jidPresencia);
|
||||
await this.delay(this.calcularDelayEscritura(texto.length));
|
||||
await this.sock.sendPresenceUpdate("paused", jidPresencia);
|
||||
|
||||
await this.sock.sendPresenceUpdate('paused', jidPresencia);
|
||||
const safeSock = wrapSocket(this.sock);
|
||||
await safeSock.sendMessage(jid, { text: texto });
|
||||
this.logger.log(`Mensaje enviado a ${jid}`);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async enviarApertura(
|
||||
telefono: string,
|
||||
mensajeApertura: string,
|
||||
): Promise<void> {
|
||||
const jid = `${telefono}@s.whatsapp.net`;
|
||||
await this.enviarMensaje(jid, mensajeApertura);
|
||||
}
|
||||
|
||||
isConectado(): boolean {
|
||||
return this.sock !== null;
|
||||
}
|
||||
|
||||
1
mvp/Whatsapp-bot/tsconfig.build.tsbuildinfo
Normal file
1
mvp/Whatsapp-bot/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
1
mvp/Whatsapp-bot/tsconfig.tsbuildinfo
Normal file
1
mvp/Whatsapp-bot/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -1,13 +1,29 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads, conversacionWhatsapp } from '@/db/schema';
|
||||
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
|
||||
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||
import { conversacionSchema } from '@/lib/funnel/bot-schemas';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Historial de la conversación de WhatsApp (orden cronológico) para que el bot recupere el contexto.
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
|
||||
const { id } = await params;
|
||||
const turnos = await db
|
||||
.select({
|
||||
rol: conversacionWhatsapp.rol,
|
||||
mensaje: conversacionWhatsapp.mensaje,
|
||||
createdAt: conversacionWhatsapp.createdAt,
|
||||
})
|
||||
.from(conversacionWhatsapp)
|
||||
.where(eq(conversacionWhatsapp.leadId, id))
|
||||
.orderBy(conversacionWhatsapp.createdAt);
|
||||
return jsonResponse(turnos, 200);
|
||||
}
|
||||
|
||||
// Añade un turno de la conversación de WhatsApp al historial del lead, y opcionalmente actualiza
|
||||
// el estado del mensaje (estado_wa) y el paso del bot (bot_step) en el lead.
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
||||
34
mvp/b2c/src/app/api/leads/[id]/route.ts
Normal file
34
mvp/b2c/src/app/api/leads/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads } from '@/db/schema';
|
||||
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Estado del lead para el bot de WhatsApp: le permite retomar la conversación (botStep, viabilidad,
|
||||
// extracción en crudo) tras un reinicio sin perder contexto. Devuelve el objeto plano (no envuelto).
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
|
||||
const { id } = await params;
|
||||
|
||||
const [lead] = await db
|
||||
.select({
|
||||
id: leads.id,
|
||||
nombre: leads.nombre,
|
||||
telefono: leads.telefono,
|
||||
botStep: leads.botStep,
|
||||
estadoWa: leads.estadoWa,
|
||||
espacio: leads.espacio,
|
||||
rangoM2: leads.rangoM2,
|
||||
estilo: leads.estilo,
|
||||
presupuestoDeclarado: leads.presupuestoDeclarado,
|
||||
viable: leads.viable,
|
||||
})
|
||||
.from(leads)
|
||||
.where(eq(leads.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!lead) return jsonResponse({ ok: false, error: 'Lead no encontrado.' }, 404);
|
||||
return jsonResponse(lead, 200);
|
||||
}
|
||||
9
mvp/image-worker/.dockerignore
Normal file
9
mvp/image-worker/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.tsbuildinfo
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
16
mvp/image-worker/.env.example
Normal file
16
mvp/image-worker/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
PORT=3001
|
||||
|
||||
# OpenRouter (unica API key para todos los modelos)
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
# Modelo para prompt-builder y supervisor (texto + vision)
|
||||
OPENROUTER_MODEL_TEXTO=anthropic/claude-3.5-haiku-20241022
|
||||
# Modelo para generacion de imagenes
|
||||
OPENROUTER_MODEL_IMAGEN=google/gemini-2.0-flash-exp-image-generation
|
||||
|
||||
# App principal Reformix
|
||||
REFORMIX_API_URL=https://reformix.dv3.com.es
|
||||
FUNNEL_API_KEY=...
|
||||
|
||||
# Comportamiento del supervisor
|
||||
MAX_RETRIES=2
|
||||
SUPERVISOR_MIN_SCORE=70
|
||||
19
mvp/image-worker/Dockerfile
Normal file
19
mvp/image-worker/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# Worker de renders (NestJS). Recibe POST /perfil-completo, genera las imágenes "después" con IA
|
||||
# (OpenRouter) y las devuelve a la app vía POST /api/leads/:id/ingesta. Stateless.
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/prompts ./prompts
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main"]
|
||||
85
mvp/image-worker/README.md
Normal file
85
mvp/image-worker/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Reformix Image Worker
|
||||
|
||||
Worker de generación de renders fotorrealistas "después" para Reformix. Recibe el perfil completo de un lead desde la app principal, genera imágenes del resultado de la reforma usando IA, las valida y las entrega de vuelta a la app.
|
||||
|
||||
## Rol en el sistema
|
||||
|
||||
```
|
||||
App Reformix
|
||||
│ POST /perfil-completo (webhook)
|
||||
▼
|
||||
Render Worker ← este proyecto
|
||||
│
|
||||
├── Etapa 1: Claude Haiku 4.5 via OpenRouter genera prompt técnico en inglés
|
||||
├── Etapa 2: Gemini 2.0 Flash via OpenRouter genera imagen
|
||||
└── Etapa 3: Claude Haiku 4.5 Vision via OpenRouter valida coherencia
|
||||
│
|
||||
│ POST /api/leads/:id/ingesta (con finalizar:true)
|
||||
▼
|
||||
App Reformix → PDF → Email → WhatsApp → Cliente
|
||||
```
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node.js 18+
|
||||
- API key de OpenRouter (unica clave para todos los modelos)
|
||||
|
||||
## Instalación
|
||||
|
||||
```bash
|
||||
cd mvp/image-worker
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Editar .env con tu OPENROUTER_API_KEY
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
| Variable | Descripción | Default |
|
||||
|----------|------------|---------|
|
||||
| `PORT` | Puerto del servidor HTTP | `3001` |
|
||||
| `OPENROUTER_API_KEY` | API key de OpenRouter (unica para todos los modelos) | — |
|
||||
| `OPENROUTER_MODEL_TEXTO` | Modelo para prompt-builder y supervisor | `anthropic/claude-3.5-haiku-20241022` |
|
||||
| `OPENROUTER_MODEL_IMAGEN` | Modelo para generación de imágenes | `google/gemini-2.0-flash-exp-image-generation` |
|
||||
| `REFORMIX_API_URL` | URL base de la app Reformix | `http://localhost:3000` |
|
||||
| `FUNNEL_API_KEY` | API key compartida con la app Reformix | — |
|
||||
| `MAX_RETRIES` | Reintentos por zona si el supervisor rechaza | `2` |
|
||||
| `SUPERVISOR_MIN_SCORE` | Score mínimo para aprobar un render (0-100) | `70` |
|
||||
|
||||
## Conexión con la app Reformix
|
||||
|
||||
En el `.env` de la app Reformix (`mvp/b2c/`), configurar:
|
||||
|
||||
```env
|
||||
PERFIL_WEBHOOK_URL=http://localhost:3001/perfil-completo
|
||||
```
|
||||
|
||||
## Pipeline de 3 etapas (todo via OpenRouter)
|
||||
|
||||
### Etapa 1 — Prompt Builder (Claude Haiku 4.5 via OpenRouter)
|
||||
Genera un prompt técnico detallado en inglés para el modelo de image-to-image. Incluye materiales, iluminación, paleta de colores, estilo y palabras clave de render arquitectónico.
|
||||
|
||||
**Modelo:** `anthropic/claude-3.5-haiku-20241022`
|
||||
**Prompt base:** `prompts/prompt-builder.txt`
|
||||
|
||||
### Etapa 2 — Image Generator (Gemini 2.0 Flash via OpenRouter)
|
||||
Toma el prompt generado + la foto "antes" del cliente y produce el render "después". Maneja rate limiting (429) con reintento automático.
|
||||
|
||||
**Modelo:** `google/gemini-2.0-flash-exp-image-generation`
|
||||
|
||||
### Etapa 3 — Supervisor (Claude Haiku 4.5 Vision via OpenRouter)
|
||||
Compara la foto "antes" con el render "después" y evalúa coherencia: estilo, materiales, calidad, artefactos. Devuelve score 0-100. Si el score es menor a `SUPERVISOR_MIN_SCORE`, reintenta desde la Etapa 2 (máximo `MAX_RETRIES` veces).
|
||||
|
||||
**Modelo:** `anthropic/claude-3.5-haiku-20241022`
|
||||
|
||||
## Skills
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---------|-------------|
|
||||
| `skills/pipeline.md` | Orquestación del pipeline de 3 etapas |
|
||||
| `skills/webhook.md` | Contrato del webhook entrante `/perfil-completo` |
|
||||
| `skills/prompt-builder.md` | Llamada a OpenRouter para generar prompts |
|
||||
| `skills/image-generator.md` | Llamada a OpenRouter para generación de imágenes |
|
||||
| `skills/supervisor.md` | Validación de calidad con Claude Haiku Vision via OpenRouter |
|
||||
| `skills/reformix-api.md` | Entrega de renders al endpoint `/ingesta` de la app |
|
||||
28
mvp/image-worker/dist/app.module.js
vendored
Normal file
28
mvp/image-worker/dist/app.module.js
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AppModule = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const webhook_module_1 = require("./webhook/webhook.module");
|
||||
const pipeline_module_1 = require("./pipeline/pipeline.module");
|
||||
const reformix_module_1 = require("./reformix/reformix.module");
|
||||
let AppModule = class AppModule {
|
||||
};
|
||||
exports.AppModule = AppModule;
|
||||
exports.AppModule = AppModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [
|
||||
config_1.ConfigModule.forRoot({ isGlobal: true }),
|
||||
webhook_module_1.WebhookModule,
|
||||
pipeline_module_1.PipelineModule,
|
||||
reformix_module_1.ReformixModule,
|
||||
],
|
||||
})
|
||||
], AppModule);
|
||||
//# sourceMappingURL=app.module.js.map
|
||||
1
mvp/image-worker/dist/app.module.js.map
vendored
Normal file
1
mvp/image-worker/dist/app.module.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"app.module.js","sourceRoot":"","sources":["../src/app.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2CAA8C;AAC9C,6DAAyD;AACzD,gEAA4D;AAC5D,gEAA4D;AAUrD,IAAM,SAAS,GAAf,MAAM,SAAS;CAAG,CAAA;AAAZ,8BAAS;oBAAT,SAAS;IARrB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE;YACP,qBAAY,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YACxC,8BAAa;YACb,gCAAc;YACd,gCAAc;SACf;KACF,CAAC;GACW,SAAS,CAAG"}
|
||||
19
mvp/image-worker/dist/main.js
vendored
Normal file
19
mvp/image-worker/dist/main.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("reflect-metadata");
|
||||
const core_1 = require("@nestjs/core");
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const app_module_1 = require("./app.module");
|
||||
async function bootstrap() {
|
||||
const app = await core_1.NestFactory.create(app_module_1.AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug'],
|
||||
});
|
||||
app.useGlobalPipes(new common_1.ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
|
||||
const config = app.get(config_1.ConfigService);
|
||||
const port = config.get('PORT', 3001);
|
||||
await app.listen(port);
|
||||
console.log(`[Reformix Image Worker] corriendo en puerto ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
//# sourceMappingURL=main.js.map
|
||||
1
mvp/image-worker/dist/main.js.map
vendored
Normal file
1
mvp/image-worker/dist/main.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";;AAAA,4BAA0B;AAC1B,uCAA2C;AAC3C,2CAAgD;AAChD,2CAA+C;AAC/C,6CAAyC;AAEzC,KAAK,UAAU,SAAS;IACtB,MAAM,GAAG,GAAG,MAAM,kBAAW,CAAC,MAAM,CAAC,sBAAS,EAAE;QAC9C,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC;KAC1C,CAAC,CAAC;IAEH,GAAG,CAAC,cAAc,CAAC,IAAI,uBAAc,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,oBAAoB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAEzG,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,sBAAa,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAC;AACrE,CAAC;AAED,SAAS,EAAE,CAAC"}
|
||||
94
mvp/image-worker/dist/pipeline/image-generator.service.js
vendored
Normal file
94
mvp/image-worker/dist/pipeline/image-generator.service.js
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var ImageGeneratorService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ImageGeneratorService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const axios_1 = require("axios");
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
let ImageGeneratorService = ImageGeneratorService_1 = class ImageGeneratorService {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.logger = new common_1.Logger(ImageGeneratorService_1.name);
|
||||
}
|
||||
async generarRender(prompt, fotoAntesDataUri) {
|
||||
const apiKey = this.config.get('OPENROUTER_API_KEY');
|
||||
const model = this.config.get('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.0-flash-exp-image-generation');
|
||||
const intentosRateLimit = 1;
|
||||
for (let attempt = 0; attempt <= intentosRateLimit; attempt++) {
|
||||
try {
|
||||
const response = await axios_1.default.post(OPENROUTER_URL, {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
const content = response.data.choices?.[0]?.message?.content;
|
||||
if (!content)
|
||||
throw new Error('OpenRouter no devolvio contenido');
|
||||
const imagen = this.extraerImagenDeRespuesta(content, response.data);
|
||||
if (!imagen)
|
||||
throw new Error('No se pudo extraer imagen de la respuesta');
|
||||
return imagen;
|
||||
}
|
||||
catch (err) {
|
||||
if (err.response?.status === 429 && attempt < intentosRateLimit) {
|
||||
this.logger.warn('Rate limit (429), esperando 5s y reintentando...');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error('Fallaron todos los intentos de generacion de imagen');
|
||||
}
|
||||
extraerImagenDeRespuesta(content, rawResponse) {
|
||||
if (content.startsWith('data:image'))
|
||||
return content;
|
||||
const dataUriMatch = content.match(/data:image\/[a-zA-Z]+;base64,[^\s"']+/);
|
||||
if (dataUriMatch)
|
||||
return dataUriMatch[0];
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"'()]+\.(png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch)
|
||||
return urlMatch[0];
|
||||
const parts = rawResponse?.choices?.[0]?.message?.content;
|
||||
if (Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
if (part.type === 'image_url' && part.image_url?.url)
|
||||
return part.image_url.url;
|
||||
if (part.image_url?.url?.startsWith('data:image'))
|
||||
return part.image_url.url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
exports.ImageGeneratorService = ImageGeneratorService;
|
||||
exports.ImageGeneratorService = ImageGeneratorService = ImageGeneratorService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [config_1.ConfigService])
|
||||
], ImageGeneratorService);
|
||||
//# sourceMappingURL=image-generator.service.js.map
|
||||
1
mvp/image-worker/dist/pipeline/image-generator.service.js.map
vendored
Normal file
1
mvp/image-worker/dist/pipeline/image-generator.service.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"image-generator.service.js","sourceRoot":"","sources":["../../src/pipeline/image-generator.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,2CAAoD;AACpD,2CAA+C;AAC/C,iCAA0B;AAE1B,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAGhE,IAAM,qBAAqB,6BAA3B,MAAM,qBAAqB;IAGhC,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;QAFjC,WAAM,GAAG,IAAI,eAAM,CAAC,uBAAqB,CAAC,IAAI,CAAC,CAAC;IAEZ,CAAC;IAEtD,KAAK,CAAC,aAAa,CAAC,MAAc,EAAE,gBAAwB;QAC1D,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,oBAAoB,CAAC,CAAC;QAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,yBAAyB,EAAE,8CAA8C,CAAC,CAAC;QAEjH,MAAM,iBAAiB,GAAG,CAAC,CAAC;QAC5B,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,iBAAiB,EAAE,OAAO,EAAE,EAAE,CAAC;YAC9D,IAAI,CAAC;gBACH,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,IAAI,CAC/B,cAAc,EACd;oBACE,KAAK;oBACL,QAAQ,EAAE;wBACR;4BACE,IAAI,EAAE,MAAM;4BACZ,OAAO,EAAE;gCACP,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE;gCAC9B,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,EAAE,GAAG,EAAE,gBAAgB,EAAE,EAAE;6BAC5D;yBACF;qBACF;iBACF,EACD;oBACE,OAAO,EAAE;wBACP,aAAa,EAAE,UAAU,MAAM,EAAE;wBACjC,cAAc,EAAE,kBAAkB;wBAClC,cAAc,EAAE,qBAAqB;wBACrC,SAAS,EAAE,uBAAuB;qBACnC;oBACD,OAAO,EAAE,KAAK;iBACf,CACF,CAAC;gBAEF,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC;gBAC7D,IAAI,CAAC,OAAO;oBAAE,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC;gBAElE,MAAM,MAAM,GAAG,IAAI,CAAC,wBAAwB,CAAC,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;gBACrE,IAAI,CAAC,MAAM;oBAAE,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;gBAE1E,OAAO,MAAM,CAAC;YAChB,CAAC;YAAC,OAAO,GAAQ,EAAE,CAAC;gBAClB,IAAI,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,GAAG,IAAI,OAAO,GAAG,iBAAiB,EAAE,CAAC;oBAChE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,kDAAkD,CAAC,CAAC;oBACrE,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;oBAC9C,SAAS;gBACX,CAAC;gBACD,MAAM,GAAG,CAAC;YACZ,CAAC;QACH,CAAC;QAED,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACzE,CAAC;IAEO,wBAAwB,CAAC,OAAe,EAAE,WAAiB;QACjE,IAAI,OAAO,CAAC,UAAU,CAAC,YAAY,CAAC;YAAE,OAAO,OAAO,CAAC;QAErD,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;QAC5E,IAAI,YAAY;YAAE,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC;QAEzC,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,6CAA6C,CAAC,CAAC;QAC9E,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC;QAEjC,MAAM,KAAK,GAAG,WAAW,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC;QAC1D,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;YACzB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;gBACzB,IAAI,IAAI,CAAC,IAAI,KAAK,WAAW,IAAI,IAAI,CAAC,SAAS,EAAE,GAAG;oBAAE,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC;gBAChF,IAAI,IAAI,CAAC,SAAS,EAAE,GAAG,EAAE,UAAU,CAAC,YAAY,CAAC;oBAAE,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC;YAC/E,CAAC;QACH,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA5EY,sDAAqB;gCAArB,qBAAqB;IADjC,IAAA,mBAAU,GAAE;qCAI0B,sBAAa;GAHvC,qBAAqB,CA4EjC"}
|
||||
26
mvp/image-worker/dist/pipeline/pipeline.module.js
vendored
Normal file
26
mvp/image-worker/dist/pipeline/pipeline.module.js
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PipelineModule = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const pipeline_service_1 = require("./pipeline.service");
|
||||
const prompt_builder_service_1 = require("./prompt-builder.service");
|
||||
const image_generator_service_1 = require("./image-generator.service");
|
||||
const supervisor_service_1 = require("./supervisor.service");
|
||||
const reformix_module_1 = require("../reformix/reformix.module");
|
||||
let PipelineModule = class PipelineModule {
|
||||
};
|
||||
exports.PipelineModule = PipelineModule;
|
||||
exports.PipelineModule = PipelineModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [reformix_module_1.ReformixModule],
|
||||
providers: [pipeline_service_1.PipelineService, prompt_builder_service_1.PromptBuilderService, image_generator_service_1.ImageGeneratorService, supervisor_service_1.SupervisorService],
|
||||
exports: [pipeline_service_1.PipelineService],
|
||||
})
|
||||
], PipelineModule);
|
||||
//# sourceMappingURL=pipeline.module.js.map
|
||||
1
mvp/image-worker/dist/pipeline/pipeline.module.js.map
vendored
Normal file
1
mvp/image-worker/dist/pipeline/pipeline.module.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"pipeline.module.js","sourceRoot":"","sources":["../../src/pipeline/pipeline.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,yDAAqD;AACrD,qEAAgE;AAChE,uEAAkE;AAClE,6DAAyD;AACzD,iEAA6D;AAOtD,IAAM,cAAc,GAApB,MAAM,cAAc;CAAG,CAAA;AAAjB,wCAAc;yBAAd,cAAc;IAL1B,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,gCAAc,CAAC;QACzB,SAAS,EAAE,CAAC,kCAAe,EAAE,6CAAoB,EAAE,+CAAqB,EAAE,sCAAiB,CAAC;QAC5F,OAAO,EAAE,CAAC,kCAAe,CAAC;KAC3B,CAAC;GACW,cAAc,CAAG"}
|
||||
96
mvp/image-worker/dist/pipeline/pipeline.service.js
vendored
Normal file
96
mvp/image-worker/dist/pipeline/pipeline.service.js
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var PipelineService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PipelineService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const prompt_builder_service_1 = require("./prompt-builder.service");
|
||||
const image_generator_service_1 = require("./image-generator.service");
|
||||
const supervisor_service_1 = require("./supervisor.service");
|
||||
const reformix_service_1 = require("../reformix/reformix.service");
|
||||
let PipelineService = PipelineService_1 = class PipelineService {
|
||||
constructor(config, promptBuilder, imageGenerator, supervisor, reformix) {
|
||||
this.config = config;
|
||||
this.promptBuilder = promptBuilder;
|
||||
this.imageGenerator = imageGenerator;
|
||||
this.supervisor = supervisor;
|
||||
this.reformix = reformix;
|
||||
this.logger = new common_1.Logger(PipelineService_1.name);
|
||||
this.maxRetries = this.config.get('MAX_RETRIES', 2);
|
||||
this.minScore = this.config.get('SUPERVISOR_MIN_SCORE', 70);
|
||||
}
|
||||
async procesarLead(dto) {
|
||||
const { leadId, reforma, zonas } = dto;
|
||||
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
|
||||
const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0);
|
||||
this.logger.log(`[${leadId}] Iniciando pipeline para ${zonasConFotos.length} zonas`);
|
||||
for (const z of zonasSaltadas) {
|
||||
this.logger.log(`[${leadId}] Zona ${z.zona}: sin fotos "antes", saltando`);
|
||||
}
|
||||
const renders = [];
|
||||
for (const zona of zonasConFotos) {
|
||||
try {
|
||||
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]);
|
||||
renders.push(render);
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (renders.length === 0) {
|
||||
this.logger.warn(`[${leadId}] No se generaron renders para ninguna zona`);
|
||||
return;
|
||||
}
|
||||
const items = renders.map((r) => ({
|
||||
zona: r.zona,
|
||||
imagen: r.imagen,
|
||||
}));
|
||||
const ok = await this.reformix.entregarRenders(leadId, items);
|
||||
if (ok) {
|
||||
this.logger.log(`[${leadId}] Renders entregados correctamente (${renders.length} zonas)`);
|
||||
}
|
||||
else {
|
||||
this.logger.error(`[${leadId}] Error entregando renders a la app principal`);
|
||||
}
|
||||
}
|
||||
async procesarZona(leadId, zona, reforma, notas, fotoAntes) {
|
||||
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
|
||||
let ultimaImagen = null;
|
||||
for (let intento = 0; intento <= this.maxRetries; intento++) {
|
||||
if (intento > 0) {
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: reintento ${intento} de ${this.maxRetries}`);
|
||||
}
|
||||
const imagen = await this.imageGenerator.generarRender(prompt, fotoAntes);
|
||||
ultimaImagen = imagen;
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: imagen generada`);
|
||||
const resultado = await this.supervisor.supervisar(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas, fotoAntes, imagen);
|
||||
const aprobada = resultado.aprobado && resultado.score >= this.minScore;
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: ${aprobada ? 'aprobada' : 'rechazada'} (score: ${resultado.score}) - ${resultado.motivo}`);
|
||||
if (aprobada) {
|
||||
return { zona, imagen, score: resultado.score, aprobada: true };
|
||||
}
|
||||
}
|
||||
this.logger.warn(`[${leadId}] Zona ${zona}: usando ultimo render pese a no superar validacion`);
|
||||
return { zona, imagen: ultimaImagen, score: 0, aprobada: false };
|
||||
}
|
||||
};
|
||||
exports.PipelineService = PipelineService;
|
||||
exports.PipelineService = PipelineService = PipelineService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [config_1.ConfigService,
|
||||
prompt_builder_service_1.PromptBuilderService,
|
||||
image_generator_service_1.ImageGeneratorService,
|
||||
supervisor_service_1.SupervisorService,
|
||||
reformix_service_1.ReformixService])
|
||||
], PipelineService);
|
||||
//# sourceMappingURL=pipeline.service.js.map
|
||||
1
mvp/image-worker/dist/pipeline/pipeline.service.js.map
vendored
Normal file
1
mvp/image-worker/dist/pipeline/pipeline.service.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"pipeline.service.js","sourceRoot":"","sources":["../../src/pipeline/pipeline.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,2CAAoD;AACpD,2CAA+C;AAE/C,qEAAgE;AAChE,uEAAkE;AAClE,6DAAyD;AACzD,mEAA+D;AAUxD,IAAM,eAAe,uBAArB,MAAM,eAAe;IAK1B,YACmB,MAAqB,EACrB,aAAmC,EACnC,cAAqC,EACrC,UAA6B,EAC7B,QAAyB;QAJzB,WAAM,GAAN,MAAM,CAAe;QACrB,kBAAa,GAAb,aAAa,CAAsB;QACnC,mBAAc,GAAd,cAAc,CAAuB;QACrC,eAAU,GAAV,UAAU,CAAmB;QAC7B,aAAQ,GAAR,QAAQ,CAAiB;QAT3B,WAAM,GAAG,IAAI,eAAM,CAAC,iBAAe,CAAC,IAAI,CAAC,CAAC;QAWzD,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,aAAa,EAAE,CAAC,CAAC,CAAC;QAC5D,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,sBAAsB,EAAE,EAAE,CAAC,CAAC;IACtE,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,GAAsB;QACvC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC;QACvC,MAAM,aAAa,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACpE,MAAM,aAAa,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC;QAEtE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,MAAM,6BAA6B,aAAa,CAAC,MAAM,QAAQ,CAAC,CAAC;QAErF,KAAK,MAAM,CAAC,IAAI,aAAa,EAAE,CAAC;YAC9B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,MAAM,UAAU,CAAC,CAAC,IAAI,+BAA+B,CAAC,CAAC;QAC7E,CAAC;QAED,MAAM,OAAO,GAAiB,EAAE,CAAC;QAEjC,KAAK,MAAM,IAAI,IAAI,aAAa,EAAE,CAAC;YACjC,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;gBACpG,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACvB,CAAC;YAAC,OAAO,GAAQ,EAAE,CAAC;gBAClB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,MAAM,UAAU,IAAI,CAAC,IAAI,kBAAkB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YAClF,CAAC;QACH,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACzB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,MAAM,6CAA6C,CAAC,CAAC;YAC1E,OAAO;QACT,CAAC;QAED,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAChC,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,MAAM,EAAE,CAAC,CAAC,MAAM;SACjB,CAAC,CAAC,CAAC;QAEJ,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QAC9D,IAAI,EAAE,EAAE,CAAC;YACP,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,MAAM,uCAAuC,OAAO,CAAC,MAAM,SAAS,CAAC,CAAC;QAC5F,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,MAAM,+CAA+C,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC;IAEO,KAAK,CAAC,YAAY,CACxB,MAAc,EACd,IAAY,EACZ,OAAqC,EACrC,KAAe,EACf,SAAiB;QAEjB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,aAAa,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QAC7G,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,MAAM,UAAU,IAAI,mBAAmB,CAAC,CAAC;QAE7D,IAAI,YAAY,GAAkB,IAAI,CAAC;QAEvC,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,IAAI,CAAC,UAAU,EAAE,OAAO,EAAE,EAAE,CAAC;YAC5D,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;gBAChB,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,MAAM,UAAU,IAAI,eAAe,OAAO,OAAO,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC;YAC1F,CAAC;YAED,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;YAC1E,YAAY,GAAG,MAAM,CAAC;YACtB,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,MAAM,UAAU,IAAI,mBAAmB,CAAC,CAAC;YAE7D,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,UAAU,CAChD,OAAO,CAAC,IAAI,EACZ,OAAO,CAAC,OAAO,EACf,OAAO,CAAC,OAAO,EACf,KAAK,EACL,SAAS,EACT,MAAM,CACP,CAAC;YAEF,MAAM,QAAQ,GAAG,SAAS,CAAC,QAAQ,IAAI,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC;YACxE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,MAAM,UAAU,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,WAAW,YAAY,SAAS,CAAC,KAAK,OAAO,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;YAEtI,IAAI,QAAQ,EAAE,CAAC;gBACb,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YAClE,CAAC;QACH,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,MAAM,UAAU,IAAI,qDAAqD,CAAC,CAAC;QAChG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,YAAa,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IACpE,CAAC;CACF,CAAA;AAjGY,0CAAe;0BAAf,eAAe;IAD3B,IAAA,mBAAU,GAAE;qCAOgB,sBAAa;QACN,6CAAoB;QACnB,+CAAqB;QACzB,sCAAiB;QACnB,kCAAe;GAVjC,eAAe,CAiG3B"}
|
||||
74
mvp/image-worker/dist/pipeline/prompt-builder.service.js
vendored
Normal file
74
mvp/image-worker/dist/pipeline/prompt-builder.service.js
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var PromptBuilderService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PromptBuilderService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const axios_1 = require("axios");
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
let PromptBuilderService = PromptBuilderService_1 = class PromptBuilderService {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.logger = new common_1.Logger(PromptBuilderService_1.name);
|
||||
this.systemPrompt = '';
|
||||
const ruta = path.join(process.cwd(), 'prompts', 'prompt-builder.txt');
|
||||
if (fs.existsSync(ruta)) {
|
||||
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
|
||||
}
|
||||
else {
|
||||
this.logger.warn('prompts/prompt-builder.txt no encontrado, usando default');
|
||||
}
|
||||
}
|
||||
async generarPrompt(tipoReforma, m2Suelo, calidad, notas) {
|
||||
const apiKey = this.config.get('OPENROUTER_API_KEY');
|
||||
const model = this.config.get('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
|
||||
const userContent = `Generate a render prompt for a ${tipoReforma} renovation.
|
||||
- Area: ${m2Suelo ?? 'unknown'} m²
|
||||
- Quality level: ${calidad}
|
||||
- Client notes: ${notas.join('; ') || 'none'}
|
||||
- Style: modern ${tipoReforma} renovation`;
|
||||
try {
|
||||
const response = await axios_1.default.post(OPENROUTER_URL, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0.5,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
});
|
||||
const prompt = response.data.choices?.[0]?.message?.content?.trim();
|
||||
if (!prompt)
|
||||
throw new Error('OpenRouter devolvio respuesta vacia');
|
||||
return prompt;
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error(`Error generando prompt: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.PromptBuilderService = PromptBuilderService;
|
||||
exports.PromptBuilderService = PromptBuilderService = PromptBuilderService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [config_1.ConfigService])
|
||||
], PromptBuilderService);
|
||||
//# sourceMappingURL=prompt-builder.service.js.map
|
||||
1
mvp/image-worker/dist/pipeline/prompt-builder.service.js.map
vendored
Normal file
1
mvp/image-worker/dist/pipeline/prompt-builder.service.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"prompt-builder.service.js","sourceRoot":"","sources":["../../src/pipeline/prompt-builder.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,2CAAoD;AACpD,2CAA+C;AAC/C,yBAAyB;AACzB,6BAA6B;AAC7B,iCAA0B;AAE1B,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAGhE,IAAM,oBAAoB,4BAA1B,MAAM,oBAAoB;IAI/B,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;QAHjC,WAAM,GAAG,IAAI,eAAM,CAAC,sBAAoB,CAAC,IAAI,CAAC,CAAC;QACxD,iBAAY,GAAG,EAAE,CAAC;QAGxB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;QACvE,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,0DAA0D,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC;IAED,KAAK,CAAC,aAAa,CACjB,WAAmB,EACnB,OAAsB,EACtB,OAAe,EACf,KAAe;QAEf,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,oBAAoB,CAAC,CAAC;QAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,wBAAwB,EAAE,qCAAqC,CAAC,CAAC;QAEvG,MAAM,WAAW,GAAG,kCAAkC,WAAW;UAC3D,OAAO,IAAI,SAAS;mBACX,OAAO;kBACR,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,MAAM;kBAC1B,WAAW,aAAa,CAAC;QAEvC,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,IAAI,CAC/B,cAAc,EACd;gBACE,KAAK;gBACL,QAAQ,EAAE;oBACR,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,YAAY,EAAE;oBAC9C,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE;iBACvC;gBACD,UAAU,EAAE,GAAG;gBACf,WAAW,EAAE,GAAG;aACjB,EACD;gBACE,OAAO,EAAE;oBACP,aAAa,EAAE,UAAU,MAAM,EAAE;oBACjC,cAAc,EAAE,kBAAkB;oBAClC,cAAc,EAAE,qBAAqB;oBACrC,SAAS,EAAE,uBAAuB;iBACnC;aACF,CACF,CAAC;YAEF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YACpE,IAAI,CAAC,MAAM;gBAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;YACpE,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,2BAA2B,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YAC5D,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;CACF,CAAA;AA1DY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,mBAAU,GAAE;qCAK0B,sBAAa;GAJvC,oBAAoB,CA0DhC"}
|
||||
104
mvp/image-worker/dist/pipeline/supervisor.service.js
vendored
Normal file
104
mvp/image-worker/dist/pipeline/supervisor.service.js
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var SupervisorService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SupervisorService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const axios_1 = require("axios");
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
let SupervisorService = SupervisorService_1 = class SupervisorService {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.logger = new common_1.Logger(SupervisorService_1.name);
|
||||
this.systemPrompt = '';
|
||||
const ruta = path.join(process.cwd(), 'prompts', 'supervisor.txt');
|
||||
if (fs.existsSync(ruta)) {
|
||||
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
|
||||
}
|
||||
else {
|
||||
this.logger.warn('prompts/supervisor.txt no encontrado, usando default');
|
||||
}
|
||||
}
|
||||
async supervisar(tipoReforma, m2Suelo, calidad, notas, fotoAntes, renderDespues) {
|
||||
const apiKey = this.config.get('OPENROUTER_API_KEY');
|
||||
const model = this.config.get('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
|
||||
const notasTexto = notas.join('; ') || 'sin notas';
|
||||
try {
|
||||
const response = await axios_1.default.post(OPENROUTER_URL, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reforma tipo: ${tipoReforma}\nMetros: ${m2Suelo ?? 'desconocido'}\nCalidad: ${calidad}\nNotas del cliente: ${notasTexto}`,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: fotoAntes },
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: renderDespues },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 256,
|
||||
temperature: 0.2,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
});
|
||||
const textContent = response.data.choices?.[0]?.message?.content?.trim();
|
||||
if (!textContent) {
|
||||
return { aprobado: false, score: 0, motivo: 'Modelo devolvio respuesta vacia' };
|
||||
}
|
||||
return this.parsearRespuesta(textContent);
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error(`Error en supervisor: ${err.message}`);
|
||||
return { aprobado: false, score: 0, motivo: `Error del supervisor: ${err.message}` };
|
||||
}
|
||||
}
|
||||
parsearRespuesta(texto) {
|
||||
const jsonMatch = texto.match(/\{[^{}]*"aprobado"[^{}]*\}/i);
|
||||
if (!jsonMatch) {
|
||||
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
aprobado: Boolean(parsed.aprobado),
|
||||
score: Math.min(100, Math.max(0, Number(parsed.score) || 0)),
|
||||
motivo: String(parsed.motivo || 'Sin motivo'),
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.SupervisorService = SupervisorService;
|
||||
exports.SupervisorService = SupervisorService = SupervisorService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [config_1.ConfigService])
|
||||
], SupervisorService);
|
||||
//# sourceMappingURL=supervisor.service.js.map
|
||||
1
mvp/image-worker/dist/pipeline/supervisor.service.js.map
vendored
Normal file
1
mvp/image-worker/dist/pipeline/supervisor.service.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"supervisor.service.js","sourceRoot":"","sources":["../../src/pipeline/supervisor.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,2CAAoD;AACpD,2CAA+C;AAC/C,yBAAyB;AACzB,6BAA6B;AAC7B,iCAA0B;AAE1B,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAShE,IAAM,iBAAiB,yBAAvB,MAAM,iBAAiB;IAI5B,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;QAHjC,WAAM,GAAG,IAAI,eAAM,CAAC,mBAAiB,CAAC,IAAI,CAAC,CAAC;QACrD,iBAAY,GAAG,EAAE,CAAC;QAGxB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,gBAAgB,CAAC,CAAC;QACnE,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,sDAAsD,CAAC,CAAC;QAC3E,CAAC;IACH,CAAC;IAED,KAAK,CAAC,UAAU,CACd,WAAmB,EACnB,OAAsB,EACtB,OAAe,EACf,KAAe,EACf,SAAiB,EACjB,aAAqB;QAErB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,oBAAoB,CAAC,CAAC;QAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,wBAAwB,EAAE,qCAAqC,CAAC,CAAC;QAEvG,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,WAAW,CAAC;QAEnD,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,IAAI,CAC/B,cAAc,EACd;gBACE,KAAK;gBACL,QAAQ,EAAE;oBACR,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,YAAY,EAAE;oBAC9C;wBACE,IAAI,EAAE,MAAM;wBACZ,OAAO,EAAE;4BACP;gCACE,IAAI,EAAE,MAAM;gCACZ,IAAI,EAAE,iBAAiB,WAAW,aAAa,OAAO,IAAI,aAAa,cAAc,OAAO,wBAAwB,UAAU,EAAE;6BACjI;4BACD;gCACE,IAAI,EAAE,WAAW;gCACjB,SAAS,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE;6BAC9B;4BACD;gCACE,IAAI,EAAE,WAAW;gCACjB,SAAS,EAAE,EAAE,GAAG,EAAE,aAAa,EAAE;6BAClC;yBACF;qBACF;iBACF;gBACD,UAAU,EAAE,GAAG;gBACf,WAAW,EAAE,GAAG;aACjB,EACD;gBACE,OAAO,EAAE;oBACP,aAAa,EAAE,UAAU,MAAM,EAAE;oBACjC,cAAc,EAAE,kBAAkB;oBAClC,cAAc,EAAE,qBAAqB;oBACrC,SAAS,EAAE,uBAAuB;iBACnC;aACF,CACF,CAAC;YAEF,MAAM,WAAW,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YACzE,IAAI,CAAC,WAAW,EAAE,CAAC;gBACjB,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,iCAAiC,EAAE,CAAC;YAClF,CAAC;YAED,OAAO,IAAI,CAAC,gBAAgB,CAAC,WAAW,CAAC,CAAC;QAC5C,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,wBAAwB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YACzD,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,yBAAyB,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC;QACvF,CAAC;IACH,CAAC;IAEO,gBAAgB,CAAC,KAAa;QACpC,MAAM,SAAS,GAAG,KAAK,CAAC,KAAK,CAAC,6BAA6B,CAAC,CAAC;QAC7D,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,0CAA0C,EAAE,CAAC;QAC3F,CAAC;QAED,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;YACxC,OAAO;gBACL,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC;gBAClC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;gBAC5D,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,IAAI,YAAY,CAAC;aAC9C,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,0CAA0C,EAAE,CAAC;QAC3F,CAAC;IACH,CAAC;CACF,CAAA;AA7FY,8CAAiB;4BAAjB,iBAAiB;IAD7B,IAAA,mBAAU,GAAE;qCAK0B,sBAAa;GAJvC,iBAAiB,CA6F7B"}
|
||||
47
mvp/image-worker/dist/webhook/webhook.controller.js
vendored
Normal file
47
mvp/image-worker/dist/webhook/webhook.controller.js
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
var WebhookController_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WebhookController = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const webhook_dto_1 = require("./webhook.dto");
|
||||
const pipeline_service_1 = require("../pipeline/pipeline.service");
|
||||
let WebhookController = WebhookController_1 = class WebhookController {
|
||||
constructor(pipelineService) {
|
||||
this.pipelineService = pipelineService;
|
||||
this.logger = new common_1.Logger(WebhookController_1.name);
|
||||
}
|
||||
recibirPerfil(dto) {
|
||||
this.logger.log(`[${dto.leadId}] Webhook recibido: ${dto.zonas.length} zonas`);
|
||||
setImmediate(() => {
|
||||
this.pipelineService.procesarLead(dto).catch((err) => {
|
||||
this.logger.error(`[${dto.leadId}] Pipeline fallo: ${err.message}`, err.stack);
|
||||
});
|
||||
});
|
||||
return { ok: true, message: 'Procesando renders en background...' };
|
||||
}
|
||||
};
|
||||
exports.WebhookController = WebhookController;
|
||||
__decorate([
|
||||
(0, common_1.Post)('perfil-completo'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [webhook_dto_1.PerfilCompletoDto]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], WebhookController.prototype, "recibirPerfil", null);
|
||||
exports.WebhookController = WebhookController = WebhookController_1 = __decorate([
|
||||
(0, common_1.Controller)(),
|
||||
__metadata("design:paramtypes", [pipeline_service_1.PipelineService])
|
||||
], WebhookController);
|
||||
//# sourceMappingURL=webhook.controller.js.map
|
||||
1
mvp/image-worker/dist/webhook/webhook.controller.js.map
vendored
Normal file
1
mvp/image-worker/dist/webhook/webhook.controller.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"webhook.controller.js","sourceRoot":"","sources":["../../src/webhook/webhook.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2CAAgE;AAChE,+CAAkD;AAClD,mEAA+D;AAGxD,IAAM,iBAAiB,yBAAvB,MAAM,iBAAiB;IAG5B,YAA6B,eAAgC;QAAhC,oBAAe,GAAf,eAAe,CAAiB;QAF5C,WAAM,GAAG,IAAI,eAAM,CAAC,mBAAiB,CAAC,IAAI,CAAC,CAAC;IAEG,CAAC;IAGjE,aAAa,CAAS,GAAsB;QAC1C,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,uBAAuB,GAAG,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,CAAC;QAE/E,YAAY,CAAC,GAAG,EAAE;YAChB,IAAI,CAAC,eAAe,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;gBACnD,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,MAAM,qBAAqB,GAAG,CAAC,OAAO,EAAE,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;YACjF,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,qCAAqC,EAAE,CAAC;IACtE,CAAC;CACF,CAAA;AAjBY,8CAAiB;AAM5B;IADC,IAAA,aAAI,EAAC,iBAAiB,CAAC;IACT,WAAA,IAAA,aAAI,GAAE,CAAA;;qCAAM,+BAAiB;;sDAU3C;4BAhBU,iBAAiB;IAD7B,IAAA,mBAAU,GAAE;qCAImC,kCAAe;GAHlD,iBAAiB,CAiB7B"}
|
||||
22
mvp/image-worker/dist/webhook/webhook.module.js
vendored
Normal file
22
mvp/image-worker/dist/webhook/webhook.module.js
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WebhookModule = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const webhook_controller_1 = require("./webhook.controller");
|
||||
const pipeline_module_1 = require("../pipeline/pipeline.module");
|
||||
let WebhookModule = class WebhookModule {
|
||||
};
|
||||
exports.WebhookModule = WebhookModule;
|
||||
exports.WebhookModule = WebhookModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [pipeline_module_1.PipelineModule],
|
||||
controllers: [webhook_controller_1.WebhookController],
|
||||
})
|
||||
], WebhookModule);
|
||||
//# sourceMappingURL=webhook.module.js.map
|
||||
1
mvp/image-worker/dist/webhook/webhook.module.js.map
vendored
Normal file
1
mvp/image-worker/dist/webhook/webhook.module.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"webhook.module.js","sourceRoot":"","sources":["../../src/webhook/webhook.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,6DAAyD;AACzD,iEAA6D;AAMtD,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IAJzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,gCAAc,CAAC;QACzB,WAAW,EAAE,CAAC,sCAAiB,CAAC;KACjC,CAAC;GACW,aAAa,CAAG"}
|
||||
4833
mvp/image-worker/package-lock.json
generated
Normal file
4833
mvp/image-worker/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
mvp/image-worker/package.json
Normal file
39
mvp/image-worker/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "reformix-image-worker",
|
||||
"version": "1.0.0",
|
||||
"description": "Worker de generación de renders fotorrealistas para Reformix. Recibe el perfil del lead, genera imágenes 'después' con IA y las entrega a la app principal.",
|
||||
"author": "Reformix",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"class-transformer": "^0.5.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"ts-node": "^10.9.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
16
mvp/image-worker/prompts/prompt-builder.txt
Normal file
16
mvp/image-worker/prompts/prompt-builder.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
You are an expert interior designer and architectural renderer.
|
||||
Your task is to generate a detailed, technical prompt in English for an image-to-image AI model that will transform a real photo of a space into a photorealistic render of the completed renovation.
|
||||
|
||||
The prompt must include:
|
||||
- Specific materials and finishes (tile type, countertop material, flooring)
|
||||
- Lighting style (natural, warm artificial, accent)
|
||||
- Color palette aligned with the quality level
|
||||
- Style and atmosphere based on client notes
|
||||
- Technical rendering keywords: photorealistic, 8k, architectural visualization, professional interior photography, high detail
|
||||
|
||||
Quality level guide:
|
||||
- basica: standard materials, functional design, clean finishes
|
||||
- media: mid-range materials, modern design, quality finishes
|
||||
- premium: high-end materials, designer touches, luxury finishes
|
||||
|
||||
Output ONLY the image prompt, no explanations, no preamble.
|
||||
16
mvp/image-worker/prompts/supervisor.txt
Normal file
16
mvp/image-worker/prompts/supervisor.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
You are a quality supervisor for architectural renovation renders.
|
||||
Evaluate whether the generated "after" render is coherent with the client's brief and the "before" photo.
|
||||
|
||||
Evaluate these criteria:
|
||||
1. Style consistency with client notes and reform type
|
||||
2. Materials and finishes match the quality level (basica/media/premium)
|
||||
3. The renovated area is clearly recognizable from the "before" photo
|
||||
4. Photorealistic quality and professional appearance
|
||||
5. No obvious AI artifacts, distortions or incoherent elements
|
||||
|
||||
Respond ONLY with valid JSON, no markdown, no explanation:
|
||||
{
|
||||
"aprobado": boolean,
|
||||
"score": number between 0 and 100,
|
||||
"motivo": "reason in Spanish"
|
||||
}
|
||||
37
mvp/image-worker/skills/image-generator.md
Normal file
37
mvp/image-worker/skills/image-generator.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generador de imágenes — via OpenRouter
|
||||
|
||||
## Responsabilidad
|
||||
Recibir un prompt en inglés y una foto "antes", devolver el render "después" como data URI base64.
|
||||
|
||||
## Llamada a OpenRouter
|
||||
POST https://openrouter.ai/api/v1/chat/completions
|
||||
Authorization: Bearer {OPENROUTER_API_KEY}
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"model": "{OPENROUTER_MODEL_IMAGEN}", // google/gemini-2.0-flash-exp-image-generation
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": promptGenerado },
|
||||
{ "type": "image_url", "image_url": { "url": fotoAntesDataUri } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Manejo de la respuesta
|
||||
Extraer la imagen generada de la respuesta. Buscar en:
|
||||
1. content directo como data URI
|
||||
2. expresion regular data:image/...;base64,...
|
||||
3. URL de imagen
|
||||
4. Partes del mensaje (choices[0].message.content si es array)
|
||||
|
||||
Devolver siempre como data:image/png;base64,...
|
||||
|
||||
## Errores
|
||||
- Error de red → lanzar excepción, pipeline.service reintentará
|
||||
- Respuesta 429 (rate limit) → esperar 5s y reintentar 1 vez
|
||||
- Respuesta 5xx → lanzar excepción inmediatamente
|
||||
26
mvp/image-worker/skills/pipeline.md
Normal file
26
mvp/image-worker/skills/pipeline.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Pipeline de 3 etapas
|
||||
|
||||
## Responsabilidad
|
||||
Orquestar el procesamiento completo de un lead: desde recibir el perfil hasta entregar los renders a la app principal.
|
||||
|
||||
## Flujo por zona
|
||||
Para cada zona del lead que tenga fotos "antes":
|
||||
1. Etapa 1 → prompt-builder genera el prompt en inglés
|
||||
2. Etapa 2 → image-generator produce el render
|
||||
3. Etapa 3 → supervisor valida la coherencia
|
||||
4. Si rechazado → reintentar máximo MAX_RETRIES veces desde Etapa 2
|
||||
5. Si sigue rechazado → usar el último render de todos modos y loguear
|
||||
|
||||
## Reglas
|
||||
- Zonas sin fotos "antes": saltar y loguear, nunca lanzar error
|
||||
- Procesar todas las zonas antes de llamar a /ingesta
|
||||
- Enviar todos los renders en una sola llamada con finalizar: true
|
||||
- El pipeline corre en background, no bloquea el webhook
|
||||
|
||||
## Logs obligatorios
|
||||
- [leadId] Iniciando pipeline para N zonas
|
||||
- [leadId] Zona X: prompt generado
|
||||
- [leadId] Zona X: imagen generada
|
||||
- [leadId] Zona X: aprobada/rechazada (score: N)
|
||||
- [leadId] Zona X: reintento N de MAX_RETRIES
|
||||
- [leadId] Renders entregados correctamente
|
||||
26
mvp/image-worker/skills/prompt-builder.md
Normal file
26
mvp/image-worker/skills/prompt-builder.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Prompt Builder — Claude Haiku 4.5 via OpenRouter
|
||||
|
||||
## Responsabilidad
|
||||
Generar un prompt técnico detallado en inglés para el modelo de image-to-image a partir del contexto del lead (tipo de reforma, m², calidad, notas del cliente).
|
||||
|
||||
## Llamada a OpenRouter
|
||||
POST https://openrouter.ai/api/v1/chat/completions
|
||||
Authorization: Bearer {OPENROUTER_API_KEY}
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"model": "{OPENROUTER_MODEL_TEXTO}", // anthropic/claude-3.5-haiku-20241022
|
||||
"messages": [
|
||||
{ "role": "system", "content": "You are an expert interior designer..." },
|
||||
{ "role": "user", "content": "Generate a render prompt for a cocina renovation..." }
|
||||
],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
## System prompt
|
||||
Contenido de prompts/prompt-builder.txt
|
||||
|
||||
## Respuesta
|
||||
Texto plano con el prompt en inglés. Sin markdown, sin JSON, sin explicaciones.
|
||||
40
mvp/image-worker/skills/reformix-api.md
Normal file
40
mvp/image-worker/skills/reformix-api.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# API de la app principal Reformix
|
||||
|
||||
## Responsabilidad
|
||||
Entregar los renders generados al endpoint /ingesta de la app Reformix.
|
||||
|
||||
## Endpoint
|
||||
POST {REFORMIX_API_URL}/api/leads/{leadId}/ingesta
|
||||
Authorization: Bearer {FUNNEL_API_KEY}
|
||||
Content-Type: application/json
|
||||
|
||||
## Body
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"tipo": "foto",
|
||||
"zona": "cocina", // zona que se procesó
|
||||
"momento": "despues", // siempre "despues" para renders generados
|
||||
"imagen": "data:image/png;base64,..."
|
||||
}
|
||||
// un item por cada zona procesada
|
||||
],
|
||||
"finalizar": true // siempre true, dispara PDF + email + WhatsApp
|
||||
}
|
||||
|
||||
## Enums válidos (no usar otros valores)
|
||||
tipo item: "foto" | "texto"
|
||||
momento: "antes" | "despues"
|
||||
zona: "cocina" | "bano" | "salon" | "comedor" | "integral" | "otro"
|
||||
|
||||
## Códigos de respuesta
|
||||
200 { ok: true } → éxito
|
||||
401 → FUNNEL_API_KEY incorrecta
|
||||
404 → leadId no existe, no reintentar
|
||||
422 → payload mal formado, revisar el body
|
||||
|
||||
## Reintentos
|
||||
En caso de error 5xx o error de red:
|
||||
→ reintentar 3 veces con 2 segundos de espera entre intentos
|
||||
→ si sigue fallando, loguear como error crítico con el leadId
|
||||
→ nunca reintentar en caso de 404
|
||||
43
mvp/image-worker/skills/supervisor.md
Normal file
43
mvp/image-worker/skills/supervisor.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Supervisor de calidad — Claude Haiku 4.5 Vision via OpenRouter
|
||||
|
||||
## Responsabilidad
|
||||
Comparar la foto "antes" con el render "después" y verificar que el resultado es coherente
|
||||
con el brief del cliente. Devolver aprobación y score.
|
||||
|
||||
## Llamada a OpenRouter
|
||||
POST https://openrouter.ai/api/v1/chat/completions
|
||||
Authorization: Bearer {OPENROUTER_API_KEY}
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"model": "{OPENROUTER_MODEL_TEXTO}", // anthropic/claude-3.5-haiku-20241022
|
||||
"messages": [
|
||||
{ "role": "system", "content": "You are a quality supervisor..." },
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Reforma tipo: cocina\nMetros: 12..." },
|
||||
{ "type": "image_url", "image_url": { "url": "data:image/...foto_antes" } },
|
||||
{ "type": "image_url", "image_url": { "url": "data:image/...render_despues" } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.2
|
||||
}
|
||||
|
||||
## System prompt
|
||||
Contenido de prompts/supervisor.txt
|
||||
|
||||
## Respuesta esperada
|
||||
JSON estricto:
|
||||
{ "aprobado": boolean, "score": number, "motivo": string }
|
||||
|
||||
## Manejo de respuesta malformada
|
||||
Si el modelo no devuelve JSON válido:
|
||||
→ devolver { aprobado: false, score: 0, motivo: "Error parseando respuesta del supervisor" }
|
||||
Nunca lanzar excepción desde el supervisor, siempre devolver el objeto.
|
||||
|
||||
## Umbral de aprobación
|
||||
aprobado: true AND score >= SUPERVISOR_MIN_SCORE (del .env, por defecto 70)
|
||||
35
mvp/image-worker/skills/webhook.md
Normal file
35
mvp/image-worker/skills/webhook.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Webhook entrante — /perfil-completo
|
||||
|
||||
## Responsabilidad
|
||||
Recibir el payload de la app Reformix, validarlo y arrancar el pipeline en background.
|
||||
|
||||
## Endpoint
|
||||
POST /perfil-completo
|
||||
|
||||
## Respuesta inmediata (siempre)
|
||||
{ "ok": true, "message": "Procesando renders en background..." }
|
||||
Nunca bloquear esperando al pipeline.
|
||||
|
||||
## Payload esperado
|
||||
{
|
||||
leadId: string (UUID),
|
||||
cliente: { nombre, telefono, email, provincia },
|
||||
reforma: {
|
||||
tipo: "cocina" | "bano" | "salon" | "comedor" | "integral" | "otro",
|
||||
m2Suelo: number,
|
||||
calidad: "basica" | "media" | "premium",
|
||||
estructural: boolean,
|
||||
urgencia: "alta" | "media" | "baja",
|
||||
presupuestoTarget: number // en céntimos
|
||||
},
|
||||
empresa: { tenantId: string, nombre: string },
|
||||
zonas: Array<{
|
||||
zona: string,
|
||||
notas: string[],
|
||||
fotos: { antes: string[], despues: string[] }
|
||||
}>
|
||||
}
|
||||
|
||||
## Validación
|
||||
Usar class-validator con decoradores en webhook.dto.ts.
|
||||
Si el payload no es válido → responder 400 con el error, no arrancar el pipeline.
|
||||
15
mvp/image-worker/src/app.module.ts
Normal file
15
mvp/image-worker/src/app.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { WebhookModule } from './webhook/webhook.module';
|
||||
import { PipelineModule } from './pipeline/pipeline.module';
|
||||
import { ReformixModule } from './reformix/reformix.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
WebhookModule,
|
||||
PipelineModule,
|
||||
ReformixModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
20
mvp/image-worker/src/main.ts
Normal file
20
mvp/image-worker/src/main.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug'],
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
|
||||
|
||||
const config = app.get(ConfigService);
|
||||
const port = config.get('PORT', 3001);
|
||||
await app.listen(port);
|
||||
console.log(`[Reformix Image Worker] corriendo en puerto ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
84
mvp/image-worker/src/pipeline/image-generator.service.ts
Normal file
84
mvp/image-worker/src/pipeline/image-generator.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
@Injectable()
|
||||
export class ImageGeneratorService {
|
||||
private readonly logger = new Logger(ImageGeneratorService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async generarRender(prompt: string, fotoAntesDataUri: string): Promise<string> {
|
||||
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||
const model = this.config.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.0-flash-exp-image-generation');
|
||||
|
||||
const intentosRateLimit = 1;
|
||||
for (let attempt = 0; attempt <= intentosRateLimit; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
|
||||
const content = response.data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('OpenRouter no devolvio contenido');
|
||||
|
||||
const imagen = this.extraerImagenDeRespuesta(content, response.data);
|
||||
if (!imagen) throw new Error('No se pudo extraer imagen de la respuesta');
|
||||
|
||||
return imagen;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 429 && attempt < intentosRateLimit) {
|
||||
this.logger.warn('Rate limit (429), esperando 5s y reintentando...');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Fallaron todos los intentos de generacion de imagen');
|
||||
}
|
||||
|
||||
private extraerImagenDeRespuesta(content: string, rawResponse?: any): string | null {
|
||||
if (content.startsWith('data:image')) return content;
|
||||
|
||||
const dataUriMatch = content.match(/data:image\/[a-zA-Z]+;base64,[^\s"']+/);
|
||||
if (dataUriMatch) return dataUriMatch[0];
|
||||
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"'()]+\.(png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) return urlMatch[0];
|
||||
|
||||
const parts = rawResponse?.choices?.[0]?.message?.content;
|
||||
if (Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
if (part.type === 'image_url' && part.image_url?.url) return part.image_url.url;
|
||||
if (part.image_url?.url?.startsWith('data:image')) return part.image_url.url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
13
mvp/image-worker/src/pipeline/pipeline.module.ts
Normal file
13
mvp/image-worker/src/pipeline/pipeline.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PipelineService } from './pipeline.service';
|
||||
import { PromptBuilderService } from './prompt-builder.service';
|
||||
import { ImageGeneratorService } from './image-generator.service';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
import { ReformixModule } from '../reformix/reformix.module';
|
||||
|
||||
@Module({
|
||||
imports: [ReformixModule],
|
||||
providers: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
|
||||
exports: [PipelineService],
|
||||
})
|
||||
export class PipelineModule {}
|
||||
114
mvp/image-worker/src/pipeline/pipeline.service.ts
Normal file
114
mvp/image-worker/src/pipeline/pipeline.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PerfilCompletoDto } from '../webhook/webhook.dto';
|
||||
import { PromptBuilderService } from './prompt-builder.service';
|
||||
import { ImageGeneratorService } from './image-generator.service';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
import { ReformixService } from '../reformix/reformix.service';
|
||||
|
||||
interface ZonaRender {
|
||||
zona: string;
|
||||
imagen: string;
|
||||
score: number;
|
||||
aprobada: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PipelineService {
|
||||
private readonly logger = new Logger(PipelineService.name);
|
||||
private readonly maxRetries: number;
|
||||
private readonly minScore: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly promptBuilder: PromptBuilderService,
|
||||
private readonly imageGenerator: ImageGeneratorService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly reformix: ReformixService,
|
||||
) {
|
||||
this.maxRetries = this.config.get<number>('MAX_RETRIES', 2);
|
||||
this.minScore = this.config.get<number>('SUPERVISOR_MIN_SCORE', 70);
|
||||
}
|
||||
|
||||
async procesarLead(dto: PerfilCompletoDto): Promise<void> {
|
||||
const { leadId, reforma, zonas } = dto;
|
||||
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
|
||||
const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0);
|
||||
|
||||
this.logger.log(`[${leadId}] Iniciando pipeline para ${zonasConFotos.length} zonas`);
|
||||
|
||||
for (const z of zonasSaltadas) {
|
||||
this.logger.log(`[${leadId}] Zona ${z.zona}: sin fotos "antes", saltando`);
|
||||
}
|
||||
|
||||
const renders: ZonaRender[] = [];
|
||||
|
||||
for (const zona of zonasConFotos) {
|
||||
try {
|
||||
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]);
|
||||
renders.push(render);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (renders.length === 0) {
|
||||
this.logger.warn(`[${leadId}] No se generaron renders para ninguna zona`);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = renders.map((r) => ({
|
||||
zona: r.zona,
|
||||
imagen: r.imagen,
|
||||
}));
|
||||
|
||||
const ok = await this.reformix.entregarRenders(leadId, items);
|
||||
if (ok) {
|
||||
this.logger.log(`[${leadId}] Renders entregados correctamente (${renders.length} zonas)`);
|
||||
} else {
|
||||
this.logger.error(`[${leadId}] Error entregando renders a la app principal`);
|
||||
}
|
||||
}
|
||||
|
||||
private async procesarZona(
|
||||
leadId: string,
|
||||
zona: string,
|
||||
reforma: PerfilCompletoDto['reforma'],
|
||||
notas: string[],
|
||||
fotoAntes: string,
|
||||
): Promise<ZonaRender> {
|
||||
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
|
||||
|
||||
let ultimaImagen: string | null = null;
|
||||
|
||||
for (let intento = 0; intento <= this.maxRetries; intento++) {
|
||||
if (intento > 0) {
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: reintento ${intento} de ${this.maxRetries}`);
|
||||
}
|
||||
|
||||
const imagen = await this.imageGenerator.generarRender(prompt, fotoAntes);
|
||||
ultimaImagen = imagen;
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: imagen generada`);
|
||||
|
||||
const resultado = await this.supervisor.supervisar(
|
||||
reforma.tipo,
|
||||
reforma.m2Suelo,
|
||||
reforma.calidad,
|
||||
notas,
|
||||
fotoAntes,
|
||||
imagen,
|
||||
);
|
||||
|
||||
const aprobada = resultado.aprobado && resultado.score >= this.minScore;
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: ${aprobada ? 'aprobada' : 'rechazada'} (score: ${resultado.score}) - ${resultado.motivo}`);
|
||||
|
||||
if (aprobada) {
|
||||
return { zona, imagen, score: resultado.score, aprobada: true };
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn(`[${leadId}] Zona ${zona}: usando ultimo render pese a no superar validacion`);
|
||||
return { zona, imagen: ultimaImagen!, score: 0, aprobada: false };
|
||||
}
|
||||
}
|
||||
68
mvp/image-worker/src/pipeline/prompt-builder.service.ts
Normal file
68
mvp/image-worker/src/pipeline/prompt-builder.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
@Injectable()
|
||||
export class PromptBuilderService {
|
||||
private readonly logger = new Logger(PromptBuilderService.name);
|
||||
private systemPrompt = '';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const ruta = path.join(process.cwd(), 'prompts', 'prompt-builder.txt');
|
||||
if (fs.existsSync(ruta)) {
|
||||
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
|
||||
} else {
|
||||
this.logger.warn('prompts/prompt-builder.txt no encontrado, usando default');
|
||||
}
|
||||
}
|
||||
|
||||
async generarPrompt(
|
||||
tipoReforma: string,
|
||||
m2Suelo: number | null,
|
||||
calidad: string,
|
||||
notas: string[],
|
||||
): Promise<string> {
|
||||
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
|
||||
|
||||
const userContent = `Generate a render prompt for a ${tipoReforma} renovation.
|
||||
- Area: ${m2Suelo ?? 'unknown'} m²
|
||||
- Quality level: ${calidad}
|
||||
- Client notes: ${notas.join('; ') || 'none'}
|
||||
- Style: modern ${tipoReforma} renovation`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0.5,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = response.data.choices?.[0]?.message?.content?.trim();
|
||||
if (!prompt) throw new Error('OpenRouter devolvio respuesta vacia');
|
||||
return prompt;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error generando prompt: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
mvp/image-worker/src/pipeline/supervisor.service.ts
Normal file
109
mvp/image-worker/src/pipeline/supervisor.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface SupervisarResultado {
|
||||
aprobado: boolean;
|
||||
score: number;
|
||||
motivo: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SupervisorService {
|
||||
private readonly logger = new Logger(SupervisorService.name);
|
||||
private systemPrompt = '';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const ruta = path.join(process.cwd(), 'prompts', 'supervisor.txt');
|
||||
if (fs.existsSync(ruta)) {
|
||||
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
|
||||
} else {
|
||||
this.logger.warn('prompts/supervisor.txt no encontrado, usando default');
|
||||
}
|
||||
}
|
||||
|
||||
async supervisar(
|
||||
tipoReforma: string,
|
||||
m2Suelo: number | null,
|
||||
calidad: string,
|
||||
notas: string[],
|
||||
fotoAntes: string,
|
||||
renderDespues: string,
|
||||
): Promise<SupervisarResultado> {
|
||||
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
|
||||
|
||||
const notasTexto = notas.join('; ') || 'sin notas';
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reforma tipo: ${tipoReforma}\nMetros: ${m2Suelo ?? 'desconocido'}\nCalidad: ${calidad}\nNotas del cliente: ${notasTexto}`,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: fotoAntes },
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: renderDespues },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 256,
|
||||
temperature: 0.2,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const textContent = response.data.choices?.[0]?.message?.content?.trim();
|
||||
if (!textContent) {
|
||||
return { aprobado: false, score: 0, motivo: 'Modelo devolvio respuesta vacia' };
|
||||
}
|
||||
|
||||
return this.parsearRespuesta(textContent);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error en supervisor: ${err.message}`);
|
||||
return { aprobado: false, score: 0, motivo: `Error del supervisor: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private parsearRespuesta(texto: string): SupervisarResultado {
|
||||
const jsonMatch = texto.match(/\{[^{}]*"aprobado"[^{}]*\}/i);
|
||||
if (!jsonMatch) {
|
||||
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
aprobado: Boolean(parsed.aprobado),
|
||||
score: Math.min(100, Math.max(0, Number(parsed.score) || 0)),
|
||||
motivo: String(parsed.motivo || 'Sin motivo'),
|
||||
};
|
||||
} catch {
|
||||
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
|
||||
}
|
||||
}
|
||||
}
|
||||
8
mvp/image-worker/src/reformix/reformix.module.ts
Normal file
8
mvp/image-worker/src/reformix/reformix.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReformixService } from './reformix.service';
|
||||
|
||||
@Module({
|
||||
providers: [ReformixService],
|
||||
exports: [ReformixService],
|
||||
})
|
||||
export class ReformixModule {}
|
||||
87
mvp/image-worker/src/reformix/reformix.service.ts
Normal file
87
mvp/image-worker/src/reformix/reformix.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class ReformixService {
|
||||
private readonly logger = new Logger(ReformixService.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.baseUrl = this.config.get<string>('REFORMIX_API_URL', 'http://localhost:3000');
|
||||
this.apiKey = this.config.get<string>('FUNNEL_API_KEY', '');
|
||||
}
|
||||
|
||||
async entregarRenders(
|
||||
leadId: string,
|
||||
items: Array<{ zona: string; imagen: string }>,
|
||||
): Promise<boolean> {
|
||||
const body = {
|
||||
items: items.map((i) => ({
|
||||
tipo: 'foto',
|
||||
zona: i.zona,
|
||||
momento: 'despues',
|
||||
imagen: i.imagen,
|
||||
})),
|
||||
finalizar: true,
|
||||
};
|
||||
|
||||
const maxRetries = 3;
|
||||
const delay = 2000;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseUrl}/api/leads/${leadId}/ingesta`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
this.logger.log(`[${leadId}] Renders entregados correctamente`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
this.logger.error(`[${leadId}] Lead no encontrado (404), abandonando`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
this.logger.error(`[${leadId}] Payload invalido (422): ${JSON.stringify(response.data)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.status >= 500 && attempt < maxRetries) {
|
||||
this.logger.warn(`[${leadId}] Intento ${attempt}/${maxRetries} fallo (${response.status}), reintentando...`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.error(`[${leadId}] Error inesperado (${response.status}): ${JSON.stringify(response.data)}`);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
this.logger.error(`[${leadId}] Lead no encontrado (404), abandonando`);
|
||||
return false;
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
this.logger.warn(`[${leadId}] Intento ${attempt}/${maxRetries} error de red, reintentando...`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
} else {
|
||||
this.logger.error(`[${leadId}] Error critico entregando renders tras ${maxRetries} intentos: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
23
mvp/image-worker/src/webhook/webhook.controller.ts
Normal file
23
mvp/image-worker/src/webhook/webhook.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||
import { PerfilCompletoDto } from './webhook.dto';
|
||||
import { PipelineService } from '../pipeline/pipeline.service';
|
||||
|
||||
@Controller()
|
||||
export class WebhookController {
|
||||
private readonly logger = new Logger(WebhookController.name);
|
||||
|
||||
constructor(private readonly pipelineService: PipelineService) {}
|
||||
|
||||
@Post('perfil-completo')
|
||||
recibirPerfil(@Body() dto: PerfilCompletoDto) {
|
||||
this.logger.log(`[${dto.leadId}] Webhook recibido: ${dto.zonas.length} zonas`);
|
||||
|
||||
setImmediate(() => {
|
||||
this.pipelineService.procesarLead(dto).catch((err) => {
|
||||
this.logger.error(`[${dto.leadId}] Pipeline fallo: ${err.message}`, err.stack);
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true, message: 'Procesando renders en background...' };
|
||||
}
|
||||
}
|
||||
97
mvp/image-worker/src/webhook/webhook.dto.ts
Normal file
97
mvp/image-worker/src/webhook/webhook.dto.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { IsString, IsOptional, IsNumber, IsBoolean, IsArray, ValidateNested, IsIn, IsUUID } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class ClienteDto {
|
||||
@IsString()
|
||||
nombre: string;
|
||||
|
||||
@IsString()
|
||||
telefono: string;
|
||||
|
||||
@IsString()
|
||||
email: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
provincia?: string;
|
||||
}
|
||||
|
||||
class ReformaDto {
|
||||
@IsString()
|
||||
@IsIn(['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'])
|
||||
tipo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
m2Suelo?: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['basica', 'media', 'premium'])
|
||||
calidad: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
estructural?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['alta', 'media', 'baja'])
|
||||
urgencia?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
presupuestoTarget?: number;
|
||||
}
|
||||
|
||||
class EmpresaDto {
|
||||
@IsUUID()
|
||||
tenantId: string;
|
||||
|
||||
@IsString()
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
class FotosDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
antes: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
despues: string[];
|
||||
}
|
||||
|
||||
class ZonaDto {
|
||||
@IsString()
|
||||
zona: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
notas: string[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FotosDto)
|
||||
fotos: FotosDto;
|
||||
}
|
||||
|
||||
export class PerfilCompletoDto {
|
||||
@IsUUID()
|
||||
leadId: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ClienteDto)
|
||||
cliente: ClienteDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ReformaDto)
|
||||
reforma: ReformaDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => EmpresaDto)
|
||||
empresa: EmpresaDto;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ZonaDto)
|
||||
zonas: ZonaDto[];
|
||||
}
|
||||
9
mvp/image-worker/src/webhook/webhook.module.ts
Normal file
9
mvp/image-worker/src/webhook/webhook.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebhookController } from './webhook.controller';
|
||||
import { PipelineModule } from '../pipeline/pipeline.module';
|
||||
|
||||
@Module({
|
||||
imports: [PipelineModule],
|
||||
controllers: [WebhookController],
|
||||
})
|
||||
export class WebhookModule {}
|
||||
8
mvp/image-worker/tsconfig.build.json
Normal file
8
mvp/image-worker/tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
1
mvp/image-worker/tsconfig.build.tsbuildinfo
Normal file
1
mvp/image-worker/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
23
mvp/image-worker/tsconfig.json
Normal file
23
mvp/image-worker/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user