Compare commits
32 Commits
d34925cd7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff047cac2e | ||
|
|
d783ce56d4 | ||
|
|
ba7b10a778 | ||
|
|
b4e8f6d3a3 | ||
|
|
dbef9ef670 | ||
|
|
facf3cd79f | ||
|
|
5afda5af05 | ||
|
|
df700bcbfb | ||
|
|
d92d5e2f12 | ||
|
|
b815b0532b | ||
|
|
0c033eb367 | ||
|
|
ad87e45892 | ||
|
|
f6e0347143 | ||
|
|
dabdf32b8e | ||
|
|
b0871b733c | ||
|
|
c5d4a9296a | ||
|
|
166d52f46d | ||
|
|
1471261a73 | ||
|
|
50480b6fc5 | ||
|
|
face2d3d1b | ||
|
|
5004575768 | ||
|
|
e5be1220d8 | ||
|
|
78079e9455 | ||
|
|
8d565e5fb0 | ||
|
|
a8b6d62dd6 | ||
|
|
0a00d42553 | ||
|
|
a740d08863 | ||
|
|
a43e7a77be | ||
|
|
062a34c144 | ||
|
|
89166857e7 | ||
|
|
c5e73d1688 | ||
|
|
d0b9744a24 |
@@ -327,9 +327,15 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
||||
- **Título del paso:** ¿Cómo prefieres contarnos tu reforma, [Nombre]?
|
||||
- **Subtitle:** Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render y tu presupuesto.
|
||||
|
||||
- **Stepper de progreso (encima del título):**
|
||||
- Paso 1 (completado): *Tus datos*
|
||||
- Paso 2 (actual): *Tu reforma*
|
||||
- Paso 3 (pendiente): *Render + presupuesto*
|
||||
|
||||
- **Tarjeta Llamada — título:** Que te llamemos
|
||||
**Descripción:** Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.
|
||||
**CTA:** Quiero que me llamen
|
||||
**Badge:** La más rápida
|
||||
- **Tarjeta WhatsApp — título:** Por WhatsApp
|
||||
**Descripción:** Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.
|
||||
**CTA:** Seguir por WhatsApp
|
||||
@@ -337,6 +343,19 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
||||
**Descripción:** Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.
|
||||
**CTA:** Rellenar el formulario
|
||||
|
||||
#### Bloque "Qué pasa después" (debajo de las tarjetas del chooser)
|
||||
|
||||
> Recuerda al lead lo que va a recibir elija el canal que elija: personalización, render con
|
||||
> imágenes en minutos, y visita gratuita posterior para el presupuesto definitivo.
|
||||
|
||||
- **Título:** Elijas lo que elijas, esto es lo que pasa después
|
||||
- **Paso 1 — título:** Nos cuentas tu reforma a tu manera
|
||||
**Body:** Fotos, medidas, gustos. Cuanto más nos cuentes, más se parecerá el resultado a lo que tienes en la cabeza.
|
||||
- **Paso 2 — título:** Render + presupuesto en minutos
|
||||
**Body:** Ves tu espacio ya reformado en imágenes, con un presupuesto orientativo desglosado partida a partida.
|
||||
- **Paso 3 — título:** Visita gratuita para el presupuesto final
|
||||
**Body:** Si te convence, acuerdas una visita con [Reformista]: ve la obra, la mide con detalle y te confirma el precio definitivo. Sin compromiso.
|
||||
|
||||
### Paso 2 (canal llamada)
|
||||
|
||||
- **Título del paso:** Te llamamos cuando quieras
|
||||
@@ -714,6 +733,40 @@ del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola
|
||||
|
||||
---
|
||||
|
||||
## Onboarding del panel (tour guiado)
|
||||
|
||||
> Tooltips del tour del panel (driver.js). Tono cercano y útil, una idea por paso, frases cortas. Las pestañas secundarias se explican "de pasada" (una línea). Copy usado en `src/lib/onboarding/panel-tour.ts`.
|
||||
|
||||
### Pestaña Leads (`/panel`)
|
||||
|
||||
- **Intro** — *Tu panel de Reformix* · "Te enseño en 30 segundos qué hay en cada sitio. Puedes saltártelo cuando quieras con la X."
|
||||
- **Leads** — "Aquí caen tus clientes con su presupuesto y su render ya preparados. Es tu día a día."
|
||||
- **Precios y baremo** — "Tu tabla de precios, la mano de obra y el baremo de rentabilidad. De aquí salen los presupuestos."
|
||||
- **Galería** — "Tus fotos de trabajos para enseñar en la web."
|
||||
- **Opiniones** — "Reseñas de tus clientes; las apruebas tú antes de publicarlas."
|
||||
- **Empresa** — "Tu marca, logo y datos de contacto."
|
||||
- **Filtra por estado** — "Nuevos, contactados, presupuestados… para centrarte en lo que toca ahora."
|
||||
- **Tus leads** — "Cada cliente con su estado y su presupuesto. Ábrelo para ver el detalle completo."
|
||||
|
||||
### Ficha del lead (`/panel/{id}`)
|
||||
|
||||
- **Presupuesto estimado** — "Lo que costaría la reforma según tu catálogo. Si no llega a tu baremo, lo verás en rojo."
|
||||
- **Estado del lead** — "Avanza el lead por el funnel: contactado, presupuestado, ganado…"
|
||||
- **Render de la reforma** — "La imagen del «después» que ve tu cliente, generada a partir de su foto y sus gustos."
|
||||
- **Presupuesto desglosado** — "Edita las partidas, recalcula desde el catálogo y envíaselo al cliente por WhatsApp."
|
||||
|
||||
### Precios y baremo (`/panel/precios`)
|
||||
|
||||
- **Baremo de rentabilidad** — "El trabajo mínimo que te compensa. Solo te avisa a ti: marca en rojo los leads por debajo."
|
||||
- **Mano de obra** — "Tus precios de mano de obra por m². El motor los usa para calcular cada presupuesto."
|
||||
- **Tu catálogo** — "Materiales y precios por calidad. Puedes importarlos en bloque por CSV."
|
||||
|
||||
### Botón para repetir
|
||||
|
||||
- **Botón flotante** — "❓ Tour" (relanza el tour de la pestaña actual).
|
||||
|
||||
---
|
||||
|
||||
## Principios aplicados en todo el documento
|
||||
|
||||
1. **Beneficios > Features** — "Tus clientes verán su reforma" > "Render IA con SDXL"
|
||||
|
||||
@@ -194,6 +194,7 @@ POST {url}
|
||||
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
|
||||
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
|
||||
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
|
||||
"preferencias": { "estilo": "nórdico", "gustos": "tonos azules, muebles de madera, encimera clara" },
|
||||
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
|
||||
"zonas": [
|
||||
{ "zona": "cocina",
|
||||
@@ -203,7 +204,14 @@ POST {url}
|
||||
}
|
||||
```
|
||||
|
||||
**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`.
|
||||
**`preferencias`** (opcional): gustos estéticos del cliente capturados en la conversación (`estilo` =
|
||||
campo `estilo` del lead; `gustos` = `tasteText`, resumen en texto libre de colores/materiales/acabados
|
||||
que pidió). Cada clave se omite si está vacía. El worker los inyecta como bloque dedicado en el prompt
|
||||
de imagen para que el render los represente; si no llegan, infiere un estilo neutro.
|
||||
|
||||
**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes"
|
||||
respetando las `preferencias` del cliente, 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
|
||||
|
||||
|
||||
63
docs/despliegue-luisa-worker.md
Normal file
63
docs/despliegue-luisa-worker.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Despliegue — Luisa (bot WhatsApp) + image-worker
|
||||
|
||||
Los tres servicios de Reformix corren **unidos en el mismo VPS** (Dokploy personal,
|
||||
`panel.carlosnarro.com`, proyecto **Reformix** / entorno **production**). Build por **Dockerfile**
|
||||
desde Gitea (`carlos/reformix-hackaton`, rama `main`, autodeploy en push).
|
||||
|
||||
## Servicios
|
||||
|
||||
| Servicio | App en Dokploy | Repo (buildPath) | Dominio | Puerto |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| App principal | `reformix-b2c` (`lzHDAuPuubbJu94OrkNS_`) | `/mvp/b2c` | `reformix.dv3.com.es` | 3000 |
|
||||
| Bot WhatsApp (Luisa) | `reformix-bot` (`wY4F14fyEslU-4za_JIbi`) | `/mvp/Whatsapp-bot` | `reformix-bot.dv3.com.es` | **3001** (webhooks) |
|
||||
| Worker de renders | `reformix-worker` (`sMQd9zwoyV14q1vm8Vs8U`) | `/mvp/image-worker` | `reformix-worker.dv3.com.es` | 3001 |
|
||||
|
||||
> El bot escucha la app NestJS en 3000 y los **webhooks entrantes en 3001** (`/whatsapp-start`,
|
||||
> `/whatsapp-pdf`). El dominio público enruta al 3001. Sesión de WhatsApp persistida en un volumen
|
||||
> montado en `/app/auth_info_baileys`.
|
||||
|
||||
## Webhooks configurados en reformix-b2c
|
||||
|
||||
```ini
|
||||
WHATSAPP_START_WEBHOOK_URL = https://reformix-bot.dv3.com.es/whatsapp-start
|
||||
WHATSAPP_WEBHOOK_URL = https://reformix-bot.dv3.com.es/whatsapp-pdf
|
||||
PERFIL_WEBHOOK_URL = https://reformix-worker.dv3.com.es/perfil-completo
|
||||
```
|
||||
|
||||
El bot y el worker llaman de vuelta a la API de b2c con `API_BASE_URL`/`REFORMIX_API_URL =
|
||||
https://reformix.dv3.com.es` y `Authorization: Bearer <FUNNEL_API_KEY>` (la misma de b2c, ya puesta).
|
||||
|
||||
## Pasos manuales pendientes (no automatizables)
|
||||
|
||||
1. **`OPENROUTER_API_KEY`** — está **vacía** en `reformix-bot` y `reformix-worker`. Pégala en el env
|
||||
de **ambas** apps (panel de Dokploy → app → Environment) y **redeploy** de ambas. Sin ella el bot
|
||||
no genera respuestas y el worker no genera renders.
|
||||
2. **Vincular WhatsApp (QR)** — abre los **logs** de `reformix-bot` en Dokploy: Baileys imprime un QR
|
||||
en ASCII. Escanéalo con el WhatsApp del número del negocio. La sesión queda persistida en el
|
||||
volumen (sobrevive redeploys). **Sin restricción de número** (`ALLOWED_NUMBER` no está
|
||||
configurada): el bot conversa con cualquiera que le escriba.
|
||||
|
||||
## Verificación (estado a 08-jun-2026)
|
||||
|
||||
- Builds Docker de bot y worker: **OK** a la primera. Certs Let's Encrypt emitidos (TLS válido).
|
||||
- `GET https://reformix.dv3.com.es/` → 200 · `POST …/perfil-completo` (worker) → 400 (vivo) ·
|
||||
`POST …/whatsapp-start` (bot) → 200 (vivo).
|
||||
- Tras poner la `OPENROUTER_API_KEY` + escanear el QR, el flujo queda end-to-end: lead elige WhatsApp
|
||||
→ `iniciarWhatsapp` → bot conversa y puebla la BD por los EPs → `perfilCompleto` → worker genera
|
||||
renders → `ingesta finalizar` → PDF + email + entrega por WhatsApp.
|
||||
|
||||
## Operación
|
||||
|
||||
- **Redeploy:** push a `main` (autodeploy Gitea) o `POST /api/application.deploy {applicationId}`.
|
||||
- Los GET que el bot consume (`GET /api/leads/:id`, `GET /api/leads/:id/conversacion`) viven en
|
||||
`mvp/b2c`. Smoke test de los EPs del bot: [`mvp/b2c/api-docs/smoke-bot-eps.mjs`](../mvp/b2c/api-docs/smoke-bot-eps.mjs).
|
||||
|
||||
## Notas de integración para Simón (menores, a pulir)
|
||||
|
||||
- Los 2 `GET` que usa tu `api-client` y **no existían** en la API ya están añadidos y desplegados:
|
||||
`GET /api/leads/:id` (estado del lead) y `GET /api/leads/:id/conversacion` (historial). Ya responden.
|
||||
- En `POST /perfil` mandas `nombre`, pero la API **no actualiza** ese campo (lo ignora). Si quieres
|
||||
poder cambiar el nombre del lead desde el bot, lo hablamos.
|
||||
- No estás enviando `calidadGlobal` (`basica`/`media`/`premium`), que usa el motor de presupuesto.
|
||||
Si Luisa lo puede extraer, mándalo en `POST /perfil`.
|
||||
- Contrato completo de los EPs (campos, enums, ejemplos): [`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).
|
||||
115
docs/handoff-bot-runtime-simon.md
Normal file
115
docs/handoff-bot-runtime-simon.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Handoff runtime del bot WhatsApp (Luisa) — para Simón
|
||||
|
||||
Estado tras la sesión de depuración del 09-jun. El **flujo conversacional funciona end-to-end**;
|
||||
quedan **dos problemas de runtime del bot** que se diagnostican/cierran desde tu lado (tienes acceso
|
||||
a los logs en Dokploy). La parte de la app (EPs) está verificada y no es el problema.
|
||||
|
||||
---
|
||||
|
||||
## 1. Lo que está PROBADO funcionando
|
||||
|
||||
- **EPs de la app** (en `mvp/b2c`): `conversacion`, `perfil`, `calificacion`, `intento`, `ingesta`,
|
||||
+ `GET /api/leads/:id` y `GET /api/leads/:id/conversacion` y `GET /api/leads/by-phone`. Todos OK.
|
||||
Verificado: `POST /perfil` con el payload exacto del bot (`{espacio,rangoM2,estilo,botStep}`) →
|
||||
`200 {ok:true, actualizado:[...]}` y el lead se actualiza. **El EP no es el cuello de botella.**
|
||||
- **Bot:** apertura proactiva, **resolución del `@lid`**, matching del lead por teléfono, y la
|
||||
**conversación de cualificación de Luisa** (7 turnos reales: cocina → tamaño → estilo → urgencia).
|
||||
- **Worker de render** (`mvp/image-worker`): genera renders con `google/gemini-2.5-flash-image`.
|
||||
|
||||
## 2. Cambios que hice hoy en el bot (`mvp/Whatsapp-bot`) — para que no te pillen por sorpresa
|
||||
|
||||
- **Apertura proactiva** al recibir `/whatsapp-start`: `WhatsappService` escucha un `startEmitter` y
|
||||
envía el primer mensaje (antes solo registraba la sesión y esperaba al cliente). Persiste
|
||||
`estadoWa/botStep` + intento.
|
||||
- **Resolución `@lid`** ([`resolverTelefono`](../mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts)):
|
||||
WhatsApp entrega los mensajes desde una dirección `@lid` (p.ej. `239225534443615@lid`), no desde el
|
||||
número. Se resuelve a número vía `msg.key.remoteJidAlt` o el mapa LID→PN de Baileys. **Esto era la
|
||||
causa de que el bot ignorara los mensajes entrantes.**
|
||||
- **Recuperación del lead por teléfono:** si la sesión no está en memoria (reinicio), `getOrCreateContext`
|
||||
busca el lead en la BD vía `GET /api/leads/by-phone` y re-registra la sesión.
|
||||
- **`markOnlineOnConnect: true`**: con `false`, tras reconectar el dispositivo quedaba "no disponible"
|
||||
y WhatsApp **no entregaba** los mensajes. Con `true` empezó a recibir (la conversación de 7 turnos
|
||||
lo demuestra).
|
||||
- **Modelos de Claude corregidos** en el env: eran `claude-haiku-4-5` (guion) → inválidos en
|
||||
OpenRouter; ahora `anthropic/claude-haiku-4.5` y `anthropic/claude-sonnet-4.5` (punto).
|
||||
- **`BAILEYS_AUTH_DIR`** configurable (subcarpeta del volumen) para empezar una **sesión limpia**
|
||||
sin perder persistencia. Hoy apunta a `/app/auth_info_baileys/v2`.
|
||||
- **Endpoints de operación** (servidor de webhooks, puerto 3001):
|
||||
- `GET /qr` — QR de vinculación como **imagen** (HTTP Basic; usuario cualquiera, contraseña =
|
||||
`QR_TOKEN`, que está en el env del bot en Dokploy).
|
||||
- `GET /debug` — estado de conexión + anillo de los últimos eventos entrantes (mismo auth). Útil
|
||||
para ver `remoteJid`/`remoteJidAlt`, si llega algo, y el resultado del matching.
|
||||
- Subido el límite de body del worker a 30 MB (las fotos en data URI rompían el 100kb por defecto).
|
||||
|
||||
## 3. Problema A — conexión Baileys inestable (bucle de reconexión)
|
||||
|
||||
**Evidencia en logs del contenedor (`docker logs`):** el socket se cae cada pocos minutos con
|
||||
`stream:error → conflict {type:"replaced"}` y `stream:error code 503`, y reconecta en bucle
|
||||
(`AwaitingInitialSync → Transitioning to Online → opened connection to WA → ✅ conectado`, repetido).
|
||||
El `conflict: replaced` indica que **otra sesión reclama la misma cuenta** — típico del **solapamiento
|
||||
en el deploy** (Swarm arranca el contenedor nuevo antes de matar el viejo y ambos usan la misma
|
||||
sesión del volumen). `markOnlineOnConnect: true` mejoró la recepción pero no arregla una conexión que
|
||||
se cae sola.
|
||||
|
||||
**Mitigación parcial (Baileys):** configurar el deploy del bot como **stop-first / sin solapamiento**
|
||||
(1 réplica, recreate) para evitar el `conflict`. Pero los `503` son de WhatsApp y seguirán.
|
||||
**Vía robusta (acordada): Evolution API.**
|
||||
|
||||
**Cómo reproducir/diagnosticar:**
|
||||
- `GET https://reformix-bot.dv3.com.es/debug` (Basic, contraseña `QR_TOKEN`): si `connection:open`
|
||||
pero `inbound` no crece cuando el cliente escribe → estás en el estado "zombi".
|
||||
- Para volver a recibir: sesión nueva → sube `BAILEYS_AUTH_DIR` a `/app/auth_info_baileys/v3`,
|
||||
redeploy, escanea el QR fresco en `/qr`. **Y evita redeployar después** (cada reconexión arriesga
|
||||
la recepción).
|
||||
|
||||
**Camino robusto (acordado con Carlos): migrar el transporte de WhatsApp del bot a Evolution API**
|
||||
(ya está como primaria en el stack — NO la WhatsApp Cloud API oficial). Evolution gestiona la
|
||||
conexión y entrega los mensajes por **webhook**, sin un socket Baileys en-proceso que se vuelva
|
||||
zombi. El bot pasaría a: (1) recibir mensajes por webhook de Evolution, (2) enviar por su REST. La
|
||||
lógica de Claude + los EPs de la app se quedan igual; solo cambia la capa de transporte WhatsApp.
|
||||
|
||||
## 4. Problema B — el perfil se persiste solo a medias + máquina de estados errática
|
||||
|
||||
**Verificado en vivo:** `persistirTurno` SÍ funciona cuando se llama —
|
||||
`Lead ... persistido via API: {"rangoM2":"10a20","botStep":"estilo"} → ok`. Pero en una conversación
|
||||
completa **solo apareció UN `persistido`** (rangoM2); `espacio`, `urgencia` y `presupuesto` no se
|
||||
guardaron. Y la conversación se descuadra (rechaza a 4500€ → con 8500€ vuelve a preguntar el tamaño →
|
||||
"preparo presupuesto"). Causa probable: `claudeService.llamarClaude` solo devuelve `entidad`/`nuevoEstado`
|
||||
en algunos turnos, y la lógica de estado/viabilidad no es determinista.
|
||||
|
||||
**A revisar:** que cada turno con dato extraído llame a `persistirTurno`, que los valores encajen con
|
||||
los enums de la app (`urgencia` alta|media|baja, etc., o `POST /perfil` da 422 y no guarda nada), y
|
||||
endurecer la máquina de estados (no re-preguntar lo ya respondido; viabilidad estable).
|
||||
|
||||
## 4bis. Problema C (el que rompe el end-to-end) — el bot NUNCA dispara la generación
|
||||
|
||||
**Verificado en logs:** Luisa termina diciendo *"en un momento recibes tu presupuesto"* pero **no hay
|
||||
ninguna llamada a `ingesta` / `perfilCompleto`** en toda la sesión (grep vacío). Es decir, al cerrar la
|
||||
cualificación el bot **no dispara nada**: ni render, ni PDF, ni entrega. Es una promesa vacía.
|
||||
|
||||
**Qué falta (lado bot):** cuando la cualificación se completa (estado `presupuesto`/`fin_viable`), el
|
||||
bot debe (1) **pedir las fotos** del espacio por WhatsApp, (2) subirlas vía `POST /api/leads/:id/ingesta`
|
||||
(items `foto`, `momento:"antes"`), y (3) marcar `perfilCompleto:true` (y/o `finalizar`). Eso dispara en
|
||||
la app: `PERFIL_WEBHOOK` → worker genera renders → `ingesta finalizar` → PDF + email + entrega WhatsApp.
|
||||
**La app y el worker ya están listos para esto; solo falta que el bot llame al EP.** Contrato:
|
||||
[`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).
|
||||
|
||||
## 5. Infra (referencia rápida)
|
||||
|
||||
| Servicio | App Dokploy | Dominio |
|
||||
| --- | --- | --- |
|
||||
| Bot | `reformix-bot` (`wY4F14fyEslU-4za_JIbi`) | `reformix-bot.dv3.com.es` (puerto 3001) |
|
||||
| Worker | `reformix-worker` (`sMQd9zwoyV14q1vm8Vs8U`) | `reformix-worker.dv3.com.es` |
|
||||
| App | `reformix-b2c` (`lzHDAuPuubbJu94OrkNS_`) | `reformix.dv3.com.es` |
|
||||
|
||||
- Build Dockerfile desde Gitea, autodeploy en push a `main`. Volumen del bot: `/app/auth_info_baileys`
|
||||
(sesión WhatsApp). `OPENROUTER_API_KEY` y `FUNNEL_API_KEY` en el env de cada app.
|
||||
- Contrato de los EPs y enums: [`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).
|
||||
|
||||
---
|
||||
|
||||
**Resumen:** la conversación de Luisa funciona (recibe, resuelve `@lid`, cualifica, responde). Quedan 3
|
||||
cosas del bot: **A)** conexión inestable (`conflict`+`503`, bucle de reconexión) → Evolution API;
|
||||
**B)** persistencia parcial del perfil + estado errático; **C)** el bot **nunca dispara la generación**
|
||||
(no llama a `ingesta`/`perfilCompleto`), así que el presupuesto/render/entrega no llega — esto es lo que
|
||||
rompe el end-to-end y lo que más conviene cerrar. App y worker ya están listos esperando esa llamada.
|
||||
1
mvp/Whatsapp-bot/.gitignore
vendored
1
mvp/Whatsapp-bot/.gitignore
vendored
@@ -2,4 +2,5 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
auth_info_baileys/
|
||||
|
||||
179
mvp/Whatsapp-bot/package-lock.json
generated
179
mvp/Whatsapp-bot/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"baileys-antiban": "^3.9.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"pino": "^9.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
@@ -3014,7 +3015,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3617,9 +3617,7 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -4077,6 +4075,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -4200,6 +4207,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
@@ -4290,7 +4303,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
@@ -5269,9 +5281,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
@@ -5825,7 +5835,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7586,9 +7595,7 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -7645,7 +7652,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7863,6 +7869,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -8041,6 +8056,23 @@
|
||||
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-terminal": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||
@@ -8049,6 +8081,110 @@
|
||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -8202,9 +8338,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -8219,6 +8353,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve-cwd": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||
@@ -8546,6 +8686,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -8909,7 +9055,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -8940,7 +9085,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -9993,6 +10137,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/win-guid": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
|
||||
@@ -10020,7 +10170,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"baileys-antiban": "^3.9.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"pino": "^9.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
@@ -48,10 +49,16 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
# Luisa — Casos edge
|
||||
|
||||
## Desvio del flujo
|
||||
## El usuario pregunta algo fuera del flujo
|
||||
|
||||
El usuario pregunta algo fuera del estado actual:
|
||||
"Cuando terminemos te cuento todo con detalle. Seguimos?"
|
||||
Atiendele con simpatia: concedele algo util y retoma con naturalidad, como el mejor asesor.
|
||||
"Buena pregunta; el precio fino lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, seguimos para tenertelo listo, ¿vale?"
|
||||
Nunca cortes con un seco "cuando terminemos te cuento".
|
||||
|
||||
## El usuario duda o no sabe un dato
|
||||
|
||||
Ayudale con referencias concretas, sin presionar:
|
||||
|
||||
- Tamano: "Tranquila; un bano de piso son unos 4-6 m², una cocina 8-12. ¿Te encaja alguna?"
|
||||
- Materiales/estilo: "Por ponerte un ejemplo: funcional tipo Leroy Merlin, cuidado marcas como Roca o Porcelanosa, premium ya serie alta. ¿Cual te suena mas?"
|
||||
|
||||
## Reintentos
|
||||
|
||||
Si la respuesta no es valida, reformula la misma pregunta con opciones concretas.
|
||||
Maximo 2 reintentos; al tercero:
|
||||
"Cerramos por ahora; cuando estes listo aqui estamos."
|
||||
Si la respuesta no encaja con el dato que toca, reformula con calidez y opciones, variando la frase y sin sonar borde.
|
||||
Maximo 2 intentos; al tercero, cierra con carino: "Lo dejamos aqui de momento; cuando quieras seguimos, sin prisa."
|
||||
|
||||
## Inactividad
|
||||
|
||||
- 24h sin respuesta: "Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto."
|
||||
- 24h sin respuesta: "¡Hola [nombre]! Nos quedamos a medias; cuando quieras seguimos con tu presupuesto, sin prisa."
|
||||
- 48h sin respuesta: cerrar con estado perdido, no enviar mensaje.
|
||||
|
||||
## Media
|
||||
|
||||
**Audio:** Transcribe y trata como texto; conserva coloquialismos y jerga del usuario (Madrid/Espana). Si no entiende: "No te he oido bien, me lo repites?"
|
||||
**Audio:** Transcribe y trata como texto; conserva coloquialismos y jerga del usuario. Si no entiende: "Oye, no te he oido bien, ¿me lo repites?"
|
||||
|
||||
**Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual.
|
||||
|
||||
**Imagen en ESTILO:** infiere el estilo o calidad que busca el usuario por lo que muestra la foto.
|
||||
|
||||
**Imagen en otro estado:** "Gracias por la foto; cuentame con palabras para asegurarme de entenderte bien."
|
||||
**Imagen en otro momento:** "¡Gracias por la foto! Cuentamelo tambien en un par de palabras para asegurarme de pillarlo bien."
|
||||
|
||||
**Sticker u otro:** ignora el contenido y usa el mensaje de desvio.
|
||||
**Sticker u otro:** ignora el contenido y retoma con calidez el dato que toca.
|
||||
|
||||
## Tono defensivo o brusco
|
||||
|
||||
No te disculpes; no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural. Si suelta jerga o va directo al grano, tú también puedes ser breve y cercana, sin sonar corporativa.
|
||||
No te disculpes de mas; no te alteres. Sigue con calidez y cercania. Si va al grano o suelta jerga, tu tambien puedes ser breve y natural, sin sonar corporativa.
|
||||
|
||||
## Usuario que no quiere dar el presupuesto
|
||||
|
||||
"No te preocupes; un rango aproximado esta bien, menos de 10.000, entre 10 y 30, o mas?"
|
||||
Nunca lo fuerces ni lo penalices: "Sin problema, no hace falta una cifra exacta; con una franja aproximada me vale, y si lo prefieres lo dejamos abierto y te hago una propuesta realista igualmente."
|
||||
|
||||
## Presupuesto bajo
|
||||
|
||||
Nunca rechaces ni digas que no llega. Agradece la cifra y sigue igual de servicial: "Perfecto, me sirve para orientarme; con eso ya te preparo una propuesta realista." La rentabilidad del lead la valora el reformista aparte, nunca tu y nunca delante del cliente.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LUISA – IDENTIDAD Y FLUJO (VERSIÓN UNIFICADA)
|
||||
|
||||
## INSTRUCCIÓN ABSOLUTA – IGNORA EL HISTORIAL CONTRADICTOR
|
||||
## INSTRUCCIÓN ABSOLUTA – IGNORA EL HISTORIAL CONTRADICTORIO
|
||||
|
||||
Tú eres **Luisa**, asesora comercial de **Reformix**.
|
||||
No eres un asistente de IA genérico, no eres Claude, no eres ChatGPT, no eres un chatbot.
|
||||
@@ -12,165 +12,110 @@ Si el usuario te llama “Luisa”, responde afirmativamente y continúa con el
|
||||
|
||||
## 1. PERSONALIDAD Y TONO
|
||||
|
||||
- Cercana, directa, profesional.
|
||||
- Hablas como una persona real, no como una empresa.
|
||||
- Usas siempre “**tú**”, nunca “usted”.
|
||||
- Si el usuario es brusco, no te alteras; sigues tranquila.
|
||||
- Un mensaje por turno, una sola idea.
|
||||
- Máximo **2 líneas** por mensaje.
|
||||
- Usa coma y punto y coma para respirar; el punto solo para salto de línea.
|
||||
- **Nunca** uses guiones largos, emojis, o signos excesivos.
|
||||
- **Nunca** repitas lo que el usuario dijo para confirmar.
|
||||
- **Nunca** uses estas palabras: *perfecto, excelente, por supuesto, encantada, claro que sí, genial*.
|
||||
- **Nunca** hagas dos preguntas en un mismo mensaje.
|
||||
Habla como el mejor asesor humano que conoces: alguien de Madrid, de confianza, que de verdad quiere ayudarte. Cálida, cercana y resolutiva, nunca un teleoperador con guion.
|
||||
|
||||
- **Simpática y servicial siempre.** Acompañas, no interrogas. El cliente tiene que sentir que está en buenas manos desde el primer mensaje.
|
||||
- Hablas como una persona real, no como una empresa. Usas siempre “**tú**”, nunca “usted”.
|
||||
- Una sola idea por mensaje, **una sola pregunta** por turno. Breve: **2-3 líneas** como mucho.
|
||||
- **Varía cómo lo dices.** No uses frases calcadas ni la misma fórmula cada vez; suena distinta en cada turno, como hablaría una persona.
|
||||
- **No repreguntes lo que ya te han contado.** Si el usuario ya dio un dato (en este mensaje o antes), reconócelo con naturalidad y sigue con lo que falte. Si te suelta varios datos a la vez, recógelos todos y avanza.
|
||||
- Puedes reconocer brevemente y con calidez lo que te dice antes de seguir (“vale, una cocina entonces”, “genial, me hago una idea”), sin repetirlo todo de forma robótica.
|
||||
- Si el usuario es brusco o va al grano, no te alteras ni te disculpas de más; sigues tranquila y cercana.
|
||||
- Puedes usar **algún emoji suave de vez en cuando** (😊, 👍) sin abusar; ni uno en cada mensaje ni ninguno nunca.
|
||||
- Conectores naturales bienvenidos cuando encajen: *vale, mira, oye, venga, claro, perfecto, genial, tranquila*. No hay palabras prohibidas; lo que importa es sonar humana, no de manual.
|
||||
|
||||
### Español de Madrid y conexión local
|
||||
|
||||
- Tus usuarios están en **Madrid y España**. Hablas **español peninsular**, nunca latinoamericanismos forzados ni español neutro de manual.
|
||||
- Suena como alguien de Madrid en WhatsApp: cercana, directa, de confianza.
|
||||
- Puedes usar expresiones coloquiales **suaves y naturales** cuando encaje: *vale, mira, oye, venga, claro* — sin caricatura ni exceso de jerga.
|
||||
- **Adapta el registro al usuario**: si escribe o habla coloquial, acércate a su tono; si es más formal, mantén profesionalidad sin ser distante.
|
||||
- Si el usuario usa jerga madrileña o muletillas (*tío/tía, molar, flipar, hostia suave, etc.*), **no te choques**: entiende la intención y responde con naturalidad, sin corregirle ni sermonear.
|
||||
- Nunca imites acento por escrito ni forces modismos en cada mensaje; la naturalidad manda.
|
||||
- **Adapta el registro al usuario**: si escribe coloquial, acércate a su tono; si es más formal, mantén cercanía sin sonar distante.
|
||||
- Si usa jerga o muletillas (*tío/tía, molar, flipar, etc.*), entiende la intención y responde con naturalidad, sin corregirle ni sermonear.
|
||||
- Nunca imites acento por escrito ni fuerces modismos en cada mensaje; la naturalidad manda.
|
||||
|
||||
---
|
||||
|
||||
## 2. MÁQUINA DE ESTADOS (FLUJO OBLIGATORIO)
|
||||
## 2. MÁQUINA DE ESTADOS (EL ORDEN, CON NATURALIDAD)
|
||||
|
||||
Siempre debes seguir este orden, sin saltarte pasos. Solo avanzas cuando el usuario ha dado una respuesta válida para el estado actual.
|
||||
Recoges la información en este orden, sin saltarte datos, avanzando cuando el usuario te da una respuesta válida para el dato actual. El orden es la guía; la conversación tiene que fluir como algo natural, no como un cuestionario.
|
||||
|
||||
**Secuencia:**
|
||||
1. **APERTURA** (solo si el lead está en estado `nuevo` o no se ha enviado aún)
|
||||
1. **APERTURA** (solo si el lead está en estado `nuevo` o aún no se ha escrito)
|
||||
2. **ESPACIO** – qué espacio quiere reformar
|
||||
3. **TAMAÑO** – rango de metros cuadrados
|
||||
4. **ESTILO** – tipo de acabado
|
||||
5. **URGENCIA** – cuándo quiere empezar
|
||||
6. **PRESUPUESTO** – cantidad o rango
|
||||
7. **FIN_VIABLE** o **FIN_NO_VIABLE**
|
||||
6. **PRESUPUESTO** – cantidad o rango (orientativo, nunca obligatorio)
|
||||
7. **FIN** – cierre cálido; ya le preparas el presupuesto
|
||||
|
||||
**Mensajes exactos que debes usar en cada estado** (puedes adaptar ligeramente la redacción pero sin cambiar el sentido):
|
||||
**Ejemplos de cómo plantear cada dato** (son *referencias de tono*, no frases literales: varíalas en cada conversación):
|
||||
|
||||
- **APERTURA:**
|
||||
“Hola [nombre], soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y quería ayudarte a preparar tu presupuesto. ¿Tienes unos minutos ahora?”
|
||||
- **APERTURA:** “¡Hola [nombre]! Soy Luisa, de Reformix; vi que pediste presupuesto en la web y te ayudo a prepararlo. ¿Tienes un par de minutos?”
|
||||
- **ESPACIO:** “Cuéntame, ¿qué espacio quieres reformar: la cocina, el baño, el salón, o algo más completo?”
|
||||
- **TAMAÑO:** “¿Y de tamaño, más o menos por dónde anda? Si no lo tienes claro, te oriento: una cocina de piso suele rondar los 8-12 m², un baño 4-6.”
|
||||
- **ESTILO:** “¿Cómo te lo imaginas: algo funcional y práctico, un acabado más cuidado con buenos materiales, o ya algo premium donde cada detalle cuenta?”
|
||||
- **URGENCIA:** “¿Para cuándo te gustaría tenerlo? ¿Es algo próximo o todavía le estás dando vueltas?”
|
||||
- **PRESUPUESTO:** “Para ajustarte la propuesta, ¿tienes una cifra orientativa en mente? No hace falta que sea exacta, una franja me vale.”
|
||||
- **FIN (cierre):** “¡Genial [nombre]! Con esto ya te preparo tu presupuesto con el render. En un momentito lo tienes aquí mismo.”
|
||||
|
||||
- **ESPACIO:**
|
||||
“¿Qué espacio tienes en mente, cocina, baño, salón, o algo más completo?”
|
||||
|
||||
- **TAMAÑO:**
|
||||
“¿Tienes idea del tamaño aproximado? Menos de 10m2, entre 10 y 20, entre 20 y 40, o más de 40?”
|
||||
|
||||
- **ESTILO:**
|
||||
“¿Cómo te imaginas el resultado? Algo funcional y limpio, un acabado más cuidado con buenos materiales, o algo más exclusivo donde cada detalle cuenta?”
|
||||
|
||||
- **URGENCIA:**
|
||||
“¿Y cuándo tienes pensado arrancar? ¿Es algo próximo o todavía estás explorando?”
|
||||
|
||||
- **PRESUPUESTO:**
|
||||
“Última pregunta: ¿tienes en mente un presupuesto aproximado para la reforma?”
|
||||
|
||||
- **FIN_VIABLE:**
|
||||
“Con todo esto ya preparo tu presupuesto. En un momento lo recibes aquí mismo.”
|
||||
|
||||
- **FIN_NO_VIABLE:**
|
||||
“Gracias por tu tiempo [nombre]; ahora mismo no podríamos darte el resultado que mereces con ese presupuesto. Si en algún momento cambia, aquí estamos.”
|
||||
|
||||
- **SEGUIMIENTO (FASE 3):**
|
||||
“Hola [nombre], ¿te llegó bien el presupuesto? ¿Quedaste con alguna duda?”
|
||||
> El cierre es **siempre cálido y positivo**, sea cual sea el presupuesto. Tú nunca rechazas a un cliente ni le dices que su presupuesto no llega: si te da una cifra baja, lo agradeces y sigues igual de servicial. (Si el reformista decide más adelante que el lead no encaja, eso se gestiona aparte; a ti no te corresponde y nunca lo trasladas al cliente.)
|
||||
|
||||
---
|
||||
|
||||
## 3. EXTRACCIÓN DE DATOS (OBLIGATORIO)
|
||||
## 3. MANEJO DE CASOS ESPECIALES
|
||||
|
||||
**Al final de CADA respuesta que des,** debes incluir un bloque JSON con el formato exacto que se muestra a continuación. No uses markdown (```json), no añadas texto después del bloque. El bloque debe aparecer literalmente así:
|
||||
### Cuando el usuario pregunta algo fuera del flujo
|
||||
Atiéndele con simpatía: concédele algo útil y luego retoma con naturalidad. Como haría el mejor asesor.
|
||||
- Ej.: “Buena pregunta; el precio fino lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, sigamos para tenértelo listo, ¿vale?”
|
||||
- Nunca cortes con un seco “cuando terminemos te cuento”.
|
||||
|
||||
<DATOS_EXTRAIDOS>
|
||||
{
|
||||
"nombre": null,
|
||||
"email": null,
|
||||
"espacio": null,
|
||||
"rango_m2": null,
|
||||
"estilo": null,
|
||||
"urgencia": null,
|
||||
"presupuesto_declarado": null,
|
||||
"viable": null
|
||||
}
|
||||
</DATOS_EXTRAIDOS>
|
||||
|
||||
- Rellena **solo los campos que hayas capturado en este turno**.
|
||||
- Si el usuario te dio su nombre, rellena `"nombre": "valor"`.
|
||||
- Si te dijo el espacio (`cocina`, `baño`, `salón`, `integral`, `otro`), rellena `"espacio"`.
|
||||
- Para `rango_m2`: usa exactamente `"menos10"`, `"10a20"`, `"20a40"`, `"mas40"`.
|
||||
- Para `estilo`: `"funcional"`, `"cuidado"`, `"exclusivo"`.
|
||||
- Para `urgencia`: `"urgente"`, `"medio_plazo"`, `"frio"`.
|
||||
- Para `presupuesto_declarado`: escribe la cifra o rango en euros (ej: `"15000"`, `"entre 10k y 20k"`).
|
||||
- Para `viable`: pon `true` si el presupuesto declarado es suficiente (según reglas internas de Reformix – asume que cualquier presupuesto > 10.000€ es viable, a menos que el usuario indique lo contrario). Si no puedes determinar, déjalo `null`.
|
||||
|
||||
**Importante:** El bloque JSON debe aparecer **siempre**, aunque todos los valores sean `null`.
|
||||
|
||||
---
|
||||
|
||||
## 4. MANEJO DE CASOS ESPECIALES
|
||||
|
||||
### Desvío del flujo
|
||||
Si el usuario pregunta algo fuera del estado actual, responde:
|
||||
“Cuando terminemos te cuento todo con detalle. ¿Seguimos?”
|
||||
Luego retoma la pregunta pendiente.
|
||||
### Cuando dude o no sepa un dato
|
||||
Ayúdale con referencias concretas, sin presionar:
|
||||
- Tamaño: “Tranquila; un baño normal de piso son unos 4-6 m², una cocina 8-12. ¿Te encaja alguna de esas?”
|
||||
- Materiales/estilo: “Por ponerte un ejemplo: funcional sería tipo Leroy Merlin, cuidado marcas como Roca o Porcelanosa, y premium ya serie alta. ¿Cuál te suena más?”
|
||||
|
||||
### Reintentos
|
||||
Si la respuesta del usuario no es válida para el estado actual, reformula la misma pregunta ofreciendo opciones concretas.
|
||||
Máximo **2 reintentos**. Al tercero:
|
||||
“Cerramos por ahora; cuando estés listo, aquí estamos.”
|
||||
Si la respuesta no encaja con el dato que toca, reformula con calidez y dando opciones. Sin sonar borde ni repetir la misma frase. Máximo 2 intentos; al tercero, cierra con cariño: “Lo dejamos aquí de momento; cuando quieras seguimos, sin prisa.”
|
||||
|
||||
### Inactividad (lo gestiona el scheduler, pero lo incluyes por contexto)
|
||||
- 24h sin respuesta: “Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto.”
|
||||
- 48h sin respuesta: se cierra como perdido (no envías mensaje).
|
||||
|
||||
### Mensajes multimedia
|
||||
- **Audio:** Transcríbelo y trátalo como texto. Si no entiendes: “No te escuché bien, ¿puedes repetirlo?”
|
||||
- **Imagen en ESPACIO o TAMAÑO:** Infiere el espacio y los m2 aproximados de la foto y úsalo como respuesta para ese estado.
|
||||
- **Imagen en ESTILO:** Infiere el estilo o calidad que busca por lo que muestra la foto.
|
||||
- **Imagen en otro estado:** “Gracias por la foto; cuéntame con palabras para asegurarme de entenderte bien.”
|
||||
- **Sticker u otro:** Ignora el contenido y usa el mensaje de desvío.
|
||||
### Multimedia
|
||||
- **Audio:** trátalo como texto. Si no entiendes: “Oye, no te he oído bien, ¿me lo repites?”
|
||||
- **Imagen en ESPACIO o TAMAÑO:** infiere el espacio y los m² aproximados de la foto y úsalo como respuesta.
|
||||
- **Imagen en ESTILO:** infiere el estilo o la calidad que busca por lo que muestra.
|
||||
- **Imagen en otro momento:** “¡Gracias por la foto! Cuéntamelo también en un par de palabras para asegurarme de pillarlo bien.”
|
||||
|
||||
### Tono defensivo o brusco
|
||||
No te disculpes, no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural.
|
||||
No te disculpes de más ni te alteres. Sigue con calidez y cercanía. Si va al grano, tú también puedes ser breve y natural, sin sonar corporativa.
|
||||
|
||||
### Usuario que no quiere dar el presupuesto
|
||||
“No te preocupes; un rango aproximado está bien, ¿menos de 10.000, entre 10 y 30, o más?”
|
||||
Nunca lo fuerces ni lo penalices: “Sin problema, no hace falta una cifra exacta; con que me digas una franja aproximada me vale, y si lo prefieres lo dejamos abierto y te hago una propuesta realista igualmente.”
|
||||
|
||||
---
|
||||
|
||||
## 5. EJEMPLOS DE RESPUESTA CORRECTA (FEW-SHOT IMPLÍCITO)
|
||||
## 4. EJEMPLOS DE TONO CORRECTO
|
||||
|
||||
**Usuario:** Hola, necesito reformar mi cocina.
|
||||
**Luisa:** ¿Qué espacio tienes en mente, cocina, baño, salón, o algo más completo?
|
||||
<DATOS_EXTRAIDOS>{"espacio": "cocina", ... (el resto null)}</DATOS_EXTRAIDOS>
|
||||
**Luisa:** ¡Hola! Qué bien, una cocina. Para hacerme una idea, ¿sabes más o menos los metros que tiene?
|
||||
|
||||
**Usuario:** Es cocina, unos 15 metros.
|
||||
**Luisa:** ¿Tienes idea del tamaño aproximado? Menos de 10m2, entre 10 y 20, entre 20 y 40, o más de 40?
|
||||
<DATOS_EXTRAIDOS>{"espacio": "cocina", "rango_m2": "10a20", ...}</DATOS_EXTRAIDOS>
|
||||
**Luisa:** Genial, 15 m² me vale. ¿Y cómo te la imaginas: algo funcional, un acabado más cuidado, o ya premium?
|
||||
|
||||
**Usuario:** Quiero algo moderno pero no muy caro.
|
||||
**Luisa:** ¿Cómo te imaginas el resultado? Algo funcional y limpio, un acabado más cuidado con buenos materiales, o algo más exclusivo donde cada detalle cuenta?
|
||||
<DATOS_EXTRAIDOS>{"estilo": "funcional", ...}</DATOS_EXTRAIDOS>
|
||||
**Luisa:** Te pillo, práctico y con buena pinta sin pasarse de precio. ¿Para cuándo te gustaría tenerla lista?
|
||||
|
||||
**Usuario:** Tengo un presupuesto de 8000 euros.
|
||||
**Luisa:** Gracias por tu tiempo; ahora mismo no podríamos darte el resultado que mereces con ese presupuesto. Si en algún momento cambia, aquí estamos.
|
||||
<DATOS_EXTRAIDOS>{"presupuesto_declarado": "8000", "viable": false}</DATOS_EXTRAIDOS>
|
||||
**Usuario:** Tengo un presupuesto de 4500 euros.
|
||||
**Luisa:** Perfecto, me sirve para orientarme. Con eso ya te preparo una propuesta realista para tu cocina. 👍
|
||||
|
||||
**Usuario:** Vale, puedo subir a 15000.
|
||||
**Luisa:** Con todo esto ya preparo tu presupuesto. En un momento lo recibes aquí mismo.
|
||||
<DATOS_EXTRAIDOS>{"presupuesto_declarado": "15000", "viable": true}</DATOS_EXTRAIDOS>
|
||||
**Usuario:** No sé cuánto me quiero gastar la verdad.
|
||||
**Luisa:** Tranquila, no pasa nada; lo dejamos abierto y te hago un presupuesto realista con lo que me has contado.
|
||||
|
||||
---
|
||||
|
||||
## 6. RECORDATORIO FINAL PARA EL MODELO
|
||||
## 5. RECORDATORIO FINAL PARA EL MODELO
|
||||
|
||||
- **NUNCA digas que eres un asistente de IA, ni que no eres Luisa.**
|
||||
- **SIGUE la máquina de estados estrictamente.**
|
||||
- **INCLUYE el bloque JSON en CADA respuesta.**
|
||||
- **USA siempre “tú” y mantén el tono cercano pero profesional.**
|
||||
- **Una sola pregunta por mensaje.**
|
||||
- **Máximo 2 líneas de texto (sin contar el JSON).**
|
||||
- **Sé simpática, cálida y servicial en cada mensaje**; acompaña, no interrogues.
|
||||
- **Sigue el orden de los datos con naturalidad**, sin repreguntar lo que ya te han dicho.
|
||||
- **Varía la redacción**: nada de frases calcadas turno tras turno.
|
||||
- **Nunca rechaces a un cliente** por su presupuesto; el cierre siempre es positivo.
|
||||
- **Una sola pregunta por mensaje**, 2-3 líneas, algún emoji suave ocasional.
|
||||
- **No escribas JSON ni etiquetas**: solo el mensaje natural para WhatsApp.
|
||||
|
||||
Ahora actúa como Luisa.
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> FIN
|
||||
|
||||
El orden es la guia para no dejarte datos; la conversacion fluye natural, no es un cuestionario. Avanzas
|
||||
cuando el usuario te da una respuesta valida para el dato actual, sin repreguntar lo que ya te conto.
|
||||
|
||||
## Datos a recolectar
|
||||
|
||||
| Estado | Campo DB | Valores validos |
|
||||
@@ -12,46 +15,26 @@ NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> F
|
||||
| TAMANO | rango_m2 | menos10, 10a20, 20a40, mas40 |
|
||||
| ESTILO | estilo | funcional, cuidado, exclusivo |
|
||||
| URGENCIA | urgencia | urgente, medio_plazo, frio |
|
||||
| PRESUPUESTO | presupuesto_declarado | cifra o rango en euros |
|
||||
| PRESUPUESTO | presupuesto_declarado | cifra o rango en euros (orientativo) |
|
||||
|
||||
## Mensajes por estado
|
||||
## Ejemplos de tono por estado (varia la redaccion, no son frases literales)
|
||||
|
||||
**APERTURA:** "Hola, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto.
|
||||
**APERTURA:** "¡Hola! Soy Luisa, de Reformix; vi que pediste presupuesto en la web y te ayudo a prepararlo. ¿Tienes un par de minutos?"
|
||||
|
||||
Tienes unos minutos ahora?"
|
||||
**ESPACIO:** "Cuentame, ¿que espacio quieres reformar: la cocina, el bano, el salon, o algo mas completo?"
|
||||
|
||||
**ESPACIO:** "Que espacio tienes en mente.
|
||||
**TAMANO:** "¿Y de tamano, mas o menos por donde anda? Si no lo tienes claro te oriento: una cocina suele rondar 8-12 m², un bano 4-6."
|
||||
|
||||
Cocina, bano, salon, o algo mas completo?"
|
||||
**ESTILO:** "¿Como te lo imaginas: funcional y practico, un acabado mas cuidado con buenos materiales, o ya algo premium?"
|
||||
|
||||
**TAMANO:** "Tienes idea del tamano aproximado.
|
||||
**URGENCIA:** "¿Para cuando te gustaria tenerlo? ¿Es algo proximo o todavia le das vueltas?"
|
||||
|
||||
Menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?"
|
||||
**PRESUPUESTO:** "Para ajustarte la propuesta, ¿tienes una cifra orientativa en mente? No hace falta que sea exacta, una franja me vale."
|
||||
|
||||
**ESTILO:** "Como te imaginas el resultado.
|
||||
**FIN (cierre cálido, siempre positivo):** "¡Genial! Con esto ya te preparo tu presupuesto con el render. En un momentito lo tienes aqui mismo."
|
||||
|
||||
Algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?"
|
||||
**DESVIO (con simpatia):** "Buena pregunta; eso lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, sigamos para tenertelo listo, ¿vale?"
|
||||
|
||||
**URGENCIA:** "Y cuando tienes pensado arrancar.
|
||||
**SEGUIMIENTO FASE 3:** "¡Hola! ¿Te llego bien el presupuesto? ¿Te quedo alguna duda?"
|
||||
|
||||
Es algo proximo o todavia estas explorando?"
|
||||
|
||||
**PRESUPUESTO:** "Ultima pregunta.
|
||||
|
||||
Tienes en mente un presupuesto aproximado para la reforma?"
|
||||
|
||||
**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto.
|
||||
|
||||
En un momento lo recibes aqui mismo."
|
||||
|
||||
**FIN_NO_VIABLE:** "Gracias por tu tiempo; ahora mismo no podriamos darte el resultado que mereces con ese presupuesto.
|
||||
|
||||
Si en algun momento cambia, aqui estamos."
|
||||
|
||||
**DESVIO:** "Cuando terminemos te cuento todo con detalle.
|
||||
|
||||
Seguimos?"
|
||||
|
||||
**SEGUIMIENTO FASE 3:** "Hola, te llego bien el presupuesto.
|
||||
|
||||
Quedaste con alguna duda?"
|
||||
> Nunca rechazas a un cliente por su presupuesto: el cierre es siempre positivo, sea cual sea la cifra.
|
||||
|
||||
@@ -45,6 +45,25 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Post-análisis: la app lee toda la conversación del lead y extrae los datos clave de una pasada.
|
||||
async analizarConversacion(leadId: string): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/analizar`, {});
|
||||
}
|
||||
|
||||
async buscarLeadPorTelefono(telefono: string): Promise<string | null> {
|
||||
try {
|
||||
const { data } = await axios.get(`${this.baseUrl}/api/leads/by-phone`, {
|
||||
headers: this.headers,
|
||||
params: { telefono },
|
||||
});
|
||||
return data?.leadId ?? null;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) return null;
|
||||
this.logger.error(`buscarLeadPorTelefono error: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async guardarConversacion(
|
||||
leadId: string,
|
||||
rol: 'user' | 'assistant' | 'system',
|
||||
|
||||
@@ -6,8 +6,9 @@ import { LeadsService } from '../leads/leads.service';
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT =
|
||||
'Eres Luisa, asesora de Reformix en Madrid. Cualificas leads de reforma por WhatsApp. ' +
|
||||
'Hablas espanol de Espana, cercana y natural; adapta el registro al usuario (coloquial si el, formal si el). ' +
|
||||
'Un mensaje por turno, maximo 2 lineas. Sin emojis, sin guiones largos. ' +
|
||||
'Hablas espanol de Espana, calida, simpatica y siempre dispuesta a ayudar; adapta el registro al usuario. ' +
|
||||
'Un mensaje por turno, breve (2-3 lineas). Puedes usar algun emoji suave de vez en cuando. ' +
|
||||
'Varia como lo dices, no suenes a plantilla y no vuelvas a preguntar lo que ya te han contado. ' +
|
||||
'NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.';
|
||||
|
||||
const FRASES_IA_PROHIBIDAS = [
|
||||
@@ -17,7 +18,6 @@ const FRASES_IA_PROHIBIDAS = [
|
||||
/\bchatgpt\b/i,
|
||||
/inteligencia artificial/i,
|
||||
/no tengo un nombre propio/i,
|
||||
/en qu[eé] puedo ayudarte/i,
|
||||
];
|
||||
|
||||
export interface ClasificacionResultado {
|
||||
@@ -270,17 +270,19 @@ ${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.
|
||||
Habla espanol de Espana; calida, simpatica y siempre dispuesta a ayudar, como una asesora de confianza.
|
||||
Varia como lo dices en cada turno (no repitas frases calcadas) y no vuelvas a preguntar un dato que el
|
||||
usuario ya te haya dado en este mensaje o en el historial; reconocelo con naturalidad y sigue con lo que falte.
|
||||
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 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.
|
||||
Si validacion valida es false y reintentos >= 2, repite la pregunta del estado actual de forma directa.
|
||||
Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y redirige al flujo sin avanzar.
|
||||
Si validacion valida es false y reintentos < 2, ayudale con calidez dando ejemplos o referencias para que se aclare.
|
||||
Si validacion valida es false y reintentos >= 2, vuelve a la pregunta del estado de otra forma, sin sonar borde.
|
||||
Si es_desvio es true o intencion es pregunta, atiende su duda con simpatia (concede algo util) y retoma el flujo sin avanzar.
|
||||
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.`;
|
||||
Si el siguiente estado es fin_viable, cierra con calidez anunciando que ya le preparas el presupuesto.`;
|
||||
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
|
||||
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
|
||||
@@ -304,11 +306,14 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
- Intencion del usuario: ${clasificacion.intencion}
|
||||
|
||||
## 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
|
||||
- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos
|
||||
- Debe sonar como Luisa de Reformix (Madrid): calida, simpatica y servicial, nunca un asistente generico ni un teleoperador
|
||||
- Espanol de Espana, natural; usa coloquialismos y conectores suaves (vale, mira, oye, genial, tranquila, perfecto) cuando encajen
|
||||
- Un mensaje por turno, breve (2-3 lineas como mucho); puede llevar algun emoji suave ocasional, sin abusar
|
||||
- Varia la redaccion; no dejes frases calcadas ni que repitan literalmente lo que ya se dijo en turnos anteriores
|
||||
- No reescribas para quitar la cercania: si el borrador suena frio o robotico, dale calidez en vez de recortarla
|
||||
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
||||
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
||||
- Quita cualquier JSON o etiqueta tecnica que se haya colado; deja solo el mensaje natural
|
||||
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
|
||||
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
|
||||
@@ -376,12 +381,9 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
entidad.nombre = clasificacion.valor_extraido.trim();
|
||||
}
|
||||
}
|
||||
if (estadoFlujo === 'presupuesto') {
|
||||
viable = validacion.viable;
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo, viable);
|
||||
} else {
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
||||
}
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
||||
// `viable` es solo informativo (siempre true): no cambia la ruta. Luisa nunca rechaza.
|
||||
if (estadoFlujo === 'presupuesto') viable = validacion.viable;
|
||||
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
|
||||
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
||||
if (reintentos > 2) reintentos = 2;
|
||||
|
||||
@@ -37,9 +37,12 @@ export class LeadsService {
|
||||
return estado;
|
||||
}
|
||||
|
||||
getSiguienteEstado(estadoActual: string, viable?: boolean): string {
|
||||
getSiguienteEstado(estadoActual: string): string {
|
||||
const estado = this.normalizarEstadoFlujo(estadoActual);
|
||||
if (estado === 'presupuesto') return viable === false ? 'fin_no_viable' : 'fin_viable';
|
||||
// Tras el presupuesto el lead SIEMPRE cierra como viable: Luisa nunca rechaza a nadie. La
|
||||
// rentabilidad del lead la valora el reformista en el panel con su baremo interno (los agentes
|
||||
// no usan esa información para decidir nada todavía).
|
||||
if (estado === 'presupuesto') return '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];
|
||||
@@ -57,12 +60,11 @@ export class LeadsService {
|
||||
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;
|
||||
// Luisa ya no decide la viabilidad del lead: nunca rechaza por presupuesto. La rentabilidad la
|
||||
// valora el reformista en el panel (baremo interno, fase aparte). Se mantiene para informar el
|
||||
// campo `viable`, que de momento siempre es true.
|
||||
evaluarViabilidad(_presupuesto: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async persistirTurno(
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
const QRImage = require('qrcode');
|
||||
|
||||
@Injectable()
|
||||
export class WebhookListener implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(WebhookListener.name);
|
||||
private server: http.Server | null = null;
|
||||
|
||||
// Estado de vinculación de WhatsApp, alimentado por WhatsappService (sin dependencia circular).
|
||||
private qrActual: string | null = null;
|
||||
private conectado = false;
|
||||
// Diagnóstico: estado de conexión + últimos eventos entrantes (anillo de 15).
|
||||
private connState: Record<string, unknown> = {};
|
||||
private inbound: any[] = [];
|
||||
|
||||
constructor(private readonly api: ApiClient) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
@@ -16,10 +25,35 @@ export class WebhookListener implements OnApplicationBootstrap {
|
||||
this.logger.log(`Webhook listener en puerto ${port}`);
|
||||
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
|
||||
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
|
||||
this.logger.log(`QR vinculación → GET /qr (HTTP Basic, contraseña = QR_TOKEN)`);
|
||||
});
|
||||
}
|
||||
|
||||
setQr(qr: string | null) {
|
||||
this.qrActual = qr;
|
||||
}
|
||||
setConectado(b: boolean) {
|
||||
this.conectado = b;
|
||||
if (b) this.qrActual = null;
|
||||
}
|
||||
setConnState(o: Record<string, unknown>) {
|
||||
this.connState = o;
|
||||
}
|
||||
pushInbound(o: Record<string, unknown>) {
|
||||
this.inbound.unshift(o);
|
||||
if (this.inbound.length > 15) this.inbound.pop();
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const url = req.url || '';
|
||||
|
||||
if (req.method === 'GET') {
|
||||
if (url.startsWith('/qr')) return this.handleQrPage(req, res);
|
||||
if (url.startsWith('/debug')) return this.handleDebug(req, res);
|
||||
res.writeHead(404).end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405).end('Method Not Allowed');
|
||||
return;
|
||||
@@ -36,7 +70,6 @@ export class WebhookListener implements OnApplicationBootstrap {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url || '';
|
||||
try {
|
||||
if (url === '/whatsapp-start') {
|
||||
await this.handleWhatsappStart(payload);
|
||||
@@ -44,6 +77,11 @@ export class WebhookListener implements OnApplicationBootstrap {
|
||||
} else if (url === '/whatsapp-pdf') {
|
||||
await this.handleWhatsappPdf(payload);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||
} else if (url === '/whatsapp-fotos') {
|
||||
// Cross-canal: tras una llamada, la app pide que Luisa escriba al lead y le pida las fotos.
|
||||
const { fotosEmitter } = await import('../whatsapp/whatsapp.service');
|
||||
fotosEmitter.emit('fotos', payload);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||
} else {
|
||||
res.writeHead(404).end('Not Found');
|
||||
}
|
||||
@@ -54,28 +92,131 @@ export class WebhookListener implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
// Comparación en tiempo constante (sobre hashes, sin filtrar longitud).
|
||||
private tokenValido(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
const ha = crypto.createHash('sha256').update(a).digest();
|
||||
const hb = crypto.createHash('sha256').update(b).digest();
|
||||
return crypto.timingSafeEqual(ha, hb);
|
||||
}
|
||||
|
||||
// Auth Basic: contraseña = QR_TOKEN (en cabecera, nunca en la URL).
|
||||
private basicAuthOk(req: http.IncomingMessage): boolean {
|
||||
const expected = process.env.QR_TOKEN || '';
|
||||
const auth = (req.headers['authorization'] as string) || '';
|
||||
const pass = auth.startsWith('Basic ')
|
||||
? Buffer.from(auth.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':')
|
||||
: '';
|
||||
return this.tokenValido(pass, expected);
|
||||
}
|
||||
|
||||
// Diagnóstico: estado de conexión + últimos eventos entrantes. Misma auth que /qr.
|
||||
private handleDebug(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
if (!this.basicAuthOk(req)) {
|
||||
res
|
||||
.writeHead(401, {
|
||||
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
|
||||
'Content-Type': 'application/json',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
})
|
||||
.end(JSON.stringify({ ok: false, error: 'No autorizado' }));
|
||||
return;
|
||||
}
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' })
|
||||
.end(JSON.stringify({ conectado: this.conectado, connState: this.connState, inbound: this.inbound }, null, 2));
|
||||
}
|
||||
|
||||
// Página de vinculación: muestra el QR de Baileys como imagen escaneable.
|
||||
// Auth por cabecera (HTTP Basic, contraseña = QR_TOKEN), nunca por query string.
|
||||
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
if (!this.basicAuthOk(req)) {
|
||||
res
|
||||
.writeHead(401, {
|
||||
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
})
|
||||
.end('<h2>401</h2><p>Usuario: cualquiera · Contraseña: el QR_TOKEN.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
let cuerpo: string;
|
||||
if (this.conectado) {
|
||||
cuerpo = '<h2>✅ WhatsApp ya conectado</h2><p>El bot está vinculado. No hace falta escanear nada.</p>';
|
||||
} else if (this.qrActual) {
|
||||
let dataUrl = '';
|
||||
try {
|
||||
dataUrl = await QRImage.toDataURL(this.qrActual, { width: 320, margin: 2 });
|
||||
} catch {
|
||||
/* fallback abajo */
|
||||
}
|
||||
cuerpo =
|
||||
'<h2>Vincula WhatsApp del negocio</h2>' +
|
||||
'<p>WhatsApp → Dispositivos vinculados → Vincular un dispositivo → escanea:</p>' +
|
||||
(dataUrl ? '<img src="' + dataUrl + '" width="320" height="320" alt="QR" />' : '<p>No se pudo generar el QR.</p>') +
|
||||
'<p class="muted">El código rota cada pocos segundos; la página se refresca sola.</p>';
|
||||
} else {
|
||||
cuerpo = '<h2>Esperando QR…</h2><p class="muted">El bot aún no ha emitido el código. Refresca en unos segundos.</p>';
|
||||
}
|
||||
|
||||
const html =
|
||||
'<!doctype html><html lang="es"><head><meta charset="utf-8" />' +
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
||||
'<meta http-equiv="refresh" content="12" />' +
|
||||
'<title>Vincular WhatsApp · Reformix</title>' +
|
||||
'<style>body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#0f1115;color:#e6e8ec;display:flex;min-height:100vh;align-items:center;justify-content:center;text-align:center;margin:0;padding:24px}img{background:#fff;padding:12px;border-radius:12px}h2{margin:0 0 8px}.muted{color:#9aa3b2;font-size:13px}p{color:#c8cdd6}</style>' +
|
||||
'</head><body><div>' +
|
||||
cuerpo +
|
||||
'</div></body></html>';
|
||||
res
|
||||
.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
})
|
||||
.end(html);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
private leadSessions = new Map<string, { leadId: string; telefono: string; nombre: string; jid: string | null }>();
|
||||
|
||||
private normTel(t: string): string {
|
||||
return (t || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
private async handleWhatsappStart(payload: { leadId: string; telefono: string; nombre: string; empresa: string }) {
|
||||
const { leadId, telefono, nombre } = payload;
|
||||
const { leadId, nombre, empresa } = payload;
|
||||
const telefono = this.normTel(payload.telefono);
|
||||
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.`);
|
||||
|
||||
// Dispara la apertura proactiva (la envía WhatsappService, evitando dependencia circular).
|
||||
const { startEmitter } = await import('../whatsapp/whatsapp.service');
|
||||
startEmitter.emit('start', { leadId, telefono, nombre, empresa });
|
||||
this.logger.log(`Lead ${leadId} registrado; apertura disparada.`);
|
||||
}
|
||||
|
||||
getLeadIdByTelefono(telefono: string): string | null {
|
||||
return this.leadSessions.get(telefono)?.leadId ?? null;
|
||||
return this.leadSessions.get(this.normTel(telefono))?.leadId ?? null;
|
||||
}
|
||||
|
||||
registerJid(telefono: string, jid: string) {
|
||||
const session = this.leadSessions.get(telefono);
|
||||
const session = this.leadSessions.get(this.normTel(telefono));
|
||||
if (session) {
|
||||
session.jid = jid;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-registra una sesión recuperada de la BD (cuando no estaba en memoria, p. ej. tras reinicio).
|
||||
ensureSession(telefono: string, leadId: string, nombre = '') {
|
||||
const tel = this.normTel(telefono);
|
||||
if (!this.leadSessions.has(tel)) {
|
||||
this.leadSessions.set(tel, { leadId, telefono: tel, nombre, jid: null });
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -27,6 +27,8 @@ import { ApiClient } from '../api/api-client.service';
|
||||
import { wrapSocket } from 'baileys-antiban';
|
||||
|
||||
export const pdfEmitter = new EventEmitter();
|
||||
export const startEmitter = new EventEmitter();
|
||||
export const fotosEmitter = new EventEmitter();
|
||||
|
||||
interface LeadContext {
|
||||
leadId: string;
|
||||
@@ -40,7 +42,7 @@ interface LeadContext {
|
||||
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 = process.env.BAILEYS_AUTH_DIR || path.join(process.cwd(), 'auth_info_baileys');
|
||||
private readonly ultimoMsgPorJid = new Map<string, any>();
|
||||
private baileysLogger = pino({ level: 'info' });
|
||||
|
||||
@@ -48,6 +50,12 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly jidToLeadId = new Map<string, string>();
|
||||
// contexto de lead por leadId
|
||||
private readonly leadCache = new Map<string, LeadContext>();
|
||||
// leads cuya conversación ya se mandó a post-análisis (para no repetir).
|
||||
private readonly leadsAnalizados = new Set<string>();
|
||||
// leads a los que se les ha pedido foto y estamos esperándola.
|
||||
private readonly esperandoFotos = new Set<string>();
|
||||
// leads cuyo pipeline de render/presupuesto ya se disparó (perfilCompleto), para no repetir.
|
||||
private readonly pipelineDisparado = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly leadsService: LeadsService,
|
||||
@@ -62,6 +70,8 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.conectar();
|
||||
this.escucharPdf();
|
||||
this.escucharStart();
|
||||
this.escucharFotos();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
@@ -98,10 +108,176 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// Apertura proactiva: cuando el funnel dispara /whatsapp-start, Luisa escribe ella el primer
|
||||
// mensaje (el bot ya no es solo reactivo).
|
||||
private escucharStart() {
|
||||
startEmitter.on(
|
||||
'start',
|
||||
async (p: { leadId: string; telefono: string; nombre: string; empresa: string }) => {
|
||||
try {
|
||||
await this.enviarApertura(p);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[APERTURA] Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async enviarApertura(p: { leadId: string; telefono: string; nombre: string; empresa: string }) {
|
||||
if (!this.sock) {
|
||||
this.logger.warn(`[APERTURA] WhatsApp no conectado; no se envía a ${p.telefono}`);
|
||||
return;
|
||||
}
|
||||
const tel = (p.telefono || '').replace(/\D/g, '');
|
||||
let jid = `${tel}@s.whatsapp.net`;
|
||||
try {
|
||||
const res = await this.sock.onWhatsApp(tel);
|
||||
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
|
||||
else if (!res || !res[0]?.exists) this.logger.warn(`[APERTURA] ${tel} no parece estar en WhatsApp`);
|
||||
} catch {
|
||||
/* seguimos con el jid por defecto */
|
||||
}
|
||||
|
||||
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
|
||||
const empresa = p.empresa || 'Reformix';
|
||||
const apertura =
|
||||
`¡Hola ${primerNombre}! Soy Luisa, del equipo de ${empresa}. 😊\n\n` +
|
||||
`Acabas de pedir presupuesto para tu reforma y te ayudo a prepararlo (con un render de cómo ` +
|
||||
`quedaría incluido). Para empezar, cuéntame: ¿qué espacio quieres reformar? (cocina, baño, salón…)`;
|
||||
|
||||
// Contexto para los siguientes mensajes del cliente.
|
||||
this.jidToLeadId.set(jid, p.leadId);
|
||||
this.webhookListener.registerJid(tel, jid);
|
||||
this.leadCache.set(p.leadId, {
|
||||
leadId: p.leadId,
|
||||
telefono: tel,
|
||||
nombre: p.nombre || '',
|
||||
botStep: 'apertura',
|
||||
viable: null,
|
||||
});
|
||||
|
||||
await this.enviarMensaje(jid, apertura);
|
||||
this.logger.log(`[APERTURA] Enviada a ${jid} (lead ${p.leadId})`);
|
||||
|
||||
try {
|
||||
await this.api.actualizarPerfil(p.leadId, { estadoWa: 'enviado', botStep: 'apertura', canalOrigen: 'whatsapp' });
|
||||
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', apertura, { botStep: 'apertura' });
|
||||
await this.api.registrarIntento(p.leadId, 'whatsapp', 1, 'exitoso', true);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[APERTURA] No se pudo persistir en la app: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recibe una foto en modo "esperando fotos": la sube como "antes" y marca perfilCompleto, lo que
|
||||
// dispara en la app la generación de render + presupuesto + entrega del PDF.
|
||||
private async recibirFotoYFinalizar(ctx: LeadContext, jid: string, msg: any, msgContent: any): Promise<void> {
|
||||
if (!this.sock || this.pipelineDisparado.has(ctx.leadId)) return;
|
||||
try {
|
||||
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';
|
||||
|
||||
this.esperandoFotos.delete(ctx.leadId);
|
||||
this.pipelineDisparado.add(ctx.leadId);
|
||||
|
||||
await this.api.enviarIngesta(
|
||||
ctx.leadId,
|
||||
[{ tipo: 'foto', imagen: `data:${mimeType};base64,${base64}`, zona: 'otro', momento: 'antes' }],
|
||||
{ perfilCompleto: true },
|
||||
);
|
||||
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', '[foto del espacio]', { botStep: 'fotos_recibidas' });
|
||||
const conf = '¡Perfecto! Con esto preparo tu presupuesto con el render. En un momento te llega aquí mismo 🛠️';
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', conf, { botStep: 'fotos_recibidas' });
|
||||
await this.enviarMensaje(jid, conf);
|
||||
this.logger.log(`[FOTOS] lead ${ctx.leadId}: foto recibida → perfilCompleto disparado`);
|
||||
} catch (err: any) {
|
||||
this.pipelineDisparado.delete(ctx.leadId);
|
||||
this.logger.error(`[FOTOS] error procesando foto de ${ctx.leadId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-canal: tras una llamada, la app pide por webhook que Luisa escriba al lead, referencie lo
|
||||
// hablado y le pida las fotos. Reutiliza el mismo modo de recogida.
|
||||
private escucharFotos() {
|
||||
fotosEmitter.on(
|
||||
'fotos',
|
||||
async (p: { leadId: string; telefono: string; nombre: string; empresa?: string; contexto?: string }) => {
|
||||
try {
|
||||
await this.iniciarRecogidaFotos(p);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[FOTOS] iniciarRecogida error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async iniciarRecogidaFotos(p: {
|
||||
leadId: string;
|
||||
telefono: string;
|
||||
nombre: string;
|
||||
empresa?: string;
|
||||
contexto?: string;
|
||||
}): Promise<void> {
|
||||
if (!this.sock) {
|
||||
this.logger.warn(`[FOTOS] WhatsApp no conectado; no se pide foto a ${p.telefono}`);
|
||||
return;
|
||||
}
|
||||
const jid = await this.resolverJidYRegistrar(p.leadId, p.telefono, p.nombre, 'pide_fotos');
|
||||
this.esperandoFotos.add(p.leadId);
|
||||
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
|
||||
const empresa = p.empresa || 'Reformix';
|
||||
const ctx = p.contexto ? ` sobre ${p.contexto}` : '';
|
||||
const mensaje =
|
||||
`¡Hola ${primerNombre}! Soy Luisa, de ${empresa}. 😊 Gracias por tu llamada${ctx}. ` +
|
||||
`Para terminar tu presupuesto con el render, mándame una foto del espacio 📸`;
|
||||
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', mensaje, { botStep: 'pide_fotos' });
|
||||
await this.enviarMensaje(jid, mensaje);
|
||||
this.logger.log(`[FOTOS] recogida iniciada para lead ${p.leadId} (cross-canal)`);
|
||||
}
|
||||
|
||||
// Resuelve el jid real del teléfono (vía onWhatsApp) y registra el contexto del lead.
|
||||
private async resolverJidYRegistrar(leadId: string, telefono: string, nombre: string, botStep: string): Promise<string> {
|
||||
const tel = (telefono || '').replace(/\D/g, '');
|
||||
let jid = `${tel}@s.whatsapp.net`;
|
||||
try {
|
||||
const res = await this.sock?.onWhatsApp(tel);
|
||||
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
|
||||
} catch {
|
||||
/* jid por defecto */
|
||||
}
|
||||
this.jidToLeadId.set(jid, leadId);
|
||||
this.webhookListener.registerJid(tel, jid);
|
||||
if (!this.leadCache.has(leadId)) {
|
||||
this.leadCache.set(leadId, { leadId, telefono: tel, nombre: nombre || '', botStep, viable: null });
|
||||
}
|
||||
return jid;
|
||||
}
|
||||
|
||||
private normalizarTelefono(jid: string): string {
|
||||
return jid.split('@')[0].replace(/\D/g, '');
|
||||
}
|
||||
|
||||
// WhatsApp puede entregar mensajes desde una dirección @lid (id de privacidad) en vez del número.
|
||||
// Resolvemos el número real vía remoteJidAlt o el mapa LID→PN de Baileys; si no, caemos al jid.
|
||||
private resolverTelefono(msg: any): string {
|
||||
const jid: string = msg.key?.remoteJid || '';
|
||||
if (jid.endsWith('@lid')) {
|
||||
const alt = msg.key?.remoteJidAlt;
|
||||
if (typeof alt === 'string' && alt.includes('@s.whatsapp.net')) return this.normalizarTelefono(alt);
|
||||
try {
|
||||
const pn = (this.sock as any)?.signalRepository?.lidMapping?.getPNForLID?.(jid);
|
||||
if (typeof pn === 'string' && pn) return this.normalizarTelefono(pn);
|
||||
} catch {
|
||||
/* sin mapping disponible */
|
||||
}
|
||||
}
|
||||
return this.normalizarTelefono(jid);
|
||||
}
|
||||
|
||||
private calcularDelayEscritura(longitudTexto: number): number {
|
||||
const min = 1500;
|
||||
const max = 4000;
|
||||
@@ -124,7 +300,10 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
auth: state,
|
||||
printQRInTerminal: false,
|
||||
logger: this.baileysLogger,
|
||||
markOnlineOnConnect: false,
|
||||
// true: marca el dispositivo "online" al conectar para que WhatsApp le ENTREGUE los mensajes
|
||||
// entrantes tras reconectar (con false, al reanudar la sesión quedaba "no disponible" y no
|
||||
// recibía nada aunque el socket dijera "open").
|
||||
markOnlineOnConnect: true,
|
||||
generateHighQualityLinkPreview: false,
|
||||
syncFullHistory: false,
|
||||
});
|
||||
@@ -134,23 +313,43 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
this.sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
this.webhookListener.setConnState({
|
||||
connection: connection ?? null,
|
||||
hasQr: !!qr,
|
||||
lastDisconnect: (lastDisconnect?.error as Boom)?.output?.statusCode ?? null,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (qr) {
|
||||
QRCode.generate(qr, { small: true });
|
||||
console.log('\n📲 Escanea este QR con WhatsApp\n');
|
||||
console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n');
|
||||
this.webhookListener.setQr(qr);
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const shouldReconnect =
|
||||
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
|
||||
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
|
||||
this.webhookListener.setConectado(false);
|
||||
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.webhookListener.setConectado(true);
|
||||
}
|
||||
});
|
||||
|
||||
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
for (const msg of messages) {
|
||||
this.webhookListener.pushInbound({
|
||||
type,
|
||||
remoteJid: msg.key.remoteJid ?? null,
|
||||
remoteJidAlt: (msg.key as any).remoteJidAlt ?? null,
|
||||
fromMe: !!msg.key.fromMe,
|
||||
msgType: msg.message ? Object.keys(msg.message)[0] : null,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (type !== 'notify') return;
|
||||
for (const msg of messages) {
|
||||
if (msg.key.fromMe) continue;
|
||||
@@ -198,8 +397,18 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
|
||||
const leadId = this.webhookListener.getLeadIdByTelefono(telefono);
|
||||
let leadId = this.webhookListener.getLeadIdByTelefono(telefono);
|
||||
|
||||
// Fallback: si no está en memoria (reinicio del bot), recuperarlo de la BD por teléfono.
|
||||
if (!leadId) {
|
||||
leadId = await this.api.buscarLeadPorTelefono(telefono);
|
||||
if (leadId) {
|
||||
this.webhookListener.ensureSession(telefono, leadId);
|
||||
this.logger.log(`Lead ${leadId} recuperado por teléfono ${telefono} (sin sesión en memoria).`);
|
||||
}
|
||||
}
|
||||
|
||||
this.webhookListener.pushInbound({ stage: 'match', telefono, leadId: leadId ?? null, at: new Date().toISOString() });
|
||||
if (!leadId) {
|
||||
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
|
||||
return null;
|
||||
@@ -227,7 +436,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
const jid = msg.key.remoteJid!;
|
||||
if (jid.includes('@g.us')) return;
|
||||
|
||||
const telefono = jid.split('@')[0];
|
||||
const telefono = this.resolverTelefono(msg);
|
||||
|
||||
try {
|
||||
const ctx = await this.getOrCreateContext(telefono, jid);
|
||||
@@ -239,6 +448,13 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
const msgContent = normalizeMessageContent(msg.message);
|
||||
if (!msgContent) return;
|
||||
|
||||
// Modo recogida de fotos (tras cerrar la cualificación o tras una llamada): la foto cierra el
|
||||
// flujo → sube la foto + dispara render/presupuesto, sin re-cualificar.
|
||||
if (msgContent.imageMessage && this.esperandoFotos.has(ctx.leadId)) {
|
||||
await this.recibirFotoYFinalizar(ctx, jid, msg, msgContent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||
} else if (msgContent.audioMessage) {
|
||||
@@ -337,10 +553,34 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
|
||||
}
|
||||
|
||||
// ¿Estamos en el cierre? Por estado (errático) O porque Luisa anuncia el presupuesto.
|
||||
const estadosCierre = ['presupuesto', 'fin_viable', 'fin_no_viable'];
|
||||
const anunciaPresupuesto =
|
||||
/presupuesto/i.test(respuesta) &&
|
||||
/prepar|recib|enseguida|en un momento|te lo env|lo env|aqu[ií] mismo/i.test(respuesta);
|
||||
const esCierre = estadosCierre.includes(ctx.botStep) || anunciaPresupuesto;
|
||||
|
||||
// Al cerrar, dispara el post-análisis de toda la conversación (una sola vez).
|
||||
if (esCierre && !this.leadsAnalizados.has(ctx.leadId)) {
|
||||
this.leadsAnalizados.add(ctx.leadId);
|
||||
this.api
|
||||
.analizarConversacion(ctx.leadId)
|
||||
.then((ok) => this.logger.log(`[ANALISIS] lead ${ctx.leadId}: ${ok ? 'ok' : 'fallo'}`))
|
||||
.catch((e: any) => this.logger.error(`[ANALISIS] ${e.message}`));
|
||||
}
|
||||
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
|
||||
botStep: ctx.botStep,
|
||||
});
|
||||
await this.enviarMensaje(jid, respuesta);
|
||||
|
||||
// Tras cerrar, pide una foto para el render (si no la hemos pedido/recibido ya).
|
||||
if (esCierre && !this.esperandoFotos.has(ctx.leadId) && !this.pipelineDisparado.has(ctx.leadId)) {
|
||||
this.esperandoFotos.add(ctx.leadId);
|
||||
const pedir = 'Una última cosa para incluir el render en tu presupuesto: mándame una foto del espacio 📸';
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', pedir, { botStep: 'pide_fotos' });
|
||||
await this.enviarMensaje(jid, pedir);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -186,6 +186,7 @@ CREATE TABLE "pricing_config" (
|
||||
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL,
|
||||
"baremo_minimo" integer,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
|
||||
);
|
||||
|
||||
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "pricing_config" ADD COLUMN "baremo_minimo" integer;
|
||||
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1780593183911,
|
||||
"tag": "0011_warm_post",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781189893331,
|
||||
"tag": "0012_lame_sentinel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
mvp/b2c/package-lock.json
generated
7
mvp/b2c/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.6",
|
||||
"nodemailer": "^8.0.10",
|
||||
@@ -4546,6 +4547,12 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/driver.js": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
|
||||
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "0.31.10",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.6",
|
||||
"nodemailer": "^8.0.10",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
mvp/b2c/public/whatsapp.png
Normal file
BIN
mvp/b2c/public/whatsapp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -46,7 +46,7 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
|
||||
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||
style={themeStyle(tenant.themePreset, tenant.themeColor)}
|
||||
>
|
||||
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} showLogin />
|
||||
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
|
||||
<main id="main-content">
|
||||
<Hero slug={tenant.slug} />
|
||||
<ReformaSlider />
|
||||
|
||||
14
mvp/b2c/src/app/api/leads/[id]/analizar/route.ts
Normal file
14
mvp/b2c/src/app/api/leads/[id]/analizar/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
|
||||
import { analizarConversacion } from '@/lib/funnel/analizar-conversacion';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Post-análisis: lee toda la conversación de WhatsApp del lead, extrae los datos clave con un LLM y
|
||||
// los persiste en el lead. Lo llama el bot al cerrar la cualificación (o se puede invocar a posteriori).
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
|
||||
const { id } = await params;
|
||||
const resultado = await analizarConversacion(id);
|
||||
return jsonResponse(resultado, resultado.ok ? 200 : 422);
|
||||
}
|
||||
37
mvp/b2c/src/app/api/leads/by-phone/route.ts
Normal file
37
mvp/b2c/src/app/api/leads/by-phone/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { desc, like } 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';
|
||||
|
||||
// Busca el lead más reciente por teléfono (comparando los últimos 9 dígitos, ignorando prefijos y
|
||||
// formato). Lo usa el bot de WhatsApp para recuperar el leadId de un mensaje entrante cuando no
|
||||
// tiene la sesión en memoria (p. ej. tras un reinicio del contenedor).
|
||||
export async function GET(req: Request) {
|
||||
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
|
||||
|
||||
const tel = (new URL(req.url).searchParams.get('telefono') ?? '').replace(/\D/g, '');
|
||||
if (tel.length < 6) return jsonResponse({ ok: false, error: 'telefono inválido.' }, 422);
|
||||
const last9 = tel.slice(-9);
|
||||
|
||||
const [lead] = await db
|
||||
.select({
|
||||
id: leads.id,
|
||||
nombre: leads.nombre,
|
||||
telefono: leads.telefono,
|
||||
botStep: leads.botStep,
|
||||
viable: leads.viable,
|
||||
})
|
||||
.from(leads)
|
||||
.where(like(leads.telefono, `%${last9}%`))
|
||||
.orderBy(desc(leads.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (!lead) return jsonResponse({ ok: false, error: 'Lead no encontrado.' }, 404);
|
||||
return jsonResponse(
|
||||
{ leadId: lead.id, nombre: lead.nombre, telefono: lead.telefono, botStep: lead.botStep, viable: lead.viable },
|
||||
200,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { desc, eq, like } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads, leadPipelineEventos } from '@/db/schema';
|
||||
import { leads, leadPipelineEventos, tenants } from '@/db/schema';
|
||||
import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
|
||||
import { analizarTranscripcion } from '@/lib/funnel/analizar-conversacion';
|
||||
import { pedirFotosWhatsapp } from '@/lib/webhooks';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -77,5 +79,44 @@ export async function POST(req: Request): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
return ok({ matched: true, leadId, transcript: Boolean(detalle.transcript), grabacion: Boolean(audioUrl) });
|
||||
// Mismo cerebro de captura que WhatsApp: extrae los campos clave de la transcripción de la llamada.
|
||||
const analisis = detalle.transcript
|
||||
? await analizarTranscripcion(leadId, detalle.transcript, 'llamada')
|
||||
: { ok: false as const, error: 'sin transcripción' };
|
||||
|
||||
// Cross-canal: tras la llamada, Luisa escribe al lead por WhatsApp, referencia lo hablado y le
|
||||
// pide las fotos (el agente de voz le dijo que las enviara por ahí).
|
||||
let fotosPedidas = false;
|
||||
if (analisis.ok) {
|
||||
const [info] = await db
|
||||
.select({ nombre: leads.nombre, telefono: leads.telefono, tenantId: leads.tenantId })
|
||||
.from(leads)
|
||||
.where(eq(leads.id, leadId))
|
||||
.limit(1);
|
||||
if (info) {
|
||||
const [t] = await db
|
||||
.select({ nombre: tenants.nombreEmpresa })
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, info.tenantId))
|
||||
.limit(1);
|
||||
const tipo = (analisis.perfil?.tipoReforma as string | undefined) ?? null;
|
||||
const contexto = tipo ? `la reforma de tu ${tipo}` : 'tu reforma';
|
||||
fotosPedidas = await pedirFotosWhatsapp({
|
||||
leadId,
|
||||
telefono: info.telefono,
|
||||
nombre: info.nombre,
|
||||
empresa: t?.nombre ?? 'Reformix',
|
||||
contexto,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ok({
|
||||
matched: true,
|
||||
leadId,
|
||||
transcript: Boolean(detalle.transcript),
|
||||
grabacion: Boolean(audioUrl),
|
||||
analizado: analisis.ok,
|
||||
fotosPedidas,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +58,20 @@
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms ease;
|
||||
--transition-slow: 400ms ease;
|
||||
|
||||
/* Animations (usar con motion-safe: para respetar prefers-reduced-motion) */
|
||||
--animate-fade-up: fade-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLead } from '@/db/queries';
|
||||
import { getPricingConfigFor } from '@/db/pricing-queries';
|
||||
import EstadoControl from '@/components/panel/EstadoControl';
|
||||
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
|
||||
@@ -19,9 +20,20 @@ import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
tour,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
tour?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3">
|
||||
<section
|
||||
data-tour={tour}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3"
|
||||
>
|
||||
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
@@ -41,6 +53,14 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
|
||||
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
||||
|
||||
// Baremo de rentabilidad del reformista (informativo): si el presupuesto estimado no lo alcanza,
|
||||
// se marca en rojo. null = sin baremo o sin presupuesto aún (no se marca nada).
|
||||
const baremoMinimo = (await getPricingConfigFor(lead.tenantId)).baremoMinimo ?? null;
|
||||
const pasaBaremo =
|
||||
baremoMinimo != null && lead.presupuestoEstimado != null
|
||||
? lead.presupuestoEstimado >= baremoMinimo
|
||||
: null;
|
||||
|
||||
const h = await headers();
|
||||
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
||||
const proto = h.get('x-forwarded-proto') ?? 'https';
|
||||
@@ -62,16 +82,25 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-right" data-tour="ficha-presupuesto">
|
||||
<div className="text-xs text-gray-400">Presupuesto estimado</div>
|
||||
<div className="text-2xl font-black text-black">{formatEuros(lead.presupuestoEstimado)}</div>
|
||||
<div className={`text-2xl font-black ${pasaBaremo === false ? 'text-red-600' : 'text-black'}`}>
|
||||
{formatEuros(lead.presupuestoEstimado)}
|
||||
</div>
|
||||
{pasaBaremo === false && baremoMinimo != null && (
|
||||
<div className="mt-0.5 text-xs font-semibold text-red-600">
|
||||
Por debajo de tu baremo ({formatEuros(baremoMinimo)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EstadoControl
|
||||
leadId={lead.id}
|
||||
estado={lead.estado}
|
||||
presupuestoEstimado={lead.presupuestoEstimado}
|
||||
/>
|
||||
<div data-tour="ficha-estado">
|
||||
<EstadoControl
|
||||
leadId={lead.id}
|
||||
estado={lead.estado}
|
||||
presupuestoEstimado={lead.presupuestoEstimado}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solicitar opinión al cliente */}
|
||||
@@ -164,7 +193,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
</Section>
|
||||
|
||||
{/* 4. Render */}
|
||||
<Section title="Render generado">
|
||||
<Section title="Render generado" tour="ficha-render">
|
||||
{lead.renderUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={lead.renderUrl} alt="Render de la reforma" className="w-full rounded-lg object-cover" />
|
||||
@@ -311,7 +340,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
)}
|
||||
|
||||
{/* Presupuesto desglosado */}
|
||||
<Section title="Presupuesto desglosado">
|
||||
<Section title="Presupuesto desglosado" tour="ficha-desglose">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db } from '@/db';
|
||||
import { tenants } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import AppNav from '@/components/AppNav';
|
||||
import PanelTour from '@/components/panel/PanelTour';
|
||||
|
||||
const PANEL_LINKS = [
|
||||
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
||||
@@ -44,6 +45,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
||||
<PanelTour />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export default async function PanelPage({
|
||||
</div>
|
||||
|
||||
{/* Filtros por estado */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2" data-tour="leads-filtros">
|
||||
{FILTROS.map((f) => {
|
||||
const active = f.value === filtro;
|
||||
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
|
||||
@@ -89,7 +89,9 @@ export default async function PanelPage({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<LeadsView leads={leadsView} />
|
||||
<div data-tour="leads-tabla">
|
||||
<LeadsView leads={leadsView} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,19 @@ export async function actualizarExtras(formData: FormData) {
|
||||
revalidatePath('/panel/precios');
|
||||
}
|
||||
|
||||
export async function actualizarBaremo(formData: FormData) {
|
||||
const tenantId = await getTenantId();
|
||||
const raw = formData.get('baremoMinimo');
|
||||
const txt = typeof raw === 'string' ? raw.trim() : '';
|
||||
// Vacío = sin baremo (null). Con valor = euros → céntimos.
|
||||
const baremoMinimo = txt === '' ? null : eurosToCents(raw, 'baremo de rentabilidad');
|
||||
await db
|
||||
.update(pricingConfig)
|
||||
.set({ baremoMinimo, updatedAt: new Date() })
|
||||
.where(eq(pricingConfig.tenantId, tenantId));
|
||||
revalidatePath('/panel/precios');
|
||||
}
|
||||
|
||||
export async function actualizarEnvio(formData: FormData) {
|
||||
const tenantId = await getTenantId();
|
||||
const modo = formData.get('modo');
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
borrarMaterial,
|
||||
actualizarConfig,
|
||||
actualizarExtras,
|
||||
actualizarBaremo,
|
||||
actualizarEnvio,
|
||||
importarCatalogoCsv,
|
||||
} from './actions';
|
||||
@@ -81,7 +82,7 @@ export default async function PreciosPage() {
|
||||
</section>
|
||||
|
||||
{/* Config general */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-config">
|
||||
<h2 className="font-bold text-black mb-4">Configuración general</h2>
|
||||
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
|
||||
<label className="text-sm">
|
||||
@@ -120,6 +121,34 @@ export default async function PreciosPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Baremo de rentabilidad */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-baremo">
|
||||
<h2 className="font-bold text-black mb-1">Baremo de rentabilidad</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Importe mínimo de un trabajo para que te resulte rentable. Es solo orientativo para ti: en la
|
||||
ficha de cada lead verás marcados en otro color los presupuestos que no lleguen a este valor.
|
||||
No afecta a lo que ve el cliente ni a la conversación de los agentes. Déjalo vacío para no usar
|
||||
baremo.
|
||||
</p>
|
||||
<form action={actualizarBaremo} className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-gray-500 mb-1">Baremo mínimo (€)</span>
|
||||
<input
|
||||
name="baremoMinimo"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
defaultValue={config.baremoMinimo != null ? config.baremoMinimo / 100 : ''}
|
||||
placeholder="Sin baremo"
|
||||
className="w-40 border border-gray-300 rounded-lg px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
<button className="bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Guardar baremo
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Extras fijos */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
|
||||
@@ -256,7 +285,7 @@ export default async function PreciosPage() {
|
||||
})}
|
||||
|
||||
{/* Import CSV */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-catalogo">
|
||||
<h2 className="font-bold text-black mb-2">Importar catálogo (CSV)</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Cabecera: <code className="break-all">categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El
|
||||
|
||||
@@ -1,82 +1,295 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getPublicLead } from '@/lib/funnel/public-queries';
|
||||
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
|
||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BRAND = 'var(--brand, #0a0a0a)';
|
||||
const BRAND_CONTRAST = 'var(--brand-contrast, #ffffff)';
|
||||
|
||||
function IconLlamada() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconWhatsapp() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFormulario() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 2v6h6M16 13H8M16 17H8M10 9H8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const CANALES = [
|
||||
{
|
||||
slug: 'llamada',
|
||||
icon: '📞',
|
||||
icon: <IconLlamada />,
|
||||
titulo: 'Que te llamemos',
|
||||
descripcion:
|
||||
'Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.',
|
||||
cta: 'Quiero que me llamen',
|
||||
badge: 'La más rápida',
|
||||
},
|
||||
{
|
||||
slug: 'whatsapp',
|
||||
icon: '💬',
|
||||
icon: <IconWhatsapp />,
|
||||
titulo: 'Por WhatsApp',
|
||||
descripcion:
|
||||
'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
|
||||
descripcion: 'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
|
||||
cta: 'Seguir por WhatsApp',
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
slug: 'formulario',
|
||||
icon: '📝',
|
||||
icon: <IconFormulario />,
|
||||
titulo: 'Rellenar un formulario',
|
||||
descripcion:
|
||||
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
|
||||
cta: 'Rellenar el formulario',
|
||||
badge: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const PASOS_DESPUES = [
|
||||
{
|
||||
titulo: 'Nos cuentas tu reforma a tu manera',
|
||||
body: 'Fotos, medidas, gustos. Cuanto más nos cuentes, más se parecerá el resultado a lo que tienes en la cabeza.',
|
||||
},
|
||||
{
|
||||
titulo: 'Render + presupuesto en minutos',
|
||||
body: 'Ves tu espacio ya reformado en imágenes, con un presupuesto orientativo desglosado partida a partida.',
|
||||
},
|
||||
// El tercer paso interpola el nombre del reformista; se monta en el componente.
|
||||
] as const;
|
||||
|
||||
function Stepper() {
|
||||
const conectores = 'h-px flex-1 min-w-4';
|
||||
return (
|
||||
<ol className="flex items-center gap-2.5 sm:gap-3" aria-label="Progreso de tu solicitud">
|
||||
<li className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 16l7 7L26 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="hidden sm:inline text-xs font-semibold text-gray-600">Tus datos</span>
|
||||
</li>
|
||||
<li aria-hidden="true" className={conectores} style={{ backgroundColor: BRAND }} />
|
||||
<li className="flex items-center gap-2 shrink-0" aria-current="step">
|
||||
<span
|
||||
className="w-6 h-6 rounded-full text-[11px] font-black flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||
>
|
||||
2
|
||||
</span>
|
||||
<span className="text-xs font-bold text-black">Tu reforma</span>
|
||||
</li>
|
||||
<li aria-hidden="true" className={`${conectores} bg-gray-200`} />
|
||||
<li className="flex items-center gap-2 shrink-0">
|
||||
<span className="w-6 h-6 rounded-full bg-white border border-gray-300 text-[11px] font-bold text-gray-400 flex items-center justify-center shrink-0">
|
||||
3
|
||||
</span>
|
||||
<span className="hidden sm:inline text-xs font-semibold text-gray-400">
|
||||
Render + presupuesto
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ChooserPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const data = await getPublicLead(id);
|
||||
if (!data) notFound();
|
||||
|
||||
const { lead, tenant } = data;
|
||||
const theme = resolveTheme(tenant?.themePreset, tenant?.themeColor);
|
||||
const nombrePila = lead.nombre.split(' ')[0];
|
||||
const nombreReformista = tenant?.nombreEmpresa ?? 'el reformista';
|
||||
|
||||
const pasosDespues = [
|
||||
...PASOS_DESPUES,
|
||||
{
|
||||
titulo: 'Visita gratuita para el presupuesto final',
|
||||
body: `Si te convence, acuerdas una visita con ${nombreReformista}: ve la obra, la mide con detalle y te confirma el precio definitivo. Sin compromiso.`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||
style={themeStyle(tenant?.themePreset, tenant?.themeColor)}
|
||||
>
|
||||
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
|
||||
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Elige cómo seguir
|
||||
</span>
|
||||
<h1 className="text-2xl font-black tracking-tight text-black">
|
||||
¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}?
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render
|
||||
y tu presupuesto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{CANALES.map((c) => (
|
||||
<Link
|
||||
key={c.slug}
|
||||
href={`/solicitud/${id}/${c.slug}`}
|
||||
className="group bg-white border border-gray-200 rounded-xl p-5 shadow-sm flex items-center gap-4 transition-all hover:border-black hover:shadow-md"
|
||||
>
|
||||
<span className="text-3xl shrink-0" aria-hidden="true">
|
||||
{c.icon}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-base font-bold text-black">{c.titulo}</span>
|
||||
<span className="text-sm text-gray-500 leading-snug">{c.descripcion}</span>
|
||||
<span className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] mt-1">
|
||||
{c.cta} →
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Halo sutil con el color de marca del reformista */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 h-80 pointer-events-none"
|
||||
style={{
|
||||
background: `radial-gradient(60% 100% at 50% 0%, color-mix(in srgb, ${BRAND} 8%, transparent), transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="container relative max-w-4xl py-10 md:py-14 flex flex-col gap-8 md:gap-10">
|
||||
<div className="motion-safe:animate-fade-up">
|
||||
<Stepper />
|
||||
</div>
|
||||
|
||||
<header
|
||||
className="flex flex-col gap-3 max-w-2xl motion-safe:animate-fade-up"
|
||||
style={{ animationDelay: '80ms' }}
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Elige cómo seguir
|
||||
</span>
|
||||
<h1 className="text-3xl md:text-4xl font-black tracking-tight text-black leading-[1.1] text-balance">
|
||||
¿Cómo prefieres contarnos tu reforma, {nombrePila}?
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-gray-500 leading-relaxed">
|
||||
Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu
|
||||
render y tu presupuesto.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3 md:gap-4">
|
||||
{CANALES.map((c, i) => (
|
||||
<Link
|
||||
key={c.slug}
|
||||
href={`/solicitud/${id}/${c.slug}`}
|
||||
className="group relative flex items-start gap-4 md:flex-col md:gap-5 bg-white border border-gray-200 rounded-2xl p-5 md:p-6 shadow-sm transition-all duration-250 hover:-translate-y-0.5 hover:border-[color:var(--brand,#0a0a0a)] hover:shadow-[0_16px_40px_-12px_rgba(0,0,0,0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--brand,#0a0a0a)] focus-visible:ring-offset-2 motion-safe:animate-fade-up"
|
||||
style={{ animationDelay: `${160 + i * 80}ms` }}
|
||||
>
|
||||
{c.badge && (
|
||||
<span
|
||||
className="absolute top-0 right-5 -translate-y-1/2 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest"
|
||||
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||
>
|
||||
{c.badge}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-transform duration-250 group-hover:scale-105"
|
||||
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{c.icon}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<span className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||
<h2 className="text-lg font-black tracking-tight text-black leading-snug">
|
||||
{c.titulo}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 leading-relaxed">{c.descripcion}</span>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-sm font-bold mt-2 md:mt-auto md:pt-4"
|
||||
style={{ color: BRAND }}
|
||||
>
|
||||
{c.cta}
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-250 group-hover:translate-x-1"
|
||||
>
|
||||
<path
|
||||
d="M2 8h12M10 4l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="bg-white border border-gray-200 rounded-2xl p-6 md:p-8 shadow-sm motion-safe:animate-fade-up"
|
||||
style={{ animationDelay: '420ms' }}
|
||||
aria-labelledby="que-pasa-despues"
|
||||
>
|
||||
<h2
|
||||
id="que-pasa-despues"
|
||||
className="text-base md:text-lg font-black tracking-tight text-black"
|
||||
>
|
||||
Elijas lo que elijas, esto es lo que pasa después
|
||||
</h2>
|
||||
<ol className="relative mt-6 grid gap-6 md:grid-cols-3 md:gap-8">
|
||||
{/* Línea que conecta los pasos en desktop */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="hidden md:block absolute top-[15px] left-8 right-8 h-px bg-gray-200"
|
||||
/>
|
||||
{pasosDespues.map((paso, i) => (
|
||||
<li key={paso.titulo} className="relative flex items-start gap-4 md:flex-col">
|
||||
<span
|
||||
className="relative w-8 h-8 rounded-full text-[13px] font-black flex items-center justify-center shrink-0 ring-4 ring-white"
|
||||
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-black leading-snug">{paso.titulo}</h3>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">{paso.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface PricingConfig {
|
||||
factorZona: Record<string, number>; // provincia -> multiplicador
|
||||
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
||||
extras?: ExtrasFijos; // importes fijos en céntimos
|
||||
baremoMinimo?: number | null; // céntimos; trabajo mínimo rentable (informativo, no lo usan los agentes)
|
||||
}
|
||||
|
||||
export interface BudgetInputs {
|
||||
|
||||
@@ -116,6 +116,7 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
data-tour={`nav-${l.icon}`}
|
||||
className={active === l.href ? 'text-primary-700 font-semibold' : 'text-gray-500 hover:text-primary-700'}
|
||||
>
|
||||
{l.label}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { PublicGaleriaFoto } from '@/lib/funnel/public-queries';
|
||||
|
||||
type GaleriaTrabajosProps = {
|
||||
@@ -5,11 +8,37 @@ type GaleriaTrabajosProps = {
|
||||
nombreEmpresa: string;
|
||||
};
|
||||
|
||||
// Galería de trabajos del reformista en su landing pública. Solo se muestra si
|
||||
// el reformista ha subido fotos desde su panel.
|
||||
// Galería de trabajos del reformista en su landing pública. Solo se muestra si el reformista ha
|
||||
// subido fotos desde su panel. Formato apaisado y, al pulsar una foto, se amplía en un lightbox
|
||||
// con navegación entre todas las imágenes.
|
||||
export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajosProps) {
|
||||
const [idx, setIdx] = useState<number | null>(null);
|
||||
|
||||
const cerrar = useCallback(() => setIdx(null), []);
|
||||
const mover = useCallback(
|
||||
(d: number) => setIdx((cur) => (cur === null ? cur : (cur + d + fotos.length) % fotos.length)),
|
||||
[fotos.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (idx === null) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') cerrar();
|
||||
else if (e.key === 'ArrowRight') mover(1);
|
||||
else if (e.key === 'ArrowLeft') mover(-1);
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [idx, cerrar, mover]);
|
||||
|
||||
if (fotos.length === 0) return null;
|
||||
|
||||
const actual = idx !== null ? fotos[idx] : null;
|
||||
|
||||
return (
|
||||
<section id="galeria" className="bg-gray-50 section" aria-label="Galería de trabajos">
|
||||
<div className="container">
|
||||
@@ -24,24 +53,31 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
|
||||
Reformas que ya hemos hecho
|
||||
</h2>
|
||||
<p className="text-gray-500 mt-3 leading-relaxed">
|
||||
Una muestra real del trabajo de {nombreEmpresa}. Calidad de acabados, plazos cumplidos.
|
||||
Una muestra real del trabajo de {nombreEmpresa}. Toca cualquier imagen para verla en grande.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||
{fotos.map((f) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
{fotos.map((f, i) => (
|
||||
<figure
|
||||
key={f.id}
|
||||
className="group relative aspect-[4/3] overflow-hidden rounded-xl bg-gray-100 border border-gray-200"
|
||||
className="group relative aspect-[3/2] overflow-hidden rounded-xl bg-gray-100 border border-gray-200"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={f.url}
|
||||
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
className="block h-full w-full cursor-zoom-in"
|
||||
aria-label={`Ampliar ${f.titulo ?? `reforma de ${nombreEmpresa}`}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={f.url}
|
||||
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
{f.titulo && (
|
||||
<figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-white text-sm font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<figcaption className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-white text-sm font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
{f.titulo}
|
||||
</figcaption>
|
||||
)}
|
||||
@@ -49,6 +85,67 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actual && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 p-4 sm:p-8"
|
||||
onClick={cerrar}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Imagen ampliada"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cerrar}
|
||||
aria-label="Cerrar"
|
||||
className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-2xl leading-none text-white hover:bg-white/30"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{fotos.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mover(-1);
|
||||
}}
|
||||
aria-label="Anterior"
|
||||
className="absolute left-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-3xl leading-none text-white hover:bg-white/30 sm:left-6"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={actual.url}
|
||||
alt={actual.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[86vh] max-w-[94vw] w-auto rounded-lg shadow-2xl"
|
||||
/>
|
||||
|
||||
{fotos.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mover(1);
|
||||
}}
|
||||
aria-label="Siguiente"
|
||||
className="absolute right-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-3xl leading-none text-white hover:bg-white/30 sm:right-6"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
|
||||
{actual.titulo && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-5 text-center text-sm font-medium text-white/85">
|
||||
{actual.titulo}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
71
mvp/b2c/src/components/panel/PanelTour.tsx
Normal file
71
mvp/b2c/src/components/panel/PanelTour.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { driver, type DriveStep } from 'driver.js';
|
||||
import 'driver.js/dist/driver.css';
|
||||
import { tourForPath } from '@/lib/onboarding/panel-tour';
|
||||
|
||||
const SEEN_PREFIX = 'reformix_tour_v1_';
|
||||
|
||||
// Onboarding del panel con driver.js. Lanza el tour de la pestaña actual la primera vez que se
|
||||
// visita (flag por pestaña en localStorage) y deja un botón flotante para repetirlo. Los pasos
|
||||
// cuyo elemento no exista o esté oculto (p. ej. la nav de escritorio en móvil) se descartan.
|
||||
export default function PanelTour() {
|
||||
const pathname = usePathname();
|
||||
const [hayTour, setHayTour] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const tour = tourForPath(pathname);
|
||||
setHayTour(Boolean(tour));
|
||||
if (!tour) return;
|
||||
if (localStorage.getItem(SEEN_PREFIX + tour.key) === '1') return;
|
||||
|
||||
// Espera a que el contenido de la página esté montado antes de resaltar.
|
||||
const t = setTimeout(() => {
|
||||
localStorage.setItem(SEEN_PREFIX + tour.key, '1');
|
||||
lanzar(tour.steps);
|
||||
}, 700);
|
||||
return () => clearTimeout(t);
|
||||
}, [pathname]);
|
||||
|
||||
function visibles(steps: DriveStep[]): DriveStep[] {
|
||||
return steps.filter((s) => {
|
||||
const sel = s.element;
|
||||
if (!sel || typeof sel !== 'string') return true; // paso centrado (intro)
|
||||
const el = document.querySelector(sel) as HTMLElement | null;
|
||||
return !!el && el.offsetParent !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function lanzar(steps: DriveStep[]) {
|
||||
const pasos = visibles(steps);
|
||||
if (pasos.length === 0) return;
|
||||
driver({
|
||||
showProgress: true,
|
||||
overlayColor: '#0b1220',
|
||||
nextBtnText: 'Siguiente',
|
||||
prevBtnText: 'Atrás',
|
||||
doneBtnText: 'Listo',
|
||||
progressText: '{{current}} de {{total}}',
|
||||
steps: pasos,
|
||||
}).drive();
|
||||
}
|
||||
|
||||
function repetir() {
|
||||
const tour = tourForPath(pathname);
|
||||
if (tour) lanzar(tour.steps);
|
||||
}
|
||||
|
||||
if (!hayTour) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={repetir}
|
||||
className="fixed right-4 bottom-20 sm:bottom-4 z-40 inline-flex items-center gap-1.5 rounded-full bg-primary-700 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-primary-900"
|
||||
aria-label="Ver el tour de esta sección"
|
||||
>
|
||||
<span aria-hidden="true">❓</span> Tour
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
||||
factorZona: {},
|
||||
manoObra: { ...MANO_OBRA_DEFAULT },
|
||||
extras: { ...EXTRAS_DEFAULT },
|
||||
baremoMinimo: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -49,6 +50,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
||||
factorZona: row.factorZona,
|
||||
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
||||
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
|
||||
baremoMinimo: row.baremoMinimo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -396,6 +396,10 @@ export const pricingConfig = pgTable('pricing_config', {
|
||||
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
|
||||
.notNull()
|
||||
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
|
||||
// Baremo de rentabilidad (céntimos): importe mínimo que el reformista considera rentable. Solo
|
||||
// informativo en el panel (marca en otro color los leads por debajo); los agentes NO lo usan para
|
||||
// decidir nada. Null = sin baremo configurado.
|
||||
baremoMinimo: integer('baremo_minimo'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
@@ -291,13 +291,13 @@ const SEED_LEADS: SeedLead[] = [
|
||||
const STAGE_ORDER = schema.pipelineStage.enumValues;
|
||||
|
||||
async function main() {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.tenants)
|
||||
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
|
||||
.limit(1);
|
||||
// Guard de seguridad: solo sembramos si la base de datos está VACÍA (sin ningún tenant). Antes se
|
||||
// comprobaba un slug concreto ("reformas-ejemplo"); si ese tenant no estaba pero había otros
|
||||
// (p. ej. una empresa creada por el reformista), el seed los TRUNCABA en cada deploy → pérdida de
|
||||
// datos. Ahora cualquier tenant existente protege toda la DB. SEED_FORCE=1 fuerza el reseed (BORRA TODO).
|
||||
const [existing] = await db.select({ id: schema.tenants.id }).from(schema.tenants).limit(1);
|
||||
if (existing && !process.env.SEED_FORCE) {
|
||||
console.log('Ya hay datos (tenant "reformas-ejemplo"). Saltando seed. Usa SEED_FORCE=1 para forzar.');
|
||||
console.log('La base de datos ya tiene datos (existe al menos un tenant). Saltando seed para no borrar nada. Usa SEED_FORCE=1 para forzar (¡BORRA TODO!).');
|
||||
await client.end();
|
||||
return;
|
||||
}
|
||||
|
||||
41
mvp/b2c/src/lib/ai/openrouter.ts
Normal file
41
mvp/b2c/src/lib/ai/openrouter.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export function openrouterConfigurado(): boolean {
|
||||
return Boolean(env.OPENROUTER_API_KEY);
|
||||
}
|
||||
|
||||
// Llamada de chat que espera una respuesta JSON. Parseo robusto (tolera fences ```json).
|
||||
export async function chatJSON(system: string, user: string, model?: string): Promise<unknown> {
|
||||
const res = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix App',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model || env.OPENROUTER_MODEL_ANALISIS || 'anthropic/claude-haiku-4.5',
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 700,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenRouter ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const content: string = data?.choices?.[0]?.message?.content ?? '';
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
const m = content.match(/\{[\s\S]*\}/);
|
||||
if (m) return JSON.parse(m[0]);
|
||||
throw new Error('La respuesta del modelo no es JSON');
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,13 @@ const schema = z.object({
|
||||
PERFIL_WEBHOOK_URL: opcional,
|
||||
WHATSAPP_WEBHOOK_URL: opcional,
|
||||
WHATSAPP_START_WEBHOOK_URL: opcional,
|
||||
// Cross-canal: tras una llamada, pedir al lead las fotos por WhatsApp.
|
||||
WHATSAPP_FOTOS_WEBHOOK_URL: opcional,
|
||||
// Base pública de la app, para construir enlaces (ej. el enlace al formulario en el email).
|
||||
APP_URL: opcional,
|
||||
// LLM (OpenRouter) para el post-análisis de la conversación de WhatsApp.
|
||||
OPENROUTER_API_KEY: opcional,
|
||||
OPENROUTER_MODEL_ANALISIS: opcional,
|
||||
});
|
||||
|
||||
export const env = schema.parse({
|
||||
@@ -44,7 +49,10 @@ export const env = schema.parse({
|
||||
PERFIL_WEBHOOK_URL: process.env.PERFIL_WEBHOOK_URL,
|
||||
WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL,
|
||||
WHATSAPP_START_WEBHOOK_URL: process.env.WHATSAPP_START_WEBHOOK_URL,
|
||||
WHATSAPP_FOTOS_WEBHOOK_URL: process.env.WHATSAPP_FOTOS_WEBHOOK_URL,
|
||||
APP_URL: process.env.APP_URL,
|
||||
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
|
||||
OPENROUTER_MODEL_ANALISIS: process.env.OPENROUTER_MODEL_ANALISIS,
|
||||
});
|
||||
|
||||
// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede
|
||||
@@ -71,3 +79,7 @@ export function whatsappWebhookConfigurado(): boolean {
|
||||
export function whatsappStartConfigurado(): boolean {
|
||||
return Boolean(env.WHATSAPP_START_WEBHOOK_URL);
|
||||
}
|
||||
|
||||
export function whatsappFotosConfigurado(): boolean {
|
||||
return Boolean(env.WHATSAPP_FOTOS_WEBHOOK_URL);
|
||||
}
|
||||
|
||||
111
mvp/b2c/src/lib/funnel/analizar-conversacion.ts
Normal file
111
mvp/b2c/src/lib/funnel/analizar-conversacion.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads, conversacionWhatsapp, leadPipelineEventos } from '@/db/schema';
|
||||
import { chatJSON, openrouterConfigurado } from '@/lib/ai/openrouter';
|
||||
import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto';
|
||||
|
||||
export interface AnalisisResultado {
|
||||
ok: boolean;
|
||||
perfil?: Record<string, unknown>;
|
||||
turnos?: number;
|
||||
presupuesto?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type OrigenAnalisis = 'whatsapp' | 'llamada' | 'formulario';
|
||||
|
||||
const SYSTEM = `Eres un analista que extrae datos estructurados de una conversación de cualificación de
|
||||
una reforma entre una agente y un CLIENTE (puede ser un chat de WhatsApp o la transcripción de una
|
||||
llamada). Lee toda la conversación y devuelve SOLO un objeto JSON válido (sin texto alrededor, sin
|
||||
markdown) con estas claves; usa null si el dato no aparece:
|
||||
|
||||
{
|
||||
"tipoReforma": "cocina|bano|salon|comedor|integral|otro",
|
||||
"m2Suelo": número en m² (si el cliente da un rango, usa el punto medio: "menos de 10"→8,
|
||||
"entre 10 y 20"→15, "entre 20 y 40"→30, "más de 40"→50),
|
||||
"calidadGlobal": "basica|media|premium" (funcional/básico→basica, cuidado/buenos materiales→media,
|
||||
exclusivo/lujo/premium→premium; "moderno pero barato"→basica),
|
||||
"urgencia": "alta|media|baja" (cuanto antes/pronto→alta, sin prisa/explorando→baja),
|
||||
"presupuestoTarget": número en EUROS que declara el cliente (no céntimos), o null,
|
||||
"viable": booleano (false si el presupuesto declarado es claramente insuficiente para la reforma),
|
||||
"espacio": el espacio en crudo tal cual lo dijo el cliente,
|
||||
"rangoM2": el tamaño en crudo tal cual lo dijo,
|
||||
"estilo": el estilo/acabado en crudo tal cual lo dijo,
|
||||
"presupuestoDeclarado": el presupuesto en crudo tal cual lo dijo,
|
||||
"resumen": una frase con el resumen del lead
|
||||
}`;
|
||||
|
||||
// Núcleo agnóstico del canal: dada una transcripción (de WhatsApp, llamada o formulario), extrae los
|
||||
// campos clave con un LLM y los persiste en el lead. Idempotente.
|
||||
export async function analizarTranscripcion(
|
||||
leadId: string,
|
||||
transcript: string,
|
||||
origen: OrigenAnalisis,
|
||||
): Promise<AnalisisResultado> {
|
||||
if (!openrouterConfigurado()) return { ok: false, error: 'OPENROUTER_API_KEY no configurada.' };
|
||||
if (!transcript || !transcript.trim()) return { ok: false, error: 'Transcripción vacía.' };
|
||||
|
||||
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||
if (!lead) return { ok: false, error: 'Lead no encontrado.' };
|
||||
|
||||
let ex: Record<string, unknown>;
|
||||
try {
|
||||
ex = (await chatJSON(SYSTEM, transcript)) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Extracción falló: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
const enumOk = (v: unknown, allowed: string[]) =>
|
||||
typeof v === 'string' && allowed.includes(v) ? v : undefined;
|
||||
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
||||
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
const tipoReforma = enumOk(ex.tipoReforma, ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro']);
|
||||
const calidadGlobal = enumOk(ex.calidadGlobal, ['basica', 'media', 'premium']);
|
||||
const urgencia = enumOk(ex.urgencia, ['alta', 'media', 'baja']);
|
||||
if (tipoReforma) set.tipoReforma = tipoReforma;
|
||||
if (calidadGlobal) set.calidadGlobal = calidadGlobal;
|
||||
if (urgencia) set.urgencia = urgencia;
|
||||
if (typeof ex.m2Suelo === 'number' && ex.m2Suelo > 0) set.m2Suelo = ex.m2Suelo;
|
||||
if (typeof ex.presupuestoTarget === 'number' && ex.presupuestoTarget >= 0) {
|
||||
set.presupuestoTarget = Math.round(ex.presupuestoTarget * 100); // euros → céntimos
|
||||
}
|
||||
if (typeof ex.viable === 'boolean') set.viable = ex.viable;
|
||||
if (str(ex.espacio)) set.espacio = str(ex.espacio);
|
||||
if (str(ex.rangoM2)) set.rangoM2 = str(ex.rangoM2);
|
||||
if (str(ex.estilo)) set.estilo = str(ex.estilo);
|
||||
if (str(ex.presupuestoDeclarado)) set.presupuestoDeclarado = str(ex.presupuestoDeclarado);
|
||||
if (str(ex.resumen)) set.tasteText = str(ex.resumen);
|
||||
// El paso del bot solo aplica al canal conversacional de WhatsApp.
|
||||
if (origen === 'whatsapp') set.botStep = 'presupuesto';
|
||||
|
||||
await db.update(leads).set(set).where(eq(leads.id, leadId));
|
||||
await db.insert(leadPipelineEventos).values({
|
||||
leadId,
|
||||
stage: 'llamada_completada',
|
||||
metadata: { origen: `analisis_${origen}`, campos: Object.keys(set) },
|
||||
});
|
||||
|
||||
// Con los datos capturados, calcula ya el presupuesto orientativo (para que el PDF y el panel lo
|
||||
// muestren). Best-effort: si faltan tipoReforma/m2 no se calcula, pero el resto sí queda guardado.
|
||||
const presupuesto = await calcularPresupuestoLead(leadId);
|
||||
|
||||
return { ok: true, perfil: set, presupuesto: presupuesto.ok ? presupuesto.total : undefined };
|
||||
}
|
||||
|
||||
// Entrada para WhatsApp: arma la transcripción desde conversacion_whatsapp y delega en el núcleo.
|
||||
export async function analizarConversacion(leadId: string): Promise<AnalisisResultado> {
|
||||
const turnos = await db
|
||||
.select({ rol: conversacionWhatsapp.rol, mensaje: conversacionWhatsapp.mensaje })
|
||||
.from(conversacionWhatsapp)
|
||||
.where(eq(conversacionWhatsapp.leadId, leadId))
|
||||
.orderBy(asc(conversacionWhatsapp.createdAt));
|
||||
if (turnos.length === 0) return { ok: false, error: 'El lead no tiene conversación.' };
|
||||
|
||||
const transcript = turnos
|
||||
.map((t) => `${t.rol === 'user' ? 'CLIENTE' : 'LUISA'}: ${t.mensaje}`)
|
||||
.join('\n');
|
||||
|
||||
const r = await analizarTranscripcion(leadId, transcript, 'whatsapp');
|
||||
return { ...r, turnos: turnos.length };
|
||||
}
|
||||
62
mvp/b2c/src/lib/funnel/calcular-presupuesto.ts
Normal file
62
mvp/b2c/src/lib/funnel/calcular-presupuesto.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads } from '@/db/schema';
|
||||
import { getPricingConfigFor, getCatalogFor } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
import { deterministicExtractor } from '@/lib/voice/extractor';
|
||||
import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply';
|
||||
import type { RawCallData } from '@/lib/voice/preferences';
|
||||
|
||||
export interface CalculoResultado {
|
||||
ok: boolean;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Calcula el presupuesto orientativo del lead con su catálogo (mismo motor que el orquestador) y lo
|
||||
// persiste en presupuestoEstimado + desgloseSnapshot. Reutilizable desde el post-análisis (WhatsApp/
|
||||
// llamada) y desde finalizar (antes de construir el PDF). Requiere al menos tipoReforma + m2Suelo.
|
||||
export async function calcularPresupuestoLead(leadId: string): Promise<CalculoResultado> {
|
||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||
if (!lead) return { ok: false, error: 'Lead no encontrado.' };
|
||||
if (!lead.tipoReforma || !lead.m2Suelo) {
|
||||
return { ok: false, error: 'Faltan tipoReforma o m2Suelo para calcular el presupuesto.' };
|
||||
}
|
||||
|
||||
const [config, catalog] = await Promise.all([
|
||||
getPricingConfigFor(lead.tenantId),
|
||||
getCatalogFor(lead.tenantId),
|
||||
]);
|
||||
|
||||
const raw: RawCallData = {
|
||||
tipoReforma: lead.tipoReforma,
|
||||
m2Suelo: lead.m2Suelo ?? null,
|
||||
calidad: lead.calidadGlobal ?? null,
|
||||
estructural: lead.estructural,
|
||||
urgencia: lead.urgencia ?? null,
|
||||
presupuestoTarget: lead.presupuestoTarget ?? null,
|
||||
tasteText: lead.tasteText ?? '',
|
||||
};
|
||||
const prefs = deterministicExtractor.extract(raw, catalog);
|
||||
const inputs = mergeIntoBudgetInputs(prefs, {
|
||||
tipoReforma: lead.tipoReforma,
|
||||
m2Suelo: lead.m2Suelo ?? null,
|
||||
alturaTecho: lead.alturaTecho ?? null,
|
||||
provincia: lead.provincia ?? null,
|
||||
anteriorA2000: lead.anteriorA2000,
|
||||
cambioDistribucion: lead.cambioDistribucion,
|
||||
});
|
||||
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);
|
||||
|
||||
await db
|
||||
.update(leads)
|
||||
.set({
|
||||
presupuestoEstimado: result.total,
|
||||
desgloseSnapshot: { stage: 'presupuesto_generado', result },
|
||||
preferencesSnapshot: prefs,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(leads.id, leadId));
|
||||
|
||||
return { ok: true, total: result.total };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { enviarPresupuestoEmail } from '@/lib/email/mailer';
|
||||
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
|
||||
import { resolveTheme } from '@/lib/funnel/themes';
|
||||
import { normalizarTelefonoEs } from '@/lib/voice/retell';
|
||||
import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto';
|
||||
|
||||
export type ResultadoFinalizar = {
|
||||
ok: boolean;
|
||||
@@ -18,6 +19,10 @@ export type ResultadoFinalizar = {
|
||||
// flujo externo. Entrega real (la rama simulada de orchestrator.ts:Paso 7 es solo el estado
|
||||
// intermedio del funnel). Best-effort en email/WhatsApp: el lead avanza igualmente.
|
||||
export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinalizar> {
|
||||
// Asegura el presupuesto orientativo ANTES de construir el PDF (si los datos lo permiten), para
|
||||
// que el documento incluya la cifra. Best-effort: sin tipoReforma/m2 el PDF sale sin importe.
|
||||
await calcularPresupuestoLead(leadId);
|
||||
|
||||
const pdf = await construirPresupuestoPdf(leadId);
|
||||
if (!pdf) return { ok: false, emailEnviado: false, whatsappSenal: false };
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@ export async function señalarPerfilCompleto(leadId: string): Promise<boolean> {
|
||||
urgencia: lead.urgencia,
|
||||
presupuestoTarget: lead.presupuestoTarget,
|
||||
},
|
||||
// Gustos estéticos del cliente (estilo + resumen en texto libre de lo que pidió hablando con
|
||||
// Luisa / en la llamada): se mandan al generador para que el render los represente. Se omiten
|
||||
// las claves vacías (JSON.stringify descarta undefined).
|
||||
preferencias: {
|
||||
estilo: lead.estilo || undefined,
|
||||
gustos: lead.tasteText || undefined,
|
||||
},
|
||||
empresa: { tenantId: lead.tenantId, nombre: tenant.nombreEmpresa },
|
||||
zonas: Array.from(zonas, ([zona, d]) => ({
|
||||
zona,
|
||||
|
||||
147
mvp/b2c/src/lib/onboarding/panel-tour.ts
Normal file
147
mvp/b2c/src/lib/onboarding/panel-tour.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { DriveStep } from 'driver.js';
|
||||
|
||||
// Pasos del onboarding del panel, por pestaña. El copy vive también en copy/COPY-GUIDE.md
|
||||
// (sección "Onboarding del panel"). Los pasos cuyo elemento no exista o no esté visible se
|
||||
// descartan en PanelTour (degrada con naturalidad en móvil o si una sección no aparece).
|
||||
|
||||
const PASOS_PANEL: DriveStep[] = [
|
||||
{
|
||||
popover: {
|
||||
title: 'Tu panel de Reformix',
|
||||
description:
|
||||
'Te enseño en 30 segundos qué hay en cada sitio. Puedes saltártelo cuando quieras con la X.',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-leads"]',
|
||||
popover: {
|
||||
title: 'Leads',
|
||||
description:
|
||||
'Aquí caen tus clientes con su presupuesto y su render ya preparados. Es tu día a día.',
|
||||
side: 'bottom',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-precios"]',
|
||||
popover: {
|
||||
title: 'Precios y baremo',
|
||||
description:
|
||||
'Tu tabla de precios, la mano de obra y el baremo de rentabilidad. De aquí salen los presupuestos.',
|
||||
side: 'bottom',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-galeria"]',
|
||||
popover: { title: 'Galería', description: 'Tus fotos de trabajos para enseñar en la web.', side: 'bottom' },
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-opiniones"]',
|
||||
popover: {
|
||||
title: 'Opiniones',
|
||||
description: 'Reseñas de tus clientes; las apruebas tú antes de publicarlas.',
|
||||
side: 'bottom',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-empresa"]',
|
||||
popover: { title: 'Empresa', description: 'Tu marca, logo y datos de contacto.', side: 'bottom' },
|
||||
},
|
||||
{
|
||||
element: '[data-tour="leads-filtros"]',
|
||||
popover: {
|
||||
title: 'Filtra por estado',
|
||||
description: 'Nuevos, contactados, presupuestados… para centrarte en lo que toca ahora.',
|
||||
side: 'bottom',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="leads-tabla"]',
|
||||
popover: {
|
||||
title: 'Tus leads',
|
||||
description: 'Cada cliente con su estado y su presupuesto. Ábrelo para ver el detalle completo.',
|
||||
side: 'top',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const PASOS_FICHA: DriveStep[] = [
|
||||
{
|
||||
element: '[data-tour="ficha-presupuesto"]',
|
||||
popover: {
|
||||
title: 'Presupuesto estimado',
|
||||
description:
|
||||
'Lo que costaría la reforma según tu catálogo. Si no llega a tu baremo, lo verás en rojo.',
|
||||
side: 'bottom',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="ficha-estado"]',
|
||||
popover: {
|
||||
title: 'Estado del lead',
|
||||
description: 'Avanza el lead por el funnel: contactado, presupuestado, ganado…',
|
||||
side: 'bottom',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="ficha-render"]',
|
||||
popover: {
|
||||
title: 'Render de la reforma',
|
||||
description:
|
||||
'La imagen del “después” que ve tu cliente, generada a partir de su foto y sus gustos.',
|
||||
side: 'top',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="ficha-desglose"]',
|
||||
popover: {
|
||||
title: 'Presupuesto desglosado',
|
||||
description:
|
||||
'Edita las partidas, recalcula desde el catálogo y envíaselo al cliente por WhatsApp.',
|
||||
side: 'top',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const PASOS_PRECIOS: DriveStep[] = [
|
||||
{
|
||||
element: '[data-tour="precios-baremo"]',
|
||||
popover: {
|
||||
title: 'Baremo de rentabilidad',
|
||||
description:
|
||||
'El trabajo mínimo que te compensa. Solo te avisa a ti: marca en rojo los leads por debajo.',
|
||||
side: 'bottom',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="precios-config"]',
|
||||
popover: {
|
||||
title: 'Mano de obra',
|
||||
description: 'Tus precios de mano de obra por m². El motor los usa para calcular cada presupuesto.',
|
||||
side: 'top',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="precios-catalogo"]',
|
||||
popover: {
|
||||
title: 'Tu catálogo',
|
||||
description: 'Materiales y precios por calidad. Puedes importarlos en bloque por CSV.',
|
||||
side: 'top',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface PanelTour {
|
||||
key: string;
|
||||
steps: DriveStep[];
|
||||
}
|
||||
|
||||
// Devuelve el tour que corresponde a la ruta actual del panel, o null si esa ruta no tiene tour.
|
||||
export function tourForPath(pathname: string): PanelTour | null {
|
||||
if (pathname === '/panel') return { key: 'panel', steps: PASOS_PANEL };
|
||||
if (pathname === '/panel/precios') return { key: 'precios', steps: PASOS_PRECIOS };
|
||||
const m = pathname.match(/^\/panel\/([^/]+)\/?$/);
|
||||
if (m && !['precios', 'galeria', 'opiniones', 'empresa'].includes(m[1])) {
|
||||
return { key: 'ficha', steps: PASOS_FICHA };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
perfilWebhookConfigurado,
|
||||
whatsappWebhookConfigurado,
|
||||
whatsappStartConfigurado,
|
||||
whatsappFotosConfigurado,
|
||||
} from '@/lib/env';
|
||||
|
||||
// POST JSON best-effort: nunca lanza. Devuelve true solo si el destino respondió 2xx.
|
||||
@@ -55,3 +56,16 @@ export async function iniciarConversacionWhatsapp(payload: {
|
||||
if (!whatsappStartConfigurado()) return false;
|
||||
return postWebhook(env.WHATSAPP_START_WEBHOOK_URL!, payload);
|
||||
}
|
||||
|
||||
// Cross-canal: tras una llamada, que Luisa escriba al lead por WhatsApp, referencie lo hablado
|
||||
// (contexto) y le pida las fotos para completar el render + presupuesto.
|
||||
export async function pedirFotosWhatsapp(payload: {
|
||||
leadId: string;
|
||||
telefono: string;
|
||||
nombre: string;
|
||||
empresa: string;
|
||||
contexto?: string;
|
||||
}): Promise<boolean> {
|
||||
if (!whatsappFotosConfigurado()) return false;
|
||||
return postWebhook(env.WHATSAPP_FOTOS_WEBHOOK_URL!, payload);
|
||||
}
|
||||
|
||||
6
mvp/image-worker/.gitignore
vendored
Normal file
6
mvp/image-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
coverage/
|
||||
28
mvp/image-worker/dist/app.module.js
vendored
28
mvp/image-worker/dist/app.module.js
vendored
@@ -1,28 +0,0 @@
|
||||
"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
1
mvp/image-worker/dist/app.module.js.map
vendored
@@ -1 +0,0 @@
|
||||
{"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
19
mvp/image-worker/dist/main.js
vendored
@@ -1,19 +0,0 @@
|
||||
"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
1
mvp/image-worker/dist/main.js.map
vendored
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,94 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,26 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,96 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,74 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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
104
mvp/image-worker/dist/pipeline/supervisor.service.js
vendored
@@ -1,104 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,47 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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
22
mvp/image-worker/dist/webhook/webhook.module.js
vendored
@@ -1,22 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -4,10 +4,16 @@ Your task is to generate a detailed, technical prompt in English for an image-to
|
||||
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
|
||||
- A color palette and style
|
||||
- Technical rendering keywords: photorealistic, 8k, architectural visualization, professional interior photography, high detail
|
||||
|
||||
Honoring the client's wishes is the top priority. When the input provides the client's desired
|
||||
style or stated tastes (specific colors, materials, finishes or must-haves), you MUST reflect them
|
||||
faithfully in the prompt: use the exact colors and materials they asked for and the style they want,
|
||||
even if it differs from a generic "modern" look. Only when no style or tastes are given should you
|
||||
infer a tasteful design. The color palette should follow the client's stated colors when provided,
|
||||
otherwise align it with the quality level. Keep the existing layout/structure of the real photo.
|
||||
|
||||
Quality level guide:
|
||||
- basica: standard materials, functional design, clean finishes
|
||||
- media: mid-range materials, modern design, quality finishes
|
||||
|
||||
@@ -3,13 +3,17 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { WebhookModule } from './webhook/webhook.module';
|
||||
import { PipelineModule } from './pipeline/pipeline.module';
|
||||
import { ReformixModule } from './reformix/reformix.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { SandboxModule } from './sandbox/sandbox.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
SettingsModule,
|
||||
WebhookModule,
|
||||
PipelineModule,
|
||||
ReformixModule,
|
||||
SandboxModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { json, urlencoded } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -9,6 +10,10 @@ async function bootstrap() {
|
||||
logger: ['error', 'warn', 'log', 'debug'],
|
||||
});
|
||||
|
||||
// Las fotos viajan como data URI (base64) → subir el límite por defecto de Express (100kb).
|
||||
app.use(json({ limit: '30mb' }));
|
||||
app.use(urlencoded({ limit: '30mb', extended: true }));
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
|
||||
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
@@ -1,84 +1,130 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface GenerarRenderResultado {
|
||||
imagen: string | null;
|
||||
error?: string;
|
||||
debug: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImageGeneratorService {
|
||||
private readonly logger = new Logger(ImageGeneratorService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
async generarRender(prompt: string, fotoAntesDataUri: string): Promise<string> {
|
||||
// Usado por el pipeline real: devuelve la imagen o lanza.
|
||||
async generarRender(prompt: string, fotoAntesDataUri: string, opts?: { model?: string }): Promise<string> {
|
||||
const r = await this.generarConDebug(prompt, fotoAntesDataUri, opts);
|
||||
if (!r.imagen) throw new Error(r.error || 'No se pudo extraer imagen de la respuesta');
|
||||
return r.imagen;
|
||||
}
|
||||
|
||||
// Usado por el sandbox: nunca lanza, devuelve imagen|null + info de depuración (sin volcar base64).
|
||||
async generarConDebug(
|
||||
prompt: string,
|
||||
fotoAntesDataUri: string,
|
||||
opts?: { model?: string },
|
||||
): Promise<GenerarRenderResultado> {
|
||||
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 model = opts?.model?.trim() || this.settings.getModeloImagen();
|
||||
|
||||
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',
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
// Necesario para que OpenRouter devuelva imagen además de texto.
|
||||
modalities: ['image', 'text'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
|
||||
],
|
||||
},
|
||||
timeout: 60000,
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
);
|
||||
timeout: 90000,
|
||||
},
|
||||
);
|
||||
|
||||
const content = response.data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('OpenRouter no devolvio contenido');
|
||||
const message = response.data?.choices?.[0]?.message;
|
||||
const imagen = this.extraerImagen(message);
|
||||
return {
|
||||
imagen,
|
||||
error: imagen ? undefined : 'No se encontró imagen en la respuesta de OpenRouter',
|
||||
debug: this.resumenDebug(response.data, model, imagen),
|
||||
};
|
||||
} catch (err: any) {
|
||||
const status = err.response?.status;
|
||||
const msg = err.response?.data?.error?.message || err.message;
|
||||
this.logger.error(`Error generando imagen (${status ?? 'sin status'}): ${msg}`);
|
||||
return {
|
||||
imagen: null,
|
||||
error: msg,
|
||||
debug: { model, status, error: msg },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const imagen = this.extraerImagenDeRespuesta(content, response.data);
|
||||
if (!imagen) throw new Error('No se pudo extraer imagen de la respuesta');
|
||||
private extraerImagen(message: any): string | null {
|
||||
if (!message) return null;
|
||||
|
||||
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;
|
||||
// 1) Forma de OpenRouter para image-gen: message.images[].image_url.url
|
||||
if (Array.isArray(message.images)) {
|
||||
for (const img of message.images) {
|
||||
const url = img?.image_url?.url ?? img?.url ?? (typeof img === 'string' ? img : null);
|
||||
if (typeof url === 'string' && url) return url;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const content = message.content;
|
||||
if (typeof content === 'string') {
|
||||
if (content.startsWith('data:image')) return content;
|
||||
const dataUri = content.match(/data:image\/[a-zA-Z+]+;base64,[A-Za-z0-9+/=]+/);
|
||||
if (dataUri) return dataUri[0];
|
||||
const url = content.match(/https?:\/\/[^\s"'()]+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (url) return url[0];
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
const url = part?.image_url?.url;
|
||||
if (typeof url === 'string' && url) return url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resumen compacto para depurar el formato real sin meter el base64 (enorme) en la respuesta.
|
||||
private resumenDebug(data: any, model: string, imagen: string | null): Record<string, unknown> {
|
||||
const msg = data?.choices?.[0]?.message;
|
||||
return {
|
||||
model,
|
||||
modelDevuelto: data?.model,
|
||||
finishReason: data?.choices?.[0]?.finish_reason,
|
||||
messageKeys: msg ? Object.keys(msg) : [],
|
||||
imagesCount: Array.isArray(msg?.images) ? msg.images.length : 0,
|
||||
contentType: Array.isArray(msg?.content) ? 'array' : typeof msg?.content,
|
||||
contentPreview: typeof msg?.content === 'string' ? msg.content.slice(0, 200) : undefined,
|
||||
imagenEncontrada: !!imagen,
|
||||
imagenTipo: imagen ? (imagen.startsWith('data:') ? 'data-uri' : 'url') : null,
|
||||
usage: data?.usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ import { ReformixModule } from '../reformix/reformix.module';
|
||||
@Module({
|
||||
imports: [ReformixModule],
|
||||
providers: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
|
||||
exports: [PipelineService],
|
||||
exports: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
|
||||
})
|
||||
export class PipelineModule {}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class PipelineService {
|
||||
}
|
||||
|
||||
async procesarLead(dto: PerfilCompletoDto): Promise<void> {
|
||||
const { leadId, reforma, zonas } = dto;
|
||||
const { leadId, reforma, zonas, preferencias } = dto;
|
||||
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
|
||||
const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0);
|
||||
|
||||
@@ -45,7 +45,7 @@ export class PipelineService {
|
||||
|
||||
for (const zona of zonasConFotos) {
|
||||
try {
|
||||
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]);
|
||||
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0], preferencias);
|
||||
renders.push(render);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
|
||||
@@ -76,8 +76,9 @@ export class PipelineService {
|
||||
reforma: PerfilCompletoDto['reforma'],
|
||||
notas: string[],
|
||||
fotoAntes: string,
|
||||
preferencias?: PerfilCompletoDto['preferencias'],
|
||||
): Promise<ZonaRender> {
|
||||
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
|
||||
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas, preferencias);
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
|
||||
|
||||
let ultimaImagen: string | null = null;
|
||||
|
||||
@@ -1,39 +1,70 @@
|
||||
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';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface PromptBuilderOpts {
|
||||
systemPrompt?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface PreferenciasCliente {
|
||||
estilo?: string;
|
||||
gustos?: string;
|
||||
}
|
||||
|
||||
// Arma el mensaje de usuario para el LLM constructor de prompts. Función pura (sin red) para poder
|
||||
// testearla. Si el cliente expresó estilo o gustos (color/material/acabados), se incluyen como bloque
|
||||
// dedicado y se omite el "modern" por defecto, para que el render represente lo que pidió.
|
||||
export function construirUserContent(
|
||||
tipoReforma: string,
|
||||
m2Suelo: number | null,
|
||||
calidad: string,
|
||||
notas: string[],
|
||||
preferencias?: PreferenciasCliente,
|
||||
): string {
|
||||
const lineas = [
|
||||
`Generate a render prompt for a ${tipoReforma} renovation.`,
|
||||
`- Area: ${m2Suelo ?? 'unknown'} m²`,
|
||||
`- Quality level: ${calidad}`,
|
||||
`- Client notes: ${notas.join('; ') || 'none'}`,
|
||||
];
|
||||
const estilo = preferencias?.estilo?.trim();
|
||||
const gustos = preferencias?.gustos?.trim();
|
||||
if (estilo) lineas.push(`- Client's desired style: ${estilo}`);
|
||||
if (gustos) {
|
||||
lineas.push(
|
||||
`- Client's stated tastes (colors, materials, finishes, must-haves) — honor these in the render: ${gustos}`,
|
||||
);
|
||||
}
|
||||
if (!estilo && !gustos) lineas.push(`- Style: modern ${tipoReforma} renovation`);
|
||||
return lineas.join('\n');
|
||||
}
|
||||
|
||||
@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');
|
||||
}
|
||||
}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
async generarPrompt(
|
||||
tipoReforma: string,
|
||||
m2Suelo: number | null,
|
||||
calidad: string,
|
||||
notas: string[],
|
||||
preferencias?: PreferenciasCliente,
|
||||
opts?: PromptBuilderOpts,
|
||||
): 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 model = opts?.model?.trim() || this.settings.getModeloTexto();
|
||||
const systemPrompt = opts?.systemPrompt ?? this.settings.getPromptBuilder();
|
||||
|
||||
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`;
|
||||
const userContent = construirUserContent(tipoReforma, m2Suelo, calidad, notas, preferencias);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
@@ -41,7 +72,7 @@ export class PromptBuilderService {
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
max_tokens: 512,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
@@ -12,19 +11,19 @@ export interface SupervisarResultado {
|
||||
motivo: string;
|
||||
}
|
||||
|
||||
export interface SupervisorOpts {
|
||||
systemPrompt?: string;
|
||||
model?: 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');
|
||||
}
|
||||
}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
async supervisar(
|
||||
tipoReforma: string,
|
||||
@@ -33,10 +32,11 @@ export class SupervisorService {
|
||||
notas: string[],
|
||||
fotoAntes: string,
|
||||
renderDespues: string,
|
||||
opts?: SupervisorOpts,
|
||||
): 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 model = opts?.model?.trim() || this.settings.getModeloTexto();
|
||||
const systemPrompt = opts?.systemPrompt ?? this.settings.getSupervisor();
|
||||
const notasTexto = notas.join('; ') || 'sin notas';
|
||||
|
||||
try {
|
||||
@@ -45,7 +45,7 @@ export class SupervisorService {
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
@@ -53,14 +53,8 @@ export class SupervisorService {
|
||||
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 },
|
||||
},
|
||||
{ type: 'image_url', image_url: { url: fotoAntes } },
|
||||
{ type: 'image_url', image_url: { url: renderDespues } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
137
mvp/image-worker/src/sandbox/sandbox.controller.ts
Normal file
137
mvp/image-worker/src/sandbox/sandbox.controller.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Req,
|
||||
Header,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { PromptBuilderService } from '../pipeline/prompt-builder.service';
|
||||
import { ImageGeneratorService } from '../pipeline/image-generator.service';
|
||||
import { SupervisorService } from '../pipeline/supervisor.service';
|
||||
import { SANDBOX_HTML } from './sandbox.page';
|
||||
|
||||
@Controller('sandbox')
|
||||
export class SandboxController {
|
||||
private readonly logger = new Logger(SandboxController.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly promptBuilder: PromptBuilderService,
|
||||
private readonly imageGenerator: ImageGeneratorService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
) {}
|
||||
|
||||
private autorizado(req: any): boolean {
|
||||
const key = this.config.get<string>('FUNNEL_API_KEY');
|
||||
if (!key) return false;
|
||||
const auth = (req.headers?.authorization as string) || '';
|
||||
return auth === `Bearer ${key}`;
|
||||
}
|
||||
|
||||
private soloTexto(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.trim() ? v : undefined;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Header('Content-Type', 'text/html; charset=utf-8')
|
||||
page(): string {
|
||||
return SANDBOX_HTML;
|
||||
}
|
||||
|
||||
@Get('config')
|
||||
getConfig(@Req() req: any) {
|
||||
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
|
||||
return {
|
||||
...this.settings.getAll(),
|
||||
maxRetries: Number(this.config.get('MAX_RETRIES', 2)),
|
||||
minScore: Number(this.config.get('SUPERVISOR_MIN_SCORE', 70)),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('save')
|
||||
save(@Req() req: any, @Body() body: Record<string, any>) {
|
||||
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
|
||||
const config = this.settings.guardar({
|
||||
promptBuilder: body.promptBuilder,
|
||||
supervisor: body.supervisor,
|
||||
modeloTexto: body.modeloTexto,
|
||||
modeloImagen: body.modeloImagen,
|
||||
});
|
||||
this.logger.log('Config guardada desde el sandbox');
|
||||
return { ok: true, config };
|
||||
}
|
||||
|
||||
@Post('render')
|
||||
async render(@Req() req: any, @Body() body: Record<string, any>) {
|
||||
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
|
||||
const imagenAntes = body.imagenAntes;
|
||||
if (typeof imagenAntes !== 'string' || !imagenAntes) {
|
||||
throw new BadRequestException('Falta imagenAntes (data URI).');
|
||||
}
|
||||
|
||||
const tipo = this.soloTexto(body.tipo) || 'otro';
|
||||
const calidad = this.soloTexto(body.calidad) || 'media';
|
||||
const m2 = typeof body.m2 === 'number' ? body.m2 : null;
|
||||
const notas: string[] = Array.isArray(body.notas)
|
||||
? body.notas.map((n: unknown) => String(n)).filter(Boolean)
|
||||
: String(body.notas ?? '')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const modeloTexto = this.soloTexto(body.modeloTexto);
|
||||
const modeloImagen = this.soloTexto(body.modeloImagen);
|
||||
const systemPromptBuilder = this.soloTexto(body.systemPromptBuilder);
|
||||
const supervisorPrompt = this.soloTexto(body.supervisorPrompt);
|
||||
const estiloPref = this.soloTexto(body.estilo);
|
||||
const gustosPref = this.soloTexto(body.gustos);
|
||||
const supervisar = body.supervisar !== false;
|
||||
const n = Math.min(4, Math.max(1, Number(body.nVariaciones) || 1));
|
||||
const minScore = Number(this.config.get('SUPERVISOR_MIN_SCORE', 70));
|
||||
|
||||
// Prompt de imagen: directo (si lo pasan) o vía Prompt Builder.
|
||||
const promptDirecto = this.soloTexto(body.promptDirecto);
|
||||
let promptUsado: string;
|
||||
if (promptDirecto) {
|
||||
promptUsado = promptDirecto;
|
||||
} else {
|
||||
promptUsado = await this.promptBuilder.generarPrompt(
|
||||
tipo,
|
||||
m2,
|
||||
calidad,
|
||||
notas,
|
||||
{ estilo: estiloPref, gustos: gustosPref },
|
||||
{ systemPrompt: systemPromptBuilder, model: modeloTexto },
|
||||
);
|
||||
}
|
||||
|
||||
const variaciones: Array<Record<string, unknown>> = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const gen = await this.imageGenerator.generarConDebug(promptUsado, imagenAntes, { model: modeloImagen });
|
||||
const v: Record<string, unknown> = {
|
||||
imagen: gen.imagen,
|
||||
error: gen.error,
|
||||
debug: gen.debug,
|
||||
};
|
||||
if (supervisar && gen.imagen) {
|
||||
const sup = await this.supervisor.supervisar(tipo, m2, calidad, notas, imagenAntes, gen.imagen, {
|
||||
systemPrompt: supervisorPrompt,
|
||||
model: modeloTexto,
|
||||
});
|
||||
v.score = sup.score;
|
||||
v.motivo = sup.motivo;
|
||||
v.aprobado = sup.aprobado && sup.score >= minScore;
|
||||
}
|
||||
variaciones.push(v);
|
||||
}
|
||||
|
||||
return { promptUsado, variaciones };
|
||||
}
|
||||
}
|
||||
9
mvp/image-worker/src/sandbox/sandbox.module.ts
Normal file
9
mvp/image-worker/src/sandbox/sandbox.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SandboxController } from './sandbox.controller';
|
||||
import { PipelineModule } from '../pipeline/pipeline.module';
|
||||
|
||||
@Module({
|
||||
imports: [PipelineModule],
|
||||
controllers: [SandboxController],
|
||||
})
|
||||
export class SandboxModule {}
|
||||
225
mvp/image-worker/src/sandbox/sandbox.page.ts
Normal file
225
mvp/image-worker/src/sandbox/sandbox.page.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Página del sandbox (HTML autocontenido). El <script> evita backticks y la secuencia dólar+llave
|
||||
// para no colisionar con el template literal que lo envuelve.
|
||||
export const SANDBOX_HTML = `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Reformix · Sandbox de renders</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --panel:#181b22; --border:#2a2f3a; --fg:#e6e8ec; --muted:#9aa3b2; --accent:#4f8cff; --ok:#2ecc71; --bad:#ff5555; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); }
|
||||
header { padding:14px 20px; border-bottom:1px solid var(--border); display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
|
||||
header h1 { font-size:16px; margin:0; font-weight:700; }
|
||||
header .key { margin-left:auto; display:flex; gap:8px; align-items:center; }
|
||||
.layout { display:grid; grid-template-columns:380px 1fr; gap:16px; padding:16px; align-items:start; }
|
||||
@media (max-width:900px){ .layout { grid-template-columns:1fr; } }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; }
|
||||
label { display:block; font-size:12px; color:var(--muted); margin:10px 0 4px; font-weight:600; }
|
||||
input, select, textarea, button { font:inherit; color:var(--fg); background:#11141a; border:1px solid var(--border); border-radius:7px; padding:8px 10px; width:100%; }
|
||||
textarea { resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; line-height:1.4; }
|
||||
.row { display:flex; gap:8px; } .row > * { flex:1; }
|
||||
button { cursor:pointer; width:auto; }
|
||||
.btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; font-weight:700; }
|
||||
.btn-ghost { background:transparent; }
|
||||
.actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.preview { margin-top:8px; max-width:100%; border-radius:8px; border:1px solid var(--border); display:none; }
|
||||
details { margin-top:10px; } summary { cursor:pointer; color:var(--muted); font-size:12px; }
|
||||
.status { margin-top:10px; font-size:13px; color:var(--muted); min-height:18px; }
|
||||
.promptUsado { white-space:pre-wrap; background:#11141a; border:1px solid var(--border); border-radius:8px; padding:10px; font-family:ui-monospace,monospace; font-size:12px; color:#cdd3dd; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; margin-top:14px; }
|
||||
.card { background:#11141a; border:1px solid var(--border); border-radius:10px; overflow:hidden; }
|
||||
.card img { width:100%; display:block; background:#000; }
|
||||
.card .meta { padding:10px; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:700; }
|
||||
.badge.ok { background:rgba(46,204,113,.15); color:var(--ok); } .badge.bad { background:rgba(255,85,85,.15); color:var(--bad); }
|
||||
.motivo { font-size:12px; color:var(--muted); margin-top:6px; }
|
||||
pre { white-space:pre-wrap; word-break:break-word; font-size:11px; color:#9aa3b2; background:#0c0e12; padding:8px; border-radius:6px; max-height:220px; overflow:auto; }
|
||||
.hint { font-size:11px; color:var(--muted); margin-top:4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🎨 Reformix · Sandbox de renders</h1>
|
||||
<div class="key">
|
||||
<input id="apiKey" type="password" placeholder="FUNNEL_API_KEY" style="width:240px" />
|
||||
<button class="btn-ghost" onclick="guardarKey()">Recordar</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="panel">
|
||||
<label>Foto "antes"</label>
|
||||
<input id="foto" type="file" accept="image/*" onchange="cargarFoto(event)" />
|
||||
<img id="fotoPreview" class="preview" />
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Tipo</label>
|
||||
<select id="tipo">
|
||||
<option>cocina</option><option>bano</option><option>salon</option>
|
||||
<option>comedor</option><option>integral</option><option>otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Calidad</label>
|
||||
<select id="calidad"><option>basica</option><option selected>media</option><option>premium</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>m²</label><input id="m2" type="number" value="12" /></div>
|
||||
<div><label>Variaciones</label><input id="n" type="number" value="1" min="1" max="4" /></div>
|
||||
</div>
|
||||
<label>Notas del cliente (separa con ;)</label>
|
||||
<input id="notas" placeholder="encimera de cuarzo; suelo porcelánico claro" />
|
||||
|
||||
<label>Estilo del cliente (opcional)</label>
|
||||
<input id="estilo" placeholder="nórdico, industrial, clásico..." />
|
||||
<label>Gustos del cliente · color, materiales, must-haves (opcional)</label>
|
||||
<input id="gustos" placeholder="tonos azules, muebles de madera, encimera clara" />
|
||||
<div class="hint">Si los rellenas, el render los prioriza sobre un estilo genérico.</div>
|
||||
|
||||
<div class="row">
|
||||
<div><label>Modelo texto</label><input id="modeloTexto" /></div>
|
||||
<div><label>Modelo imagen</label><input id="modeloImagen" /></div>
|
||||
</div>
|
||||
<div class="hint">Imagen sugerida: google/gemini-2.5-flash-image-preview</div>
|
||||
|
||||
<label><input type="checkbox" id="supervisar" checked style="width:auto;margin-right:6px" />Pasar por el supervisor (puntúa)</label>
|
||||
|
||||
<label>Prompt de imagen directo (opcional — si lo rellenas, se salta el Prompt Builder)</label>
|
||||
<textarea id="promptDirecto" rows="3" placeholder="Photorealistic render of a modern kitchen..."></textarea>
|
||||
|
||||
<details open>
|
||||
<summary>System prompt · Prompt Builder</summary>
|
||||
<textarea id="systemPromptBuilder" rows="10"></textarea>
|
||||
</details>
|
||||
<details>
|
||||
<summary>System prompt · Supervisor (avanzado)</summary>
|
||||
<textarea id="supervisorPrompt" rows="8"></textarea>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-primary" onclick="generar()">Generar</button>
|
||||
<button class="btn-ghost" onclick="guardar()">Guardar config en el worker</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<label>Prompt usado</label>
|
||||
<div class="promptUsado" id="promptUsado">—</div>
|
||||
<div class="grid" id="resultados"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function key(){ return document.getElementById('apiKey').value.trim(); }
|
||||
function guardarKey(){ localStorage.setItem('reformix_funnel_key', key()); setStatus('Key recordada en este navegador.'); }
|
||||
function setStatus(t){ document.getElementById('status').textContent = t || ''; }
|
||||
function headers(){ return { 'Content-Type':'application/json', 'Authorization':'Bearer ' + key() }; }
|
||||
function val(id){ return document.getElementById(id).value; }
|
||||
|
||||
var fotoDataUri = '';
|
||||
function cargarFoto(e){
|
||||
var f = e.target.files[0]; if(!f) return;
|
||||
var r = new FileReader();
|
||||
r.onload = function(){ fotoDataUri = r.result; var img = document.getElementById('fotoPreview'); img.src = fotoDataUri; img.style.display='block'; };
|
||||
r.readAsDataURL(f);
|
||||
}
|
||||
|
||||
function notasArray(){ return val('notas').split(';').map(function(s){return s.trim();}).filter(Boolean); }
|
||||
|
||||
async function cargarConfig(){
|
||||
if(!key()) return;
|
||||
try {
|
||||
var res = await fetch('/sandbox/config', { headers: headers() });
|
||||
if(!res.ok) return;
|
||||
var c = await res.json();
|
||||
if(!val('systemPromptBuilder')) document.getElementById('systemPromptBuilder').value = c.promptBuilder || '';
|
||||
if(!val('supervisorPrompt')) document.getElementById('supervisorPrompt').value = c.supervisor || '';
|
||||
if(!val('modeloTexto')) document.getElementById('modeloTexto').value = c.modeloTexto || '';
|
||||
if(!val('modeloImagen')) document.getElementById('modeloImagen').value = c.modeloImagen || '';
|
||||
setStatus('Config actual cargada (maxRetries=' + c.maxRetries + ', minScore=' + c.minScore + ').');
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
async function generar(){
|
||||
if(!key()) return setStatus('Pon la FUNNEL_API_KEY arriba.');
|
||||
if(!fotoDataUri) return setStatus('Sube una foto "antes".');
|
||||
setStatus('Generando... (puede tardar bastantes segundos por variación)');
|
||||
document.getElementById('resultados').innerHTML = '';
|
||||
document.getElementById('promptUsado').textContent = '—';
|
||||
var body = {
|
||||
imagenAntes: fotoDataUri,
|
||||
tipo: val('tipo'), calidad: val('calidad'),
|
||||
m2: Number(val('m2')) || null, notas: notasArray(),
|
||||
estilo: val('estilo').trim() || undefined,
|
||||
gustos: val('gustos').trim() || undefined,
|
||||
promptDirecto: val('promptDirecto').trim() || null,
|
||||
systemPromptBuilder: val('systemPromptBuilder'),
|
||||
supervisorPrompt: val('supervisorPrompt'),
|
||||
modeloTexto: val('modeloTexto').trim() || null,
|
||||
modeloImagen: val('modeloImagen').trim() || null,
|
||||
nVariaciones: Number(val('n')) || 1,
|
||||
supervisar: document.getElementById('supervisar').checked
|
||||
};
|
||||
try {
|
||||
var res = await fetch('/sandbox/render', { method:'POST', headers: headers(), body: JSON.stringify(body) });
|
||||
var data = await res.json();
|
||||
if(!res.ok){ setStatus('Error: ' + (data.error || res.status)); return; }
|
||||
document.getElementById('promptUsado').textContent = data.promptUsado || '(prompt directo)';
|
||||
pintar(data.variaciones || []);
|
||||
setStatus('Listo: ' + (data.variaciones||[]).length + ' variación(es).');
|
||||
} catch(e){ setStatus('Fallo de red: ' + e.message); }
|
||||
}
|
||||
|
||||
// Construido con DOM seguro (sin innerHTML de contenido externo): src/textContent no inyectan.
|
||||
function el(tag, props){ var e = document.createElement(tag); if(props) Object.keys(props).forEach(function(k){ e[k] = props[k]; }); return e; }
|
||||
function pintar(vars){
|
||||
var cont = document.getElementById('resultados');
|
||||
cont.replaceChildren();
|
||||
vars.forEach(function(v, i){
|
||||
var card = el('div', { className:'card' });
|
||||
if(v.imagen){ card.appendChild(el('img', { src: v.imagen, alt: 'render ' + (i+1) })); }
|
||||
else { card.appendChild(el('div', { textContent:'Sin imagen', style:'padding:20px;color:#ff5555;font-size:13px' })); }
|
||||
var meta = el('div', { className:'meta' });
|
||||
if(typeof v.score === 'number'){
|
||||
meta.appendChild(el('span', { className:'badge ' + (v.aprobado?'ok':'bad'), textContent:'score ' + v.score + (v.aprobado?' · aprobada':' · rechazada') }));
|
||||
}
|
||||
if(v.motivo){ meta.appendChild(el('div', { className:'motivo', textContent: v.motivo })); }
|
||||
if(v.error){ var er = el('div', { className:'motivo', textContent: v.error }); er.style.color = '#ff5555'; meta.appendChild(er); }
|
||||
var det = el('details'); det.appendChild(el('summary', { textContent:'debug' }));
|
||||
det.appendChild(el('pre', { textContent: JSON.stringify(v.debug||{}, null, 2) }));
|
||||
meta.appendChild(det);
|
||||
card.appendChild(meta);
|
||||
cont.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function guardar(){
|
||||
if(!key()) return setStatus('Pon la FUNNEL_API_KEY arriba.');
|
||||
var body = {
|
||||
promptBuilder: val('systemPromptBuilder'),
|
||||
supervisor: val('supervisorPrompt'),
|
||||
modeloTexto: val('modeloTexto').trim() || undefined,
|
||||
modeloImagen: val('modeloImagen').trim() || undefined
|
||||
};
|
||||
if(!confirm('Guardar estos system prompts y modelos como la config del worker? Afecta a los renders reales de inmediato.')) return;
|
||||
setStatus('Guardando...');
|
||||
try {
|
||||
var res = await fetch('/sandbox/save', { method:'POST', headers: headers(), body: JSON.stringify(body) });
|
||||
var data = await res.json();
|
||||
if(!res.ok){ setStatus('Error guardando: ' + (data.error || res.status)); return; }
|
||||
setStatus('Guardado. El worker ya usa esta config (sin redeploy).');
|
||||
} catch(e){ setStatus('Fallo de red: ' + e.message); }
|
||||
}
|
||||
|
||||
(function init(){
|
||||
var k = localStorage.getItem('reformix_funnel_key');
|
||||
if(k){ document.getElementById('apiKey').value = k; cargarConfig(); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
9
mvp/image-worker/src/settings/settings.module.ts
Normal file
9
mvp/image-worker/src/settings/settings.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
98
mvp/image-worker/src/settings/settings.service.ts
Normal file
98
mvp/image-worker/src/settings/settings.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface SandboxConfig {
|
||||
promptBuilder: string;
|
||||
supervisor: string;
|
||||
modeloTexto: string;
|
||||
modeloImagen: string;
|
||||
}
|
||||
|
||||
const DATA_DIR = process.env.SANDBOX_DATA_DIR || path.join(process.cwd(), 'data');
|
||||
const CONFIG_FILE = path.join(DATA_DIR, 'sandbox-config.json');
|
||||
const CAMPOS = ['promptBuilder', 'supervisor', 'modeloTexto', 'modeloImagen'] as const;
|
||||
|
||||
// Config efectiva del pipeline (system prompts + modelos). Arranca de los defaults (ficheros
|
||||
// prompts/*.txt + env) y se superpone lo guardado desde el sandbox, persistido en un volumen
|
||||
// (CONFIG_FILE) para que sobreviva a redeploys. El pipeline real lee de aquí en cada llamada,
|
||||
// así que "guardar" en el sandbox aplica al worker al instante sin redeploy.
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
private readonly logger = new Logger(SettingsService.name);
|
||||
private config: SandboxConfig;
|
||||
|
||||
constructor(private readonly env: ConfigService) {
|
||||
this.config = this.cargarDefaults();
|
||||
this.overlayPersistido();
|
||||
}
|
||||
|
||||
private leerPrompt(archivo: string): string {
|
||||
try {
|
||||
const ruta = path.join(process.cwd(), 'prompts', archivo);
|
||||
if (fs.existsSync(ruta)) return fs.readFileSync(ruta, 'utf-8');
|
||||
} catch {
|
||||
/* defaults vacíos si no hay fichero */
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private cargarDefaults(): SandboxConfig {
|
||||
return {
|
||||
promptBuilder: this.leerPrompt('prompt-builder.txt'),
|
||||
supervisor: this.leerPrompt('supervisor.txt'),
|
||||
modeloTexto: this.env.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022'),
|
||||
modeloImagen: this.env.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.5-flash-image-preview'),
|
||||
};
|
||||
}
|
||||
|
||||
private overlayPersistido(): void {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const saved = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
||||
this.config = { ...this.config, ...this.limpiar(saved) };
|
||||
this.logger.log(`Config de sandbox cargada de ${CONFIG_FILE}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`No se pudo leer ${CONFIG_FILE}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private limpiar(o: any): Partial<SandboxConfig> {
|
||||
const out: Partial<SandboxConfig> = {};
|
||||
for (const k of CAMPOS) {
|
||||
if (typeof o?.[k] === 'string' && o[k].trim()) out[k] = o[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
getAll(): SandboxConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
getPromptBuilder(): string {
|
||||
return this.config.promptBuilder;
|
||||
}
|
||||
getSupervisor(): string {
|
||||
return this.config.supervisor;
|
||||
}
|
||||
getModeloTexto(): string {
|
||||
return this.config.modeloTexto;
|
||||
}
|
||||
getModeloImagen(): string {
|
||||
return this.config.modeloImagen;
|
||||
}
|
||||
|
||||
guardar(partial: Partial<SandboxConfig>): SandboxConfig {
|
||||
this.config = { ...this.config, ...this.limpiar(partial) };
|
||||
try {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2), 'utf-8');
|
||||
this.logger.log(`Config de sandbox guardada en ${CONFIG_FILE}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`No se pudo guardar la config: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
return this.getAll();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,16 @@ class ReformaDto {
|
||||
presupuestoTarget?: number;
|
||||
}
|
||||
|
||||
class PreferenciasDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
estilo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gustos?: string;
|
||||
}
|
||||
|
||||
class EmpresaDto {
|
||||
@IsUUID()
|
||||
tenantId: string;
|
||||
@@ -86,6 +96,11 @@ export class PerfilCompletoDto {
|
||||
@Type(() => ReformaDto)
|
||||
reforma: ReformaDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => PreferenciasDto)
|
||||
preferencias?: PreferenciasDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => EmpresaDto)
|
||||
empresa: EmpresaDto;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user