Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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 te preocupes; un rango aproximado esta bien, menos de 10.000, entre 10 y 30, o mas?"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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<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 +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<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
|
||||
- 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<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 = 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<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 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):
|
||||
|
||||
<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 (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<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 };
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +115,12 @@ export class LeadsService {
|
||||
return this.leadRepo.find({ where: { estado_actual: estado } });
|
||||
}
|
||||
|
||||
async updateEstado(lead: Lead, estado: EstadoLead): Promise<Lead> {
|
||||
lead.estado_actual = estado;
|
||||
return this.leadRepo.save(lead);
|
||||
async updateEstado(lead: Lead, estado: EstadoLead | string): Promise<Lead> {
|
||||
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<Lead>): Promise<Lead> {
|
||||
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> {
|
||||
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<Lead>,
|
||||
options?: { nuevoEstado?: string; viable?: boolean },
|
||||
): Promise<Lead> {
|
||||
const patch: Partial<Lead> = { ...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 } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<string> {
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
55
mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts
Normal file
55
mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts
Normal file
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
const pending = this.pendingMessages.get(userId);
|
||||
if (!pending) return;
|
||||
|
||||
this.pendingMessages.delete(userId);
|
||||
const combinedMessage = pending.texts.join(' ');
|
||||
await callback(combinedMessage);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, any>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<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);
|
||||
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<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 +347,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 +358,4 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
isConectado(): boolean {
|
||||
return this.sock !== null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user