Configuracion de prompt Luisa
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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 te preocupes; un rango aproximado esta bien, menos de 10.000, entre 10 y 30, o mas?"
|
||||
|
||||
@@ -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?"
|
||||
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?"
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<Lead>; // datos extraídos del turno
|
||||
viable?: boolean; // flag si Claude decide el resultado final
|
||||
entidad?: Partial<Lead>;
|
||||
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<string> {
|
||||
const { temperature = 0.7, jsonMode = false } = options;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages: [{ role: 'system', content: system }, ...messages],
|
||||
max_tokens: 1024,
|
||||
temperature,
|
||||
};
|
||||
|
||||
if (jsonMode) {
|
||||
payload.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
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<T>(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>,
|
||||
): 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<ClasificacionResultado> {
|
||||
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
|
||||
const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas.
|
||||
Responde UNICAMENTE con un objeto JSON valido. Sin markdown, sin texto antes ni despues.
|
||||
|
||||
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<Partial<ClasificacionResultado>>(contenido) ?? {},
|
||||
);
|
||||
|
||||
if (parsed) return parsed;
|
||||
|
||||
this.logger.warn(
|
||||
`Clasificador JSON invalido (intento, modelo=${this.getModelo('clasificador')}): ${contenido.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.warn('Clasificador 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<ClaudeResponse> {
|
||||
clasificacion: ClasificacionResultado,
|
||||
validacion: ValidacionResultado,
|
||||
reintentos: number,
|
||||
avanzarEstado: boolean,
|
||||
siguienteEstado: string | null,
|
||||
forzarApertura = false,
|
||||
): Promise<string> {
|
||||
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):
|
||||
|
||||
<DATOS_EXTRAIDOS>
|
||||
{
|
||||
"nombre": null,
|
||||
"email": null,
|
||||
"espacio": null,
|
||||
"rango_m2": null,
|
||||
"estilo": null,
|
||||
"urgencia": null,
|
||||
"presupuesto_declarado": null,
|
||||
"viable": null
|
||||
}
|
||||
</DATOS_EXTRAIDOS>
|
||||
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
nuevo: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
|
||||
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<ClaudeResponse> {
|
||||
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 = /<DATOS_EXTRAIDOS>([\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<Lead> = {};
|
||||
let viable: boolean | undefined;
|
||||
|
||||
let respuesta = contenidoCompleto.replace(regexDatos, '').trim();
|
||||
let entidad: Partial<Lead> = {};
|
||||
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<string, unknown>)[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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
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<string, keyof Lead> = {
|
||||
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<Lead>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
lead.estado_actual = estado;
|
||||
async updateEstado(lead: Lead, estado: EstadoLead | string): Promise<Lead> {
|
||||
lead.estado_actual = estado as EstadoLead;
|
||||
return this.leadRepo.save(lead);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async enviarApertura(
|
||||
telefono: string,
|
||||
mensajeApertura: string,
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user