diff --git a/mvp/Whatsapp-bot/.env.example b/mvp/Whatsapp-bot/.env.example index bb407a8..82fb3ae 100644 --- a/mvp/Whatsapp-bot/.env.example +++ b/mvp/Whatsapp-bot/.env.example @@ -1,3 +1,7 @@ OPENROUTER_API_KEY= +MODEL_GENERADOR=anthropic/claude-sonnet-4-5 +MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 +MODEL_REGLAS=anthropic/claude-haiku-4-5 MODEL= DATABASE_URL= +ALLOWED_NUMBER= diff --git a/mvp/Whatsapp-bot/prompts/luisa_casos.md b/mvp/Whatsapp-bot/prompts/luisa_casos.md index 0db75b3..f364f9f 100644 --- a/mvp/Whatsapp-bot/prompts/luisa_casos.md +++ b/mvp/Whatsapp-bot/prompts/luisa_casos.md @@ -1,19 +1,23 @@ # Luisa — Casos edge ## Desvio del flujo + El usuario pregunta algo fuera del estado actual: "Cuando terminemos te cuento todo con detalle. Seguimos?" ## 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." ## Inactividad + - 24h sin respuesta: "Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto." - 48h sin respuesta: cerrar con estado perdido, no enviar mensaje. ## Media + **Audio:** Claude lo transcribe y trata como texto; si no entiende: "No te escuche bien, puedes repetirlo?" **Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual. @@ -25,7 +29,9 @@ Maximo 2 reintentos; al tercero: **Sticker u otro:** ignora el contenido y usa el mensaje de desvio. ## Tono defensivo o brusco + No te disculpes; no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural. ## 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?" \ No newline at end of file + +"No te preocupes; un rango aproximado esta bien, menos de 10.000, entre 10 y 30, o mas?" diff --git a/mvp/Whatsapp-bot/prompts/luisa_flujo.md b/mvp/Whatsapp-bot/prompts/luisa_flujo.md index 054911f..d04f204 100644 --- a/mvp/Whatsapp-bot/prompts/luisa_flujo.md +++ b/mvp/Whatsapp-bot/prompts/luisa_flujo.md @@ -1,32 +1,57 @@ # Luisa — Flujo y estados ## Maquina de estados + NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> FIN ## Datos a recolectar -| Estado | Campo DB | Valores validos | -|-------------|----------------------|----------------------------------------| -| ESPACIO | espacio | cocina, bano, salon, integral, otro | -| 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 | + +| Estado | Campo DB | Valores validos | +| ----------- | --------------------- | ----------------------------------- | +| ESPACIO | espacio | cocina, bano, salon, integral, otro | +| 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 | ## Mensajes por estado -**APERTURA:** "Hola [nombre], soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?" -**ESPACIO:** "Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?" +**APERTURA:** "Hola, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. -**TAMANO:** "Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?" +Tienes unos minutos ahora?" -**ESTILO:** "Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?" +**ESPACIO:** "Que espacio tienes en mente. -**URGENCIA:** "Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?" +Cocina, bano, salon, o algo mas completo?" -**PRESUPUESTO:** "Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?" +**TAMANO:** "Tienes idea del tamano aproximado. -**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto. En un momento lo recibes aqui mismo." +Menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?" -**FIN_NO_VIABLE:** "Gracias por tu tiempo [nombre]; ahora mismo no podriamos darte el resultado que mereces con ese presupuesto. Si en algun momento cambia, aqui estamos." +**ESTILO:** "Como te imaginas el resultado. -**SEGUIMIENTO FASE 3:** "Hola [nombre], te llego bien el presupuesto; quedaste con alguna duda?" \ No newline at end of file +Algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?" + +**URGENCIA:** "Y cuando tienes pensado arrancar. + +Es algo proximo o todavia estas explorando?" + +**PRESUPUESTO:** "Ultima pregunta. + +Tienes en mente un presupuesto aproximado para la reforma?" + +**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?" diff --git a/mvp/Whatsapp-bot/src/claude/claude.module.ts b/mvp/Whatsapp-bot/src/claude/claude.module.ts index 5152fb2..2c06674 100644 --- a/mvp/Whatsapp-bot/src/claude/claude.module.ts +++ b/mvp/Whatsapp-bot/src/claude/claude.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { ClaudeService } from './claude.service'; +import { LeadsModule } from '../leads/leads.module'; @Module({ + imports: [LeadsModule], providers: [ClaudeService], exports: [ClaudeService], }) diff --git a/mvp/Whatsapp-bot/src/claude/claude.service.ts b/mvp/Whatsapp-bot/src/claude/claude.service.ts index 7a0eb6b..3ae37ee 100644 --- a/mvp/Whatsapp-bot/src/claude/claude.service.ts +++ b/mvp/Whatsapp-bot/src/claude/claude.service.ts @@ -1,33 +1,80 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; import axios from 'axios'; import { Lead } from '../leads/lead.entity'; -import { Conversacion } from '../conversacion/conversacion.entity'; +import { LeadsService } from '../leads/leads.service'; + +const DEFAULT_SYSTEM_PROMPT = + 'Eres Luisa, asesora de Reformix. Cualificas leads de reforma por WhatsApp. ' + + 'Eres cercana, directa y profesional. Un mensaje por turno, maximo 2 lineas. ' + + 'Sin emojis, sin guiones largos. NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.'; + +const FRASES_IA_PROHIBIDAS = [ + /soy un (modelo|asistente)/i, + /desarrollado por openai/i, + /\bopenai\b/i, + /\bchatgpt\b/i, + /inteligencia artificial/i, + /no tengo un nombre propio/i, + /en qu[eé] puedo ayudarte/i, +]; + +export interface ClasificacionResultado { + responde_pregunta: boolean; + valor_extraido: string | null; + es_desvio: boolean; + intencion: 'respuesta' | 'desvio' | 'despedida' | 'insulto' | 'pregunta'; +} + +export interface ValidacionResultado { + valido: boolean; + valorNormalizado: string | null; + viable?: boolean; +} export interface ClaudeResponse { respuesta: string; - entidad?: Partial; // datos extraídos del turno - viable?: boolean; // flag si Claude decide el resultado final + entidad?: Partial; + viable?: boolean; + nuevoEstado?: string; } @Injectable() -export class ClaudeService { +export class ClaudeService implements OnModuleInit { private readonly logger = new Logger(ClaudeService.name); private readonly promptsDir = path.join(process.cwd(), 'prompts'); + private systemPromptCache = ''; + private reglasPromptCache = ''; + private readonly reintentosPorLead = new Map< + string, + { estado: string; count: number } + >(); - /** - * Lee y concatena los 3 archivos MD de /prompts como system prompt. - */ - private leerPromptsSistema(): string { - const archivos = ['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md']; + constructor(private readonly leadsService: LeadsService) {} + + onModuleInit() { + this.systemPromptCache = this.cargarPrompts([ + 'luisa_core.md', + 'luisa_flujo.md', + 'luisa_casos.md', + ]); + this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']); + this.logger.log( + `Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`, + ); + } + + private cargarPrompts(archivos: string[]): string { const partes: string[] = []; for (const archivo of archivos) { const rutaCompleta = path.join(this.promptsDir, archivo); try { + if (!fs.existsSync(rutaCompleta)) { + this.logger.warn(`Prompt no encontrado: ${archivo}`); + continue; + } const contenido = fs.readFileSync(rutaCompleta, 'utf-8'); if (contenido.trim()) { partes.push(`\n\n## ${archivo}\n${contenido}`); @@ -37,12 +84,34 @@ export class ClaudeService { } } - return partes.join('\n'); + const concatenado = partes.join('\n').trim(); + return concatenado || DEFAULT_SYSTEM_PROMPT; + } + + private leerPromptsSistema(): string { + return this.systemPromptCache || DEFAULT_SYSTEM_PROMPT; + } + + private leerPromptsReglas(): string { + return this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT; + } + + private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string { + const defaults = { + clasificador: 'anthropic/claude-haiku-4-5', + generador: 'anthropic/claude-sonnet-4-5', + reglas: 'anthropic/claude-haiku-4-5', + }; + + const envMap = { + clasificador: process.env.MODEL_CLASIFICADOR, + generador: process.env.MODEL_GENERADOR || process.env.MODEL, + reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR, + }; + + return envMap[clave] || defaults[clave]; } - /** - * Serializa los datos actuales del lead para el contexto de Claude. - */ private serializarLead(lead: Lead): string { return [ `- ID: ${lead.id}`, @@ -60,130 +129,528 @@ export class ClaudeService { } /** - * Llama a Claude 4.5 via OpenRouter con el contexto completo del lead. - * Devuelve la respuesta de Luisa y los datos extraídos del turno. - * - * @param lead El lead actual con sus datos en DB - * @param historial Historial de conversación [{role, content}] - * @param mensajeActual El mensaje del usuario (ya puede venir transcrito/inferido) + * OpenRouter requiere system dentro de messages[] para modelos OpenAI. + * El campo system en la raiz del payload no siempre se aplica. */ - async llamarClaude( + private async llamarOpenRouter( + model: string, + system: string, + messages: Array<{ role: string; content: string }>, + options: { temperature?: number; jsonMode?: boolean } = {}, + ): Promise { + const { temperature = 0.7, jsonMode = false } = options; + + const payload: Record = { + model, + messages: [{ role: 'system', content: system }, ...messages], + max_tokens: 1024, + temperature, + }; + + if (jsonMode) { + payload.response_format = { type: 'json_object' }; + } + + const response = await axios.post( + 'https://openrouter.ai/api/v1/chat/completions', + payload, + { + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://reformix.es', + 'X-Title': 'Reformix Luisa Bot', + }, + }, + ); + + const contenido = response.data.choices?.[0]?.message?.content || ''; + const modeloUsado = response.data.model || model; + + if (!contenido.trim()) { + this.logger.warn( + `OpenRouter devolvio contenido vacio (modelo=${modeloUsado})`, + ); + } + + return contenido; + } + + private parsearJson(texto: string): T | null { + const limpio = texto + .replace(/```json\s*/gi, '') + .replace(/```\s*/g, '') + .trim(); + + const inicio = limpio.indexOf('{'); + const fin = limpio.lastIndexOf('}'); + if (inicio === -1 || fin === -1) return null; + + try { + return JSON.parse(limpio.slice(inicio, fin + 1)) as T; + } catch { + return null; + } + } + + private normalizarClasificacion( + raw: Partial, + ): ClasificacionResultado | null { + const intenciones = [ + 'respuesta', + 'desvio', + 'despedida', + 'insulto', + 'pregunta', + ] as const; + + if (!raw || typeof raw.responde_pregunta !== 'boolean') return null; + + const intencion = intenciones.includes(raw.intencion as typeof intenciones[number]) + ? (raw.intencion as ClasificacionResultado['intencion']) + : 'respuesta'; + + return { + responde_pregunta: raw.responde_pregunta, + valor_extraido: + raw.valor_extraido === null || raw.valor_extraido === undefined + ? null + : String(raw.valor_extraido), + es_desvio: Boolean(raw.es_desvio), + intencion, + }; + } + + /** + * Capa 1 — Clasificador (Haiku): extrae intencion y valor del mensaje. + */ + private async clasificar( + mensaje: string, + estadoActual: string, + ): Promise { + const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual); + const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas. +Responde UNICAMENTE con un objeto JSON valido. Sin markdown, sin texto antes ni despues. + +Estado actual del lead: ${estadoActual} +Valores permitidos para este estado (si aplica): ${valoresPermitidos.length ? valoresPermitidos.join(', ') : 'ninguno (estado sin valor concreto)'} + +Formato exacto: +{ + "responde_pregunta": true, + "valor_extraido": null, + "es_desvio": false, + "intencion": "respuesta" +} + +Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta + +Reglas para valor_extraido: +- espacio: cocina, bano, salon, integral, otro +- tamano: menos10, 10a20, 20a40, mas40 +- estilo: funcional, cuidado, exclusivo +- urgencia: urgente, medio_plazo, frio +- presupuesto: numero o rango en euros tal como lo dijo el usuario +- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona +- Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false +- Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true`; + + const intentos = [ + { jsonMode: true, temperature: 0.1 }, + { jsonMode: true, temperature: 0 }, + ]; + + for (const opts of intentos) { + const contenido = await this.llamarOpenRouter( + this.getModelo('clasificador'), + system, + [{ role: 'user', content: mensaje }], + opts, + ); + + const parsed = this.normalizarClasificacion( + this.parsearJson>(contenido) ?? {}, + ); + + if (parsed) return parsed; + + this.logger.warn( + `Clasificador JSON invalido (intento, modelo=${this.getModelo('clasificador')}): ${contenido.slice(0, 200)}`, + ); + } + + this.logger.warn('Clasificador agotado reintentos, usando fallback conservador'); + return { + responde_pregunta: false, + valor_extraido: null, + es_desvio: true, + intencion: 'desvio', + }; + } + + /** + * Capa 2 — Validador en codigo: valida valor_extraido contra valores permitidos. + */ + private validar( + clasificacion: ClasificacionResultado, + estadoActual: string, + ): ValidacionResultado { + const estado = this.leadsService.normalizarEstadoFlujo(estadoActual); + + if ( + clasificacion.es_desvio || + clasificacion.intencion === 'desvio' || + clasificacion.intencion === 'pregunta' || + clasificacion.intencion === 'insulto' || + clasificacion.intencion === 'despedida' + ) { + return { valido: false, valorNormalizado: null }; + } + + if (estado === 'nuevo') { + return { valido: false, valorNormalizado: null }; + } + + if (estado === 'apertura') { + const valido = + clasificacion.responde_pregunta && + clasificacion.intencion === 'respuesta' && + !clasificacion.es_desvio; + return { valido, valorNormalizado: clasificacion.valor_extraido }; + } + + if (estado === 'presupuesto') { + const valor = clasificacion.valor_extraido?.trim(); + if (!valor || !this.leadsService.esPresupuestoValido(valor)) { + return { valido: false, valorNormalizado: null }; + } + const viable = this.leadsService.evaluarViabilidad(valor); + return { valido: true, valorNormalizado: valor, viable }; + } + + const valoresPermitidos = this.leadsService.getValoresPermitidos(estado); + const valor = clasificacion.valor_extraido?.trim().toLowerCase(); + + if (!valor) { + return { valido: false, valorNormalizado: null }; + } + + const coincide = valoresPermitidos.some( + (v) => v === valor || valor.includes(v), + ); + + if (!coincide) { + return { valido: false, valorNormalizado: null }; + } + + const valorNormalizado = + valoresPermitidos.find((v) => v === valor || valor.includes(v)) ?? valor; + + return { valido: true, valorNormalizado }; + } + + private claveReintento(leadId: number, estado: string): string { + return `${leadId}:${estado}`; + } + + private obtenerReintentos(leadId: number, estado: string): number { + const clave = this.claveReintento(leadId, estado); + const entry = this.reintentosPorLead.get(clave); + return entry?.estado === estado ? entry.count : 0; + } + + private incrementarReintentos(leadId: number, estado: string): number { + const clave = this.claveReintento(leadId, estado); + const actual = this.obtenerReintentos(leadId, estado); + const count = actual + 1; + this.reintentosPorLead.set(clave, { estado, count }); + return count; + } + + private resetearReintentos(leadId: number, estado: string): void { + this.reintentosPorLead.delete(this.claveReintento(leadId, estado)); + } + + /** + * Capa 3 — Generador (Sonnet): produce el borrador del mensaje de Luisa. + */ + private async generar( lead: Lead, historial: Array<{ role: string; content: string }>, mensajeActual: string, - ): Promise { + clasificacion: ClasificacionResultado, + validacion: ValidacionResultado, + reintentos: number, + avanzarEstado: boolean, + siguienteEstado: string | null, + forzarApertura = false, + ): Promise { const systemPrompt = this.leerPromptsSistema(); + const estadoFlujo = this.leadsService.normalizarEstadoFlujo( + lead.estado_actual, + ); - console.log('=== DEBUG SYSTEM PROMPT ==='); - console.log('Longitud systemPrompt:', systemPrompt.length); - console.log('Primeros 500 caracteres:', systemPrompt.substring(0, 500)); - console.log('=== FIN DEBUG ==='); - - const contextoDeLead = ` -## Contexto del lead actual - + const contextoGeneracion = ` +## Contexto del lead ${this.serializarLead(lead)} -`; - const systemFinal = `${systemPrompt} +## Estado del turno +- Estado de flujo: ${estadoFlujo} +- Clasificacion: ${JSON.stringify(clasificacion)} +- Validacion valida: ${validacion.valido} +- Valor validado: ${validacion.valorNormalizado ?? 'ninguno'} +- Reintentos en este estado: ${reintentos} +- Avanzar estado: ${avanzarEstado} +- Siguiente estado (si avanza): ${siguienteEstado ?? 'sin cambio'} +- Forzar mensaje de apertura: ${forzarApertura} -${contextoDeLead} +## Instrucciones de respuesta +Eres Luisa de Reformix. Sigue el system prompt al pie de la letra. +Devuelve SOLO el texto del mensaje a enviar por WhatsApp. +NO incluyas JSON, etiquetas XML ni bloques de datos extraidos. +NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual. +Si preguntan tu nombre, di que eres Luisa de Reformix. -## Instrucciones de extracción de datos - -Al responder, incluye al final de tu mensaje un bloque JSON con el formato exacto (sin markdown, sin comillas extras): - - -{ - "nombre": null, - "email": null, - "espacio": null, - "rango_m2": null, - "estilo": null, - "urgencia": null, - "presupuesto_declarado": null, - "viable": null -} - - -Solo rellena los campos que has capturado en este turno. Los que no hayas capturado déjalos en null. -Si puedes determinar si el lead es viable o no, pon true o false en "viable". Si no, déjalo en null.`; +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 avanzar estado es true, haz la pregunta correspondiente al siguiente estado. +Si el siguiente estado es fin_viable o fin_no_viable, usa el mensaje de cierre correspondiente.`; const messages = [ ...historial, { role: 'user', content: mensajeActual }, ]; - // Construimos el payload para enviar a OpenRouter - const payload = { - model: process.env.MODEL || 'anthropic/claude-sonnet-4-5', - messages: messages, - system: systemFinal, - max_tokens: 1024, - temperature: 0.7, + const contenido = await this.llamarOpenRouter( + this.getModelo('generador'), + `${systemPrompt}\n${contextoGeneracion}`, + messages, + { temperature: 0.7 }, + ); + + return contenido.trim(); + } + + /** + * Capa 4 — Reglas (Haiku): corrige el borrador para cumplir identidad y tono de Luisa. + */ + private async aplicarReglas( + borrador: string, + lead: Lead, + estadoFlujo: string, + clasificacion: ClasificacionResultado, + ): Promise { + const reglas = this.leerPromptsReglas(); + + const system = `${reglas} + +## Tu tarea +Recibes un borrador de respuesta para WhatsApp. Debes corregirlo para que cumpla TODAS las reglas de Luisa. +Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON. + +## Contexto +- Estado del lead: ${estadoFlujo} +- Nombre lead: ${lead.nombre || 'desconocido'} +- Intencion del usuario: ${clasificacion.intencion} + +## Reglas de correccion obligatorias +- Debe sonar como Luisa de Reformix, nunca como un asistente generico +- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos +- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales +- Si preguntan el nombre: "Soy Luisa de Reformix" +- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`; + + const contenido = await this.llamarOpenRouter( + this.getModelo('reglas'), + system, + [{ role: 'user', content: `Borrador a corregir:\n${borrador}` }], + { temperature: 0.3 }, + ); + + return contenido.trim() || borrador; + } + + private contieneFraseProhibida(texto: string): boolean { + return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto)); + } + + private mensajeFallback(estadoFlujo: string, lead: Lead): string { + const nombre = lead.nombre ? lead.nombre : ''; + const fallbacks: Record = { + nuevo: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`, + apertura: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`, + espacio: 'Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?', + tamano: 'Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?', + estilo: 'Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?', + urgencia: 'Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?', + presupuesto: 'Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?', }; - // LOG DE LO QUE SE ENVÍA (exactamente lo que ve la IA) - console.log('\n======= PETICIÓN A OPENROUTER ======='); - console.log(JSON.stringify(payload, null, 2)); - console.log('=====================================\n'); + return ( + fallbacks[estadoFlujo] ?? + 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.' + ); + } - try { - const response = await axios.post( - 'https://openrouter.ai/api/v1/chat/completions', - payload, + /** + * Orquesta las 4 capas: clasificar, validar, generar y aplicar reglas. + */ + async llamarClaude( + lead: Lead, + historial: Array<{ role: string; content: string }>, + mensajeActual: string, + ): Promise { + const esAperturaScheduler = + historial.length === 0 && mensajeActual.startsWith('APERTURA:'); + + if (esAperturaScheduler) { + const borrador = await this.generar( + lead, + historial, + mensajeActual, { - headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://reformix.es', - 'X-Title': 'Reformix Luisa Bot', - }, + responde_pregunta: true, + valor_extraido: null, + es_desvio: false, + intencion: 'respuesta', + }, + { valido: true, valorNormalizado: null }, + 0, + false, + 'apertura', + true, + ); + const respuesta = await this.aplicarReglas( + borrador, + lead, + 'apertura', + { + responde_pregunta: true, + valor_extraido: null, + es_desvio: false, + intencion: 'respuesta', }, ); + return { + respuesta: this.contieneFraseProhibida(respuesta) + ? this.mensajeFallback('apertura', lead) + : respuesta, + }; + } - // LOG DE LA RESPUESTA CRUDA (lo que devuelve OpenRouter) - console.log('\n======= RESPUESTA CRUDA DE OPENROUTER ======='); - console.log(JSON.stringify(response.data, null, 2)); - console.log('=============================================\n'); + const estadoFlujo = this.leadsService.normalizarEstadoFlujo( + lead.estado_actual, + ); - const contenidoCompleto: string = response.data.choices?.[0]?.message?.content || ''; + if (estadoFlujo === 'nuevo') { + const clasificacion: ClasificacionResultado = { + responde_pregunta: false, + valor_extraido: null, + es_desvio: false, + intencion: 'respuesta', + }; + const borrador = await this.generar( + lead, + historial, + mensajeActual, + clasificacion, + { valido: false, valorNormalizado: null }, + 0, + false, + null, + true, + ); + const respuesta = await this.aplicarReglas( + borrador, + lead, + 'nuevo', + clasificacion, + ); + return { + respuesta: this.contieneFraseProhibida(respuesta) + ? this.mensajeFallback('nuevo', lead) + : respuesta, + nuevoEstado: 'apertura', + }; + } - // También imprimimos el contenido del mensaje para ver lo que generó la IA - console.log('\n======= CONTENIDO DEL MENSAJE GENERADO ======='); - console.log(contenidoCompleto); - console.log('================================================\n'); + const clasificacion = await this.clasificar(mensajeActual, estadoFlujo); + const validacion = this.validar(clasificacion, estadoFlujo); - const regexDatos = /([\s\S]*?)<\/DATOS_EXTRAIDOS>/; - const match = contenidoCompleto.match(regexDatos); + let reintentos = this.obtenerReintentos(lead.id, estadoFlujo); + let avanzarEstado = false; + let siguienteEstado: string | null = null; + let entidad: Partial = {}; + let viable: boolean | undefined; - let respuesta = contenidoCompleto.replace(regexDatos, '').trim(); - let entidad: Partial = {}; - let viableFlag: boolean | undefined = undefined; + const puedeAvanzar = + validacion.valido && + !clasificacion.es_desvio && + clasificacion.intencion === 'respuesta'; - if (match) { - try { - const datos = JSON.parse(match[1].trim()); - Object.entries(datos).forEach(([k, v]) => { - if (v !== null && k !== 'viable') { - (entidad as Record)[k] = v; - } - }); - if (datos.viable !== null && datos.viable !== undefined) { - viableFlag = datos.viable; - } - } catch (e) { - this.logger.warn('No se pudo parsear DATOS_EXTRAIDOS: ' + e.message); + if (puedeAvanzar) { + avanzarEstado = true; + this.resetearReintentos(lead.id, estadoFlujo); + + if (validacion.valorNormalizado) { + const campo = this.leadsService.getCampoParaEstado(estadoFlujo); + if (campo) { + entidad = { [campo]: validacion.valorNormalizado }; } } - return { respuesta, entidad, viable: viableFlag }; - } catch (error) { - this.logger.error( - `Error llamando a Claude via OpenRouter: ${error.message}`, - error.response?.data, - ); - throw error; + if (estadoFlujo === 'presupuesto') { + viable = validacion.viable; + siguienteEstado = this.leadsService.getSiguienteEstado( + estadoFlujo, + viable, + ); + } else { + siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo); + } + } else if ( + !validacion.valido && + clasificacion.responde_pregunta && + !clasificacion.es_desvio + ) { + reintentos = this.incrementarReintentos(lead.id, estadoFlujo); + if (reintentos > 2) { + reintentos = 2; + } } + + const borrador = await this.generar( + lead, + historial, + mensajeActual, + clasificacion, + validacion, + reintentos, + avanzarEstado, + siguienteEstado, + ); + + let respuesta = await this.aplicarReglas( + borrador, + lead, + estadoFlujo, + clasificacion, + ); + + if (this.contieneFraseProhibida(respuesta)) { + this.logger.warn( + `Respuesta final viola reglas de identidad, usando fallback para estado=${estadoFlujo}`, + ); + respuesta = this.mensajeFallback(estadoFlujo, lead); + } + + return { + respuesta, + entidad: Object.keys(entidad).length > 0 ? entidad : undefined, + viable, + nuevoEstado: avanzarEstado ? siguienteEstado ?? undefined : undefined, + }; } } diff --git a/mvp/Whatsapp-bot/src/leads/lead.entity.ts b/mvp/Whatsapp-bot/src/leads/lead.entity.ts index d60aee5..479c58b 100644 --- a/mvp/Whatsapp-bot/src/leads/lead.entity.ts +++ b/mvp/Whatsapp-bot/src/leads/lead.entity.ts @@ -9,6 +9,14 @@ import { export type EstadoLead = | 'nuevo' | 'en_proceso' + | 'apertura' + | 'espacio' + | 'tamano' + | 'estilo' + | 'urgencia' + | 'presupuesto' + | 'fin_viable' + | 'fin_no_viable' | 'recopilando_datos' | 'completado' | 'no_viable' diff --git a/mvp/Whatsapp-bot/src/leads/leads.service.ts b/mvp/Whatsapp-bot/src/leads/leads.service.ts index c51991a..13baf9e 100644 --- a/mvp/Whatsapp-bot/src/leads/leads.service.ts +++ b/mvp/Whatsapp-bot/src/leads/leads.service.ts @@ -3,6 +3,31 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; import { Lead, EstadoLead } from './lead.entity'; +const SECUENCIA_ESTADOS = [ + 'nuevo', + 'apertura', + 'espacio', + 'tamano', + 'estilo', + 'urgencia', + 'presupuesto', +] as const; + +const VALORES_POR_ESTADO: Record = { + espacio: ['cocina', 'bano', 'salon', 'integral', 'otro'], + tamano: ['menos10', '10a20', '20a40', 'mas40'], + estilo: ['funcional', 'cuidado', 'exclusivo'], + urgencia: ['urgente', 'medio_plazo', 'frio'], +}; + +const CAMPO_POR_ESTADO: Record = { + espacio: 'espacio', + tamano: 'rango_m2', + estilo: 'estilo', + urgencia: 'urgencia', + presupuesto: 'presupuesto_declarado', +}; + @Injectable() export class LeadsService { private readonly logger = new Logger(LeadsService.name); @@ -12,6 +37,58 @@ export class LeadsService { private readonly leadRepo: Repository, ) {} + /** + * Normaliza estados legacy del scheduler/DB al flujo de cualificacion. + */ + normalizarEstadoFlujo(estado: string): string { + if (estado === 'en_proceso' || estado === 'recopilando_datos') { + return 'apertura'; + } + return estado; + } + + getSiguienteEstado(estadoActual: string, viable?: boolean): string { + const estado = this.normalizarEstadoFlujo(estadoActual); + + if (estado === 'presupuesto') { + return viable === false ? 'fin_no_viable' : 'fin_viable'; + } + + const idx = SECUENCIA_ESTADOS.indexOf( + estado as (typeof SECUENCIA_ESTADOS)[number], + ); + if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) { + return estado; + } + return SECUENCIA_ESTADOS[idx + 1]; + } + + getValoresPermitidos(estado: string): string[] { + const estadoNorm = this.normalizarEstadoFlujo(estado); + return VALORES_POR_ESTADO[estadoNorm] ?? []; + } + + getCampoParaEstado(estado: string): keyof Lead | null { + const estadoNorm = this.normalizarEstadoFlujo(estado); + return CAMPO_POR_ESTADO[estadoNorm] ?? null; + } + + esPresupuestoValido(valor: string): boolean { + const normalizado = valor.trim().toLowerCase(); + if (!normalizado) return false; + return /\d/.test(normalizado); + } + + evaluarViabilidad(presupuesto: string): boolean { + const numeros = presupuesto.match(/\d[\d.]*/g); + if (!numeros?.length) return true; + + const valor = parseInt(numeros[0].replace(/\./g, ''), 10); + if (Number.isNaN(valor)) return true; + + return valor >= 5000; + } + /** * Busca un lead por número de teléfono. * Si no existe, lo crea con estado 'nuevo'. @@ -38,8 +115,8 @@ export class LeadsService { return this.leadRepo.find({ where: { estado_actual: estado } }); } - async updateEstado(lead: Lead, estado: EstadoLead): Promise { - lead.estado_actual = estado; + async updateEstado(lead: Lead, estado: EstadoLead | string): Promise { + lead.estado_actual = estado as EstadoLead; return this.leadRepo.save(lead); } diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts index 0650e6b..9f3fa87 100644 --- a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts @@ -3,38 +3,44 @@ import { Logger, OnModuleInit, OnModuleDestroy, -} from '@nestjs/common'; +} from "@nestjs/common"; import makeWASocket, { DisconnectReason, useMultiFileAuthState, fetchLatestBaileysVersion, WASocket, downloadMediaMessage, - proto, -} from '@whiskeysockets/baileys'; -import { Boom } from '@hapi/boom'; -import * as path from 'path'; -const pino = require('pino'); -const QRCode = require('qrcode-terminal'); -import { LeadsService } from '../leads/leads.service'; -import { ConversacionService } from '../conversacion/conversacion.service'; -import { ClaudeService } from '../claude/claude.service'; -import { MediaService } from '../media/media.service'; -import { Lead } from '../leads/lead.entity'; -import { wrapSocket } from 'baileys-antiban'; +} from "@whiskeysockets/baileys"; +import { Boom } from "@hapi/boom"; +import * as path from "path"; +const pino = require("pino"); +const QRCode = require("qrcode-terminal"); +import { LeadsService } from "../leads/leads.service"; +import { ConversacionService } from "../conversacion/conversacion.service"; +import { ClaudeService } from "../claude/claude.service"; +import { MediaService } from "../media/media.service"; +import { wrapSocket } from "baileys-antiban"; + +const ESTADOS_TERMINALES = [ + "completado", + "no_viable", + "perdido", + "fin_viable", + "fin_no_viable", +]; @Injectable() export class WhatsappService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(WhatsappService.name); private sock: WASocket | null = null; - private authDir = path.join(process.cwd(), 'auth_info_baileys'); + private authDir = path.join(process.cwd(), "auth_info_baileys"); constructor( private readonly leadsService: LeadsService, private readonly conversacionService: ConversacionService, private readonly claudeService: ClaudeService, private readonly mediaService: MediaService, - ) { } + ) {} async onModuleInit() { await this.conectar(); @@ -46,6 +52,21 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { } } + private normalizarTelefono(jid: string): string { + return jid.split("@")[0].replace(/\D/g, ""); + } + + private calcularDelayEscritura(longitudTexto: number): number { + const min = 1500; + const max = 4000; + const factor = Math.min(longitudTexto / 120, 1); + return Math.round(min + (max - min) * factor); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + private async conectar() { const { state, saveCreds } = await useMultiFileAuthState(this.authDir); const { version } = await fetchLatestBaileysVersion(); @@ -54,23 +75,23 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { version, auth: state, printQRInTerminal: false, - logger: pino({ level: 'info' }) as any, + logger: pino({ level: "info" }) as any, markOnlineOnConnect: false, generateHighQualityLinkPreview: false, syncFullHistory: false, }); - this.sock.ev.on('creds.update', saveCreds); + this.sock.ev.on("creds.update", saveCreds); - this.sock.ev.on('connection.update', (update) => { + this.sock.ev.on("connection.update", (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { QRCode.generate(qr, { small: true }); - console.log('\n📲 Escanea este QR con WhatsApp\n'); + console.log("\n📲 Escanea este QR con WhatsApp\n"); } - if (connection === 'close') { + if (connection === "close") { const shouldReconnect = (lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut; @@ -83,23 +104,34 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { setTimeout(() => this.conectar(), 5000); } else { this.logger.error( - 'Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.', + "Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.", ); } - } else if (connection === 'open') { + } else if (connection === "open") { this.logger.log( - '✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.', + "✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.", ); } }); - this.sock.ev.on('messages.upsert', async ({ messages, type }) => { - if (type !== 'notify') return; + this.sock.ev.on("messages.upsert", async ({ messages, type }) => { + if (type !== "notify") return; for (const msg of messages) { if (msg.key.fromMe) continue; if (!msg.key.remoteJid) continue; - if (msg.key.remoteJid.includes('@g.us')) continue; + if (msg.key.remoteJid.includes("@g.us")) continue; + + const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid); + const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, ""); + + if (allowedNumber && telefonoNormalizado !== allowedNumber) { + this.logger.debug( + `Mensaje ignorado: ${telefonoNormalizado} no coincide con ALLOWED_NUMBER`, + ); + continue; + } + await this.procesarMensaje(msg); } }); @@ -108,46 +140,45 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { private async procesarMensaje(msg: any): Promise { const jid = msg.key.remoteJid!; - // Ignorar grupos - if (jid.includes('@g.us')) return; + if (jid.includes("@g.us")) return; - // Normalizar JID para envio (manejar formato LID de Baileys v7) - const jidNormalizado = jid.includes('@lid') - ? `${jid.split('@')[0]}@s.whatsapp.net` - : jid; - - const telefono = jidNormalizado.replace('@s.whatsapp.net', ''); + const telefono = jid.split("@")[0]; try { const lead = await this.leadsService.findOrCreate(telefono); - if (['completado', 'no_viable', 'perdido'].includes(lead.estado_actual)) { - this.logger.log(`Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`); + if (ESTADOS_TERMINALES.includes(lead.estado_actual)) { + this.logger.log( + `Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`, + ); return; } - let textoNormalizado = ''; + let textoNormalizado = ""; const msgContent = msg.message; if (!msgContent) return; if (msgContent.conversation || msgContent.extendedTextMessage) { textoNormalizado = - msgContent.conversation || - msgContent.extendedTextMessage?.text || - ''; + msgContent.conversation || msgContent.extendedTextMessage?.text || ""; } else if (msgContent.audioMessage) { - this.logger.log(`Audio recibido de lead id=${lead.id}. Transcribiendo...`); - const buffer = await downloadMediaMessage(msg as any, 'buffer', {}); - const mimeType = msgContent.audioMessage.mimetype || 'audio/ogg; codecs=opus'; + this.logger.log( + `Audio recibido de lead id=${lead.id}. Transcribiendo...`, + ); + const buffer = await downloadMediaMessage(msg as any, "buffer", {}); + const mimeType = + msgContent.audioMessage.mimetype || "audio/ogg; codecs=opus"; textoNormalizado = await this.mediaService.transcribirAudio( buffer as Buffer, mimeType, ); } else if (msgContent.imageMessage) { - this.logger.log(`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`); - const buffer = await downloadMediaMessage(msg as any, 'buffer', {}); - const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg'; + this.logger.log( + `Imagen recibida de lead id=${lead.id}. Analizando con Vision...`, + ); + const buffer = await downloadMediaMessage(msg as any, "buffer", {}); + const mimeType = msgContent.imageMessage.mimetype || "image/jpeg"; textoNormalizado = await this.mediaService.inferirImagen( buffer as Buffer, mimeType, @@ -157,7 +188,9 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`; } } else { - this.logger.log(`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`); + this.logger.log( + `Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`, + ); return; } @@ -165,34 +198,47 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`); - await this.conversacionService.guardarMensaje(lead.id, 'user', textoNormalizado); - - const historial = await this.conversacionService.obtenerHistorialComoMessages(lead.id); - - const { respuesta, entidad, viable } = await this.claudeService.llamarClaude( - lead, - historial.slice(0, -1), + await this.conversacionService.guardarMensaje( + lead.id, + "user", textoNormalizado, ); + const historial = + await this.conversacionService.obtenerHistorialComoMessages(lead.id); + + const { respuesta, entidad, viable, nuevoEstado } = + await this.claudeService.llamarClaude( + lead, + historial.slice(0, -1), + textoNormalizado, + ); + this.logger.log(`LUISA [${telefono}]: ${respuesta}`); if (entidad && Object.keys(entidad).length > 0) { await this.leadsService.updateDatos(lead.id, entidad); } - if (viable !== undefined && viable !== null) { + if (nuevoEstado === "fin_viable" || nuevoEstado === "fin_no_viable") { + await this.leadsService.marcarViable( + lead, + nuevoEstado === "fin_viable", + ); + this.logger.log(`Lead id=${lead.id} finalizado como ${nuevoEstado}`); + } else if (nuevoEstado) { + await this.leadsService.updateEstado(lead, nuevoEstado); + this.logger.log(`Lead id=${lead.id} avanzado a estado=${nuevoEstado}`); + } else if (viable !== undefined && viable !== null) { await this.leadsService.marcarViable(lead, viable); - this.logger.log(`Lead id=${lead.id} marcado como viable=${viable}`); - } else { - if (lead.estado_actual === 'nuevo') { - await this.leadsService.updateEstado(lead, 'en_proceso'); - } } - await this.conversacionService.guardarMensaje(lead.id, 'assistant', respuesta); + await this.conversacionService.guardarMensaje( + lead.id, + "assistant", + respuesta, + ); await this.enviarMensaje(jid, respuesta); - } catch (error) { this.logger.error( `Error procesando mensaje de ${telefono}: ${error.message}`, @@ -200,12 +246,19 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { ); } } + async enviarMensaje(jid: string, texto: string): Promise { - if (!this.sock) { - this.logger.error('Socket de WhatsApp no disponible'); - return; - } + if (!this.sock) return; + try { + const jidPresencia = jid.includes("@lid") + ? `${jid.split("@")[0]}@s.whatsapp.net` + : jid; + + await this.sock.sendPresenceUpdate("composing", jidPresencia); + await this.delay(this.calcularDelayEscritura(texto.length)); + await this.sock.sendPresenceUpdate("paused", jidPresencia); + const safeSock = wrapSocket(this.sock); await safeSock.sendMessage(jid, { text: texto }); this.logger.log(`Mensaje enviado a ${jid}`); @@ -214,7 +267,10 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { } } - async enviarApertura(telefono: string, mensajeApertura: string): Promise { + async enviarApertura( + telefono: string, + mensajeApertura: string, + ): Promise { const jid = `${telefono}@s.whatsapp.net`; await this.enviarMensaje(jid, mensajeApertura); } @@ -222,4 +278,4 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { isConectado(): boolean { return this.sock !== null; } -} \ No newline at end of file +}