import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; import axios from 'axios'; 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, calida, simpatica y siempre dispuesta a ayudar; adapta el registro al usuario. ' + 'Un mensaje por turno, breve (2-3 lineas). Puedes usar algun emoji suave de vez en cuando. ' + 'Varia como lo dices, no suenes a plantilla y no vuelvas a preguntar lo que ya te han contado. ' + 'NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.'; const FRASES_IA_PROHIBIDAS = [ /soy un (modelo|asistente)/i, /desarrollado por openai/i, /\bopenai\b/i, /\bchatgpt\b/i, /inteligencia artificial/i, /no tengo un nombre propio/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 LeadBasico { id: string; telefono: string; nombre: string; estado_actual: string; espacio: string | null; rango_m2: string | null; estilo: string | null; urgencia: string | null; presupuesto_declarado: string | null; viable: boolean | null; email: string | null; } export interface ClaudeResponse { respuesta: string; entidad?: Partial; viable?: boolean; nuevoEstado?: string; } @Injectable() 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(); 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}`); } catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); } } return partes.join('\n').trim() || DEFAULT_SYSTEM_PROMPT; } private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string { const envMap: Record = { 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] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5'); } private serializarLead(lead: LeadBasico): string { return [ `- ID: ${lead.id}`, `- Telefono: ${lead.telefono}`, `- Estado actual: ${lead.estado_actual}`, `- Nombre: ${lead.nombre || 'no capturado'}`, `- Email: ${lead.email || 'no capturado'}`, `- Espacio: ${lead.espacio || 'no capturado'}`, `- Rango m2: ${lead.rango_m2 || 'no capturado'}`, `- Estilo: ${lead.estilo || 'no capturado'}`, `- Urgencia: ${lead.urgencia || 'no capturado'}`, `- Presupuesto declarado: ${lead.presupuesto_declarado || 'no capturado'}`, `- Viable: ${lead.viable !== null && lead.viable !== undefined ? lead.viable : 'pendiente'}`, ].join('\n'); } 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', }, }); return response.data.choices?.[0]?.message?.content || ''; } 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, }; } 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, comedor, integral, otro - tamano: menos10, 10a20, 20a40, mas40 - estilo: funcional, cuidado, exclusivo - urgencia: alta, media, baja - presupuesto: numero o rango en euros tal como lo dijo el usuario - apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona - Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false - Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true - Usuarios de Madrid/Espana: interpreta coloquialismos, jerga y dialecto peninsular (vale, tio, mola, guay, etc.) como respuesta valida si aportan el dato del estado - Extrae el valor semantico aunque venga en lenguaje coloquial`; 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): ${contenido.slice(0, 200)}`); } return { responde_pregunta: false, valor_extraido: null, es_desvio: true, intencion: 'desvio' }; } 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') { return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, valorNormalizado: clasificacion.valor_extraido }; } if (estado === 'presupuesto') { const valor = clasificacion.valor_extraido?.trim(); if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null }; return { valido: true, valorNormalizado: valor, viable: this.leadsService.evaluarViabilidad(valor) }; } 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: string, estado: string): string { return `${leadId}:${estado}`; } private obtenerReintentos(leadId: string, estado: string): number { const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado)); return entry?.estado === estado ? entry.count : 0; } private incrementarReintentos(leadId: string, 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: string, estado: string): void { this.reintentosPorLead.delete(this.claveReintento(leadId, estado)); } private async generar( lead: LeadBasico, historial: Array<{ role: string; content: string }>, mensajeActual: string, clasificacion: ClasificacionResultado, validacion: ValidacionResultado, reintentos: number, avanzarEstado: boolean, siguienteEstado: string | null, forzarApertura = false, ): Promise { const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual); const contextoGeneracion = ` ## Contexto del lead ${this.serializarLead(lead)} ## 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} ## Instrucciones de respuesta Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra. Habla espanol de Espana; calida, simpatica y siempre dispuesta a ayudar, como una asesora de confianza. Varia como lo dices en cada turno (no repitas frases calcadas) y no vuelvas a preguntar un dato que el usuario ya te haya dado en este mensaje o en el historial; reconocelo con naturalidad y sigue con lo que falte. Devuelve SOLO el texto del mensaje a enviar por WhatsApp. NO incluyas JSON, etiquetas XML ni bloques de datos extraidos. NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual. Si forzar mensaje de apertura es true, envia el mensaje de APERTURA del flujo. Si validacion valida es false y reintentos < 2, ayudale con calidez dando ejemplos o referencias para que se aclare. Si validacion valida es false y reintentos >= 2, vuelve a la pregunta del estado de otra forma, sin sonar borde. Si es_desvio es true o intencion es pregunta, atiende su duda con simpatia (concede algo util) y retoma el flujo sin avanzar. Si avanzar estado es true, haz la pregunta correspondiente al siguiente estado. Si el siguiente estado es fin_viable, cierra con calidez anunciando que ya le preparas el presupuesto.`; const contenido = await this.llamarOpenRouter(this.getModelo('generador'), `${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`, [...historial, { role: 'user', content: mensajeActual }], { temperature: 0.7 }, ); return contenido.trim(); } private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise { const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT; 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): calida, simpatica y servicial, nunca un asistente generico ni un teleoperador - Espanol de Espana, natural; usa coloquialismos y conectores suaves (vale, mira, oye, genial, tranquila, perfecto) cuando encajen - Un mensaje por turno, breve (2-3 lineas como mucho); puede llevar algun emoji suave ocasional, sin abusar - Varia la redaccion; no dejes frases calcadas ni que repitan literalmente lo que ya se dijo en turnos anteriores - No reescribas para quitar la cercania: si el borrador suena frio o robotico, dale calidez en vez de recortarla - NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales - Si preguntan el nombre: "Soy Luisa de Reformix" - Quita cualquier JSON o etiqueta tecnica que se haya colado; deja solo el mensaje natural - Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`; const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system, [{ 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: LeadBasico): 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?', }; return fallbacks[estadoFlujo] ?? 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.'; } async llamarClaude( lead: LeadBasico, historial: Array<{ role: string; content: string }>, mensajeActual: string, ): Promise { const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual); if (estadoFlujo === 'nuevo') { const clasificacion: ClasificacionResultado = { responde_pregunta: false, valor_extraido: null, es_desvio: false, intencion: 'respuesta' }; const borrador = await this.generar(lead, historial, mensajeActual, clasificacion, { valido: false, valorNormalizado: null }, 0, false, null, true); const respuesta = await this.aplicarReglas(borrador, lead, 'nuevo', clasificacion); return { respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta, nuevoEstado: 'apertura', }; } const clasificacion = await this.clasificar(mensajeActual, estadoFlujo); const validacion = this.validar(clasificacion, estadoFlujo); let reintentos = this.obtenerReintentos(lead.id, estadoFlujo); let avanzarEstado = false; let siguienteEstado: string | null = null; let entidad: Partial = {}; let viable: boolean | undefined; const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta'; if (puedeAvanzar) { avanzarEstado = true; this.resetearReintentos(lead.id, estadoFlujo); if (validacion.valorNormalizado) { const campo = this.leadsService.getCampoParaEstado(estadoFlujo); if (campo) { (entidad as any)[campo] = validacion.valorNormalizado; } else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) { entidad.nombre = clasificacion.valor_extraido.trim(); } } siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo); // `viable` es solo informativo (siempre true): no cambia la ruta. Luisa nunca rechaza. if (estadoFlujo === 'presupuesto') viable = validacion.viable; } else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) { reintentos = this.incrementarReintentos(lead.id, estadoFlujo); if (reintentos > 2) reintentos = 2; } 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, 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, }; } }