diff --git a/mvp/Whatsapp-bot/.env.example b/mvp/Whatsapp-bot/.env.example index bb407a8..871ceb1 100644 --- a/mvp/Whatsapp-bot/.env.example +++ b/mvp/Whatsapp-bot/.env.example @@ -1,3 +1,8 @@ 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_TRANSCRIPCION=google/gemini-2.5-flash 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..0134071 100644 --- a/mvp/Whatsapp-bot/prompts/luisa_casos.md +++ b/mvp/Whatsapp-bot/prompts/luisa_casos.md @@ -1,20 +1,24 @@ # 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?" + +**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?" **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. + +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. ## 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_core.md b/mvp/Whatsapp-bot/prompts/luisa_core.md index d74a79a..d6d30df 100644 --- a/mvp/Whatsapp-bot/prompts/luisa_core.md +++ b/mvp/Whatsapp-bot/prompts/luisa_core.md @@ -24,6 +24,15 @@ Si el usuario te llama “Luisa”, responde afirmativamente y continúa con el - **Nunca** uses estas palabras: *perfecto, excelente, por supuesto, encantada, claro que sí, genial*. - **Nunca** hagas dos preguntas en un mismo mensaje. +### 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. + --- ## 2. MÁQUINA DE ESTADOS (FLUJO OBLIGATORIO) 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..6b03e07 100644 --- a/mvp/Whatsapp-bot/src/claude/claude.service.ts +++ b/mvp/Whatsapp-bot/src/claude/claude.service.ts @@ -1,33 +1,81 @@ -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 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. ' + + '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 +85,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 +130,547 @@ 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 +- Usuarios de Madrid/Espana: interpreta coloquialismos, jerga y dialecto peninsular (vale, tio, mola, guay, etc.) como respuesta valida si aportan el dato del estado +- Extrae el valor semantico aunque venga en lenguaje coloquial ("pa la cocina" -> espacio cocina, "unos 15 mil" -> presupuesto)`; + + const intentos = [ + { jsonMode: true, temperature: 0.1 }, + { jsonMode: true, temperature: 0 }, + ]; + + 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 = this.normalizarTexto(clasificacion.valor_extraido ?? ''); + + if (!valor) { + return { valido: false, valorNormalizado: null }; + } + + const coincide = valoresPermitidos.some( + (v) => v === valor || valor.includes(v) || v.includes(valor), + ); + + if (!coincide) { + return { valido: false, valorNormalizado: null }; + } + + const valorNormalizado = + valoresPermitidos.find( + (v) => v === valor || valor.includes(v) || v.includes(valor), + ) ?? valor; + + return { valido: true, valorNormalizado }; + } + + private normalizarTexto(valor: string): string { + return valor + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, ''); + } + + 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 en Madrid. Sigue el system prompt al pie de la letra. +Habla espanol de Espana; suena natural y cercana. Adapta el registro al usuario (coloquial si el, formal si el). +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 (Madrid), nunca como un asistente generico +- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja con el tono del usuario +- 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 }; + } else if ( + estadoFlujo === 'apertura' && + clasificacion.valor_extraido?.trim() + ) { + entidad = { nombre: clasificacion.valor_extraido.trim() }; } } - 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..66b37df 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,9 +115,12 @@ export class LeadsService { return this.leadRepo.find({ where: { estado_actual: estado } }); } - async updateEstado(lead: Lead, estado: EstadoLead): Promise { - lead.estado_actual = estado; - return this.leadRepo.save(lead); + async updateEstado(lead: Lead, estado: EstadoLead | string): Promise { + await this.leadRepo.update(lead.id, { + estado_actual: estado as EstadoLead, + }); + this.logger.log(`Lead id=${lead.id} estado_actual=${estado}`); + return this.leadRepo.findOne({ where: { id: lead.id } }); } /** @@ -48,14 +128,62 @@ export class LeadsService { * Solo actualiza los campos que se pasan en el partial. */ async updateDatos(leadId: number, datos: Partial): Promise { + const campos = Object.keys(datos).filter( + (k) => datos[k as keyof Lead] !== undefined, + ); + if (campos.length === 0) { + return this.leadRepo.findOne({ where: { id: leadId } }); + } + await this.leadRepo.update(leadId, datos); + this.logger.log( + `Lead id=${leadId} datos guardados: ${JSON.stringify(datos)}`, + ); return this.leadRepo.findOne({ where: { id: leadId } }); } async marcarViable(lead: Lead, viable: boolean): Promise { - lead.viable = viable; - lead.estado_actual = viable ? 'completado' : 'no_viable'; - return this.leadRepo.save(lead); + const estado = viable ? 'completado' : 'no_viable'; + await this.leadRepo.update(lead.id, { viable, estado_actual: estado }); + this.logger.log(`Lead id=${lead.id} viable=${viable}, estado=${estado}`); + return this.leadRepo.findOne({ where: { id: lead.id } }); + } + + /** + * Persiste datos del lead y cambio de estado en una sola operacion. + */ + async persistirTurno( + leadId: number, + datos: Partial, + options?: { nuevoEstado?: string; viable?: boolean }, + ): Promise { + const patch: Partial = { ...datos }; + + if (options?.nuevoEstado === 'fin_viable') { + patch.viable = true; + patch.estado_actual = 'completado'; + } else if (options?.nuevoEstado === 'fin_no_viable') { + patch.viable = false; + patch.estado_actual = 'no_viable'; + } else if (options?.nuevoEstado) { + patch.estado_actual = options.nuevoEstado as EstadoLead; + } else if (options?.viable !== undefined && options?.viable !== null) { + patch.viable = options.viable; + patch.estado_actual = options.viable ? 'completado' : 'no_viable'; + } + + const campos = Object.keys(patch).filter( + (k) => patch[k as keyof Lead] !== undefined, + ); + if (campos.length === 0) { + return this.leadRepo.findOne({ where: { id: leadId } }); + } + + await this.leadRepo.update(leadId, patch); + this.logger.log( + `Lead id=${leadId} persistido: ${JSON.stringify(patch)}`, + ); + return this.leadRepo.findOne({ where: { id: leadId } }); } /** diff --git a/mvp/Whatsapp-bot/src/media/media.service.ts b/mvp/Whatsapp-bot/src/media/media.service.ts index 77c4209..55a599c 100644 --- a/mvp/Whatsapp-bot/src/media/media.service.ts +++ b/mvp/Whatsapp-bot/src/media/media.service.ts @@ -1,82 +1,188 @@ -import { Injectable, Logger } from '@nestjs/common'; -import axios from 'axios'; -import { EstadoLead } from '../leads/lead.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import axios from "axios"; +import { EstadoLead } from "../leads/lead.entity"; @Injectable() export class MediaService { private readonly logger = new Logger(MediaService.name); private readonly OPENROUTER_URL = - 'https://openrouter.ai/api/v1/chat/completions'; + "https://openrouter.ai/api/v1/chat/completions"; private get headers() { return { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://reformix.es', - 'X-Title': 'Reformix Luisa Bot', + "Content-Type": "application/json", + "HTTP-Referer": "https://reformix.es", + "X-Title": "Reformix Luisa Bot", }; } + private getModeloTranscripcion(): string { + return ( + process.env.MODEL_TRANSCRIPCION || "google/gemini-2.5-flash" + ); + } + /** - * Transcribe un audio enviándolo a Claude 4.5 como base64. - * Baileys entrega el buffer del audio; lo convertimos a base64. - * - * @param audioBuffer Buffer del audio recibido por Baileys - * @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus) - * @returns Texto transcrito, o el fallback si falla + * Convierte mimetype de WhatsApp al formato que espera OpenRouter input_audio. + */ + mimeToAudioFormat(mimeType: string): string { + const base = mimeType.toLowerCase().split(";")[0].trim(); + const map: Record = { + "audio/ogg": "ogg", + "audio/opus": "ogg", + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/mp4": "m4a", + "audio/aac": "aac", + "audio/wav": "wav", + "audio/webm": "webm", + "audio/flac": "flac", + }; + return map[base] ?? "ogg"; + } + + /** + * Elimina encabezados y formato que el modelo pueda añadir a la transcripcion. + */ + limpiarTranscripcion(texto: string): string { + return texto + .replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, "") + .replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, "") + .replace(/^transcripci[oó]n\s*:?\s*\n?/gim, "") + .replace(/^```[\s\S]*?\n/g, "") + .replace(/\n```$/g, "") + .replace(/^["']|["']$/g, "") + .trim(); + } + + private detectarFormatoPorMagicBytes(buffer: Buffer): string | null { + if ( + buffer.length >= 4 && + buffer.subarray(0, 4).toString("ascii") === "OggS" + ) { + return "ogg"; + } + if ( + buffer.length >= 3 && + buffer[0] === 0xff && + (buffer[1] & 0xe0) === 0xe0 + ) { + return "mp3"; + } + if ( + buffer.length >= 12 && + buffer.subarray(0, 4).toString("ascii") === "RIFF" && + buffer.subarray(8, 12).toString("ascii") === "WAVE" + ) { + return "wav"; + } + return null; + } + + /** + * Transcribe un audio via OpenRouter input_audio (Gemini por defecto). + * Claude no soporta audio en OpenRouter; Luisa sigue usando Claude en el resto del pipeline. */ async transcribirAudio( audioBuffer: Buffer, - mimeType = 'audio/ogg', + mimeType = "audio/ogg; codecs=opus", ): Promise { const FALLBACK = - 'No pude escuchar bien el audio. ¿Puedes escribirme lo que me querías contar?'; + "No te he oido bien, me lo repites?"; + + const formatFromMime = this.mimeToAudioFormat(mimeType); + const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer); + const format = formatFromMagic ?? formatFromMime; + const base64Audio = audioBuffer.toString("base64"); + const model = this.getModeloTranscripcion(); + + this.logger.log( + `[AUDIO 2/4] MediaService.transcribirAudio — buffer=${audioBuffer.length} bytes, mime=${mimeType}, format=${format}, magic=${formatFromMagic ?? "no detectado"}, base64=${base64Audio.length} chars, modelo=${model}`, + ); + + if (audioBuffer.length < 100) { + this.logger.warn( + `[AUDIO 2/4] Buffer demasiado pequeno (${audioBuffer.length} bytes), abortando transcripcion`, + ); + return FALLBACK; + } + + const systemPrompt = + "Eres un transcriptor de voz para usuarios de Madrid y Espana. " + + "Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, " + + "muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. " + + "Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones."; + + const userPrompt = + "Transcribe exactamente lo que dice la persona en este audio. " + + "Es espanol de Espana, posiblemente con tono coloquial madrileño. " + + "Devuelve solo las palabras habladas, tal cual, nada mas."; try { - const base64Audio = audioBuffer.toString('base64'); + const payload = { + model, + messages: [ + { role: "system", content: systemPrompt }, + { + role: "user", + content: [ + { type: "text", text: userPrompt }, + { + type: "input_audio", + input_audio: { + data: base64Audio, + format, + }, + }, + ], + }, + ], + max_tokens: 512, + temperature: 0, + }; - const response = await axios.post( - this.OPENROUTER_URL, - { - model: process.env.MODEL || 'anthropic/claude-sonnet-4-5', - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.', - }, - { - type: 'image_url', // OpenRouter usa image_url para base64 de audio también - image_url: { - url: `data:${mimeType};base64,${base64Audio}`, - }, - }, - ], - }, - ], - max_tokens: 512, - }, - { headers: this.headers }, + this.logger.debug( + `[AUDIO 3/4] Enviando a OpenRouter — endpoint=${this.OPENROUTER_URL}, content_type=input_audio, format=${format}`, ); - const transcripcion: string = - response.data.choices?.[0]?.message?.content?.trim(); + const response = await axios.post(this.OPENROUTER_URL, payload, { + headers: this.headers, + }); - if (!transcripcion) { - this.logger.warn('Claude devolvió respuesta vacía para el audio'); + const raw: string = + response.data.choices?.[0]?.message?.content?.trim() ?? ""; + const modeloUsado = response.data.model ?? model; + + this.logger.log( + `[AUDIO 3/4] Respuesta OpenRouter — modelo=${modeloUsado}, raw_length=${raw.length}, raw_preview="${raw.slice(0, 120).replace(/\n/g, "\\n")}"`, + ); + + if (!raw) { + this.logger.warn( + "[AUDIO 4/4] Modelo devolvio respuesta vacia para el audio", + ); return FALLBACK; } + const transcripcion = this.limpiarTranscripcion(raw); + this.logger.log( - `Audio transcrito correctamente (${transcripcion.length} chars)`, + `[AUDIO 4/4] Transcripcion final — length=${transcripcion.length}, texto="${transcripcion.slice(0, 200).replace(/\n/g, "\\n")}"`, ); + + if (!transcripcion) { + this.logger.warn( + "[AUDIO 4/4] Transcripcion vacia tras limpieza, usando fallback", + ); + return FALLBACK; + } + return transcripcion; } catch (error) { this.logger.error( - `Error transcribiendo audio: ${error.message}`, + `[AUDIO 3/4] Error transcribiendo audio: ${error.message}`, error.response?.data, ); return FALLBACK; @@ -84,58 +190,50 @@ export class MediaService { } /** - * Infiere información de una imagen según el estado actual del lead. - * Útil para capturar espacios, materiales, estilos, etc. - * - * @param imagenBuffer Buffer de la imagen recibida por Baileys - * @param mimeType MIME type (ej: image/jpeg) - * @param estadoActual Estado del lead para adaptar el prompt de visión - * @returns Texto inferido, o el fallback si falla + * Infiere informacion de una imagen segun el estado actual del lead. */ async inferirImagen( imagenBuffer: Buffer, - mimeType = 'image/jpeg', - estadoActual: EstadoLead = 'en_proceso', + mimeType = "image/jpeg", + estadoActual: EstadoLead = "en_proceso", ): Promise { const FALLBACK = - 'Recibí tu imagen pero no pude analizarla bien. ¿Puedes describirme lo que muestra?'; + "Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?"; const promptPorEstado: Record = { nuevo: - 'Describe brevemente qué tipo de espacio se ve en esta imagen y sus características principales.', + "Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.", en_proceso: - 'Describe el espacio que aparece en la imagen: tipo de habitación, materiales, estado actual, tamaño aproximado.', + "Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.", recopilando_datos: - 'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservación.', + "Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.", completado: - 'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.', - no_viable: - 'Describe brevemente qué muestra esta imagen.', - perdido: - 'Describe brevemente qué muestra esta imagen.', + "Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.", + no_viable: "Describe brevemente que muestra esta imagen.", + perdido: "Describe brevemente que muestra esta imagen.", }; - const promptDeVisión = + const promptDeVision = promptPorEstado[estadoActual] || - 'Describe qué ves en esta imagen en el contexto de una reforma de hogar.'; + "Describe que ves en esta imagen en el contexto de una reforma de hogar."; try { - const base64Imagen = imagenBuffer.toString('base64'); + const base64Imagen = imagenBuffer.toString("base64"); const response = await axios.post( this.OPENROUTER_URL, { - model: process.env.MODEL || 'anthropic/claude-sonnet-4-5', + model: + process.env.MODEL_GENERADOR || + process.env.MODEL || + "anthropic/claude-sonnet-4-5", messages: [ { - role: 'user', + role: "user", content: [ + { type: "text", text: promptDeVision }, { - type: 'text', - text: promptDeVisión, - }, - { - type: 'image_url', + type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Imagen}`, }, @@ -152,7 +250,7 @@ export class MediaService { response.data.choices?.[0]?.message?.content?.trim(); if (!inferencia) { - this.logger.warn('Claude devolvió respuesta vacía para la imagen'); + this.logger.warn("Claude devolvio respuesta vacia para la imagen"); return FALLBACK; } diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts new file mode 100644 index 0000000..3585337 --- /dev/null +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class WhatsappDebounceService { + private pendingMessages: Map< + string, + { + timer: NodeJS.Timeout; + texts: string[]; + } + > = new Map(); + + private readonly DEBOUNCE_MS = 3000; + + /** + * Agrega un mensaje al buffer del usuario. + * @param userId Identificador único del usuario (ej. JID) + * @param messageText Texto del mensaje + * @param callback Función a ejecutar cuando se complete el debounce (recibe el texto combinado) + */ + async add( + userId: string, + messageText: string, + callback: (combinedMessage: string) => Promise, + ): Promise { + if (this.pendingMessages.has(userId)) { + const pending = this.pendingMessages.get(userId)!; + clearTimeout(pending.timer); + pending.texts.push(messageText); + + pending.timer = setTimeout(async () => { + await this.flush(userId, callback); + }, this.DEBOUNCE_MS); + } else { + this.pendingMessages.set(userId, { + timer: setTimeout(async () => { + await this.flush(userId, callback); + }, this.DEBOUNCE_MS), + texts: [messageText], + }); + } + } + + private async flush( + userId: string, + callback: (combinedMessage: string) => Promise, + ): Promise { + const pending = this.pendingMessages.get(userId); + if (!pending) return; + + this.pendingMessages.delete(userId); + const combinedMessage = pending.texts.join(' '); + await callback(combinedMessage); + } +} diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts index b083f4f..49ab747 100644 --- a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { WhatsappService } from './whatsapp.service'; +import { WhatsappDebounceService } from './whatsapp-debounce.service'; import { LeadsModule } from '../leads/leads.module'; import { ConversacionModule } from '../conversacion/conversacion.module'; import { ClaudeModule } from '../claude/claude.module'; @@ -7,7 +8,7 @@ import { MediaModule } from '../media/media.module'; @Module({ imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule], - providers: [WhatsappService], + providers: [WhatsappService, WhatsappDebounceService], exports: [WhatsappService], }) export class WhatsappModule {} diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts index 0650e6b..d833cbe 100644 --- a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts @@ -3,38 +3,49 @@ 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'; + normalizeMessageContent, +} from "@whiskeysockets/baileys"; +import { Boom } from "@hapi/boom"; +import * as path from "path"; +const pino = require("pino"); +const QRCode = require("qrcode-terminal"); +import { LeadsService } from "../leads/leads.service"; +import { ConversacionService } from "../conversacion/conversacion.service"; +import { ClaudeService } from "../claude/claude.service"; +import { MediaService } from "../media/media.service"; +import { WhatsappDebounceService } from "./whatsapp-debounce.service"; +import { wrapSocket } from "baileys-antiban"; + +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"); + private readonly ultimoMsgPorJid = new Map(); + private baileysLogger = pino({ level: "info" }); constructor( private readonly leadsService: LeadsService, private readonly conversacionService: ConversacionService, private readonly claudeService: ClaudeService, private readonly mediaService: MediaService, - ) { } + private readonly debounceService: WhatsappDebounceService, + ) {} async onModuleInit() { await this.conectar(); @@ -46,31 +57,48 @@ 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(); + this.baileysLogger = pino({ level: "info" }) as any; + this.sock = makeWASocket({ version, auth: state, printQRInTerminal: false, - logger: pino({ level: 'info' }) as any, + logger: this.baileysLogger, 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,73 +111,159 @@ 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; - await this.procesarMensaje(msg); + 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.encolarMensaje(msg); } }); } + private extraerTextoPlano(msg: any): string | null { + const msgContent = msg.message; + if (!msgContent) return null; + + if (msgContent.conversation || msgContent.extendedTextMessage) { + const texto = + msgContent.conversation || msgContent.extendedTextMessage?.text || ""; + return texto.trim() ? texto : null; + } + + return null; + } + + private crearMsgConTexto(msg: any, texto: string): any { + return { + ...msg, + message: { conversation: texto }, + }; + } + + private async encolarMensaje(msg: any): Promise { + const jid = msg.key.remoteJid!; + const textoPlano = this.extraerTextoPlano(msg); + + if (textoPlano === null) { + await this.procesarMensaje(msg); + return; + } + + this.ultimoMsgPorJid.set(jid, msg); + + await this.debounceService.add(jid, textoPlano, async (combinedMessage) => { + const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg; + this.ultimoMsgPorJid.delete(jid); + await this.procesarMensaje(this.crearMsgConTexto(baseMsg, combinedMessage)); + }); + } + 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); + let 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 = ''; - const msgContent = msg.message; + let textoNormalizado = ""; + const msgContent = normalizeMessageContent(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'; + const audioMeta = msgContent.audioMessage; + const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus"; + + this.logger.log( + `[AUDIO 1/4] Recibido — lead=${lead.id}, ptt=${audioMeta.ptt ?? false}, seconds=${audioMeta.seconds ?? "?"}, mimetype=${mimeType}, fileLength=${audioMeta.fileLength ?? "?"}, url=${audioMeta.url ? "si" : "no"}`, + ); + + if (!this.sock) { + this.logger.error("[AUDIO 1/4] Socket no disponible para descargar audio"); + return; + } + + const buffer = await downloadMediaMessage( + msg as any, + "buffer", + {}, + { + logger: this.baileysLogger, + reuploadRequest: this.sock.updateMediaMessage, + }, + ); + + const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + const magicHex = audioBuffer.subarray(0, 4).toString("hex"); + + this.logger.log( + `[AUDIO 1/4] Buffer descargado — size=${audioBuffer.length} bytes, magic_hex=${magicHex}, esperado_ogg=4f676753`, + ); + textoNormalizado = await this.mediaService.transcribirAudio( - buffer as Buffer, + audioBuffer, mimeType, ); + + this.logger.log( + `[AUDIO 1/4] Transcripcion recibida en procesarMensaje — "${textoNormalizado.slice(0, 200).replace(/\n/g, "\\n")}"`, + ); } else if (msgContent.imageMessage) { - this.logger.log(`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`); - const 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...`, + ); + + if (!this.sock) return; + + const buffer = await downloadMediaMessage( + msg as any, + "buffer", + {}, + { + logger: this.baileysLogger, + reuploadRequest: this.sock.updateMediaMessage, + }, + ); + const mimeType = msgContent.imageMessage.mimetype || "image/jpeg"; textoNormalizado = await this.mediaService.inferirImagen( - buffer as Buffer, + Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), mimeType, lead.estado_actual, ); @@ -157,7 +271,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 +281,44 @@ 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 ( + (entidad && Object.keys(entidad).length > 0) || + nuevoEstado || + (viable !== undefined && viable !== null) + ) { + lead = await this.leadsService.persistirTurno(lead.id, entidad ?? {}, { + nuevoEstado, + viable, + }); + this.logger.log( + `Lead id=${lead.id} en DB — estado=${lead.estado_actual}, espacio=${lead.espacio ?? "-"}, rango_m2=${lead.rango_m2 ?? "-"}, estilo=${lead.estilo ?? "-"}, urgencia=${lead.urgencia ?? "-"}, presupuesto=${lead.presupuesto_declarado ?? "-"}`, + ); } - if (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 +326,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 +347,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 +358,4 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { isConectado(): boolean { return this.sock !== null; } -} \ No newline at end of file +}