Configurcion de personalidad
This commit is contained in:
@@ -2,6 +2,7 @@ OPENROUTER_API_KEY=
|
|||||||
MODEL_GENERADOR=anthropic/claude-sonnet-4-5
|
MODEL_GENERADOR=anthropic/claude-sonnet-4-5
|
||||||
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5
|
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5
|
||||||
MODEL_REGLAS=anthropic/claude-haiku-4-5
|
MODEL_REGLAS=anthropic/claude-haiku-4-5
|
||||||
|
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
|
||||||
MODEL=
|
MODEL=
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
ALLOWED_NUMBER=
|
ALLOWED_NUMBER=
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Maximo 2 reintentos; al tercero:
|
|||||||
|
|
||||||
## Media
|
## 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.
|
**Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Maximo 2 reintentos; al tercero:
|
|||||||
|
|
||||||
## Tono defensivo o brusco
|
## 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
|
## Usuario que no quiere dar el presupuesto
|
||||||
|
|
||||||
|
|||||||
@@ -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** uses estas palabras: *perfecto, excelente, por supuesto, encantada, claro que sí, genial*.
|
||||||
- **Nunca** hagas dos preguntas en un mismo mensaje.
|
- **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)
|
## 2. MÁQUINA DE ESTADOS (FLUJO OBLIGATORIO)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { Lead } from '../leads/lead.entity';
|
|||||||
import { LeadsService } from '../leads/leads.service';
|
import { LeadsService } from '../leads/leads.service';
|
||||||
|
|
||||||
const DEFAULT_SYSTEM_PROMPT =
|
const DEFAULT_SYSTEM_PROMPT =
|
||||||
'Eres Luisa, asesora de Reformix. Cualificas leads de reforma por WhatsApp. ' +
|
'Eres Luisa, asesora de Reformix en Madrid. Cualificas leads de reforma por WhatsApp. ' +
|
||||||
'Eres cercana, directa y profesional. Un mensaje por turno, maximo 2 lineas. ' +
|
'Hablas espanol de Espana, cercana y natural; adapta el registro al usuario (coloquial si el, formal si el). ' +
|
||||||
'Sin emojis, sin guiones largos. NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.';
|
'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 = [
|
const FRASES_IA_PROHIBIDAS = [
|
||||||
/soy un (modelo|asistente)/i,
|
/soy un (modelo|asistente)/i,
|
||||||
@@ -253,7 +254,9 @@ Reglas para valor_extraido:
|
|||||||
- presupuesto: numero o rango en euros tal como lo dijo el usuario
|
- presupuesto: numero o rango en euros tal como lo dijo el usuario
|
||||||
- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona
|
- 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
|
- 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`;
|
- 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 = [
|
const intentos = [
|
||||||
{ jsonMode: true, temperature: 0.1 },
|
{ jsonMode: true, temperature: 0.1 },
|
||||||
@@ -405,7 +408,8 @@ ${this.serializarLead(lead)}
|
|||||||
- Forzar mensaje de apertura: ${forzarApertura}
|
- Forzar mensaje de apertura: ${forzarApertura}
|
||||||
|
|
||||||
## Instrucciones de respuesta
|
## Instrucciones de respuesta
|
||||||
Eres Luisa de Reformix. Sigue el system prompt al pie de la letra.
|
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.
|
Devuelve SOLO el texto del mensaje a enviar por WhatsApp.
|
||||||
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
|
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
|
||||||
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
|
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
|
||||||
@@ -456,7 +460,8 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
|||||||
- Intencion del usuario: ${clasificacion.intencion}
|
- Intencion del usuario: ${clasificacion.intencion}
|
||||||
|
|
||||||
## Reglas de correccion obligatorias
|
## Reglas de correccion obligatorias
|
||||||
- Debe sonar como Luisa de Reformix, nunca como un asistente generico
|
- 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
|
- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos
|
||||||
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
||||||
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
||||||
|
|||||||
@@ -1,82 +1,188 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { EstadoLead } from '../leads/lead.entity';
|
import { EstadoLead } from "../leads/lead.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
private readonly logger = new Logger(MediaService.name);
|
private readonly logger = new Logger(MediaService.name);
|
||||||
|
|
||||||
private readonly OPENROUTER_URL =
|
private readonly OPENROUTER_URL =
|
||||||
'https://openrouter.ai/api/v1/chat/completions';
|
"https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
|
||||||
private get headers() {
|
private get headers() {
|
||||||
return {
|
return {
|
||||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'HTTP-Referer': 'https://reformix.es',
|
"HTTP-Referer": "https://reformix.es",
|
||||||
'X-Title': 'Reformix Luisa Bot',
|
"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.
|
* Convierte mimetype de WhatsApp al formato que espera OpenRouter input_audio.
|
||||||
* Baileys entrega el buffer del audio; lo convertimos a base64.
|
*/
|
||||||
*
|
mimeToAudioFormat(mimeType: string): string {
|
||||||
* @param audioBuffer Buffer del audio recibido por Baileys
|
const base = mimeType.toLowerCase().split(";")[0].trim();
|
||||||
* @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus)
|
const map: Record<string, string> = {
|
||||||
* @returns Texto transcrito, o el fallback si falla
|
"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(
|
async transcribirAudio(
|
||||||
audioBuffer: Buffer,
|
audioBuffer: Buffer,
|
||||||
mimeType = 'audio/ogg',
|
mimeType = "audio/ogg; codecs=opus",
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const FALLBACK =
|
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 {
|
try {
|
||||||
const base64Audio = audioBuffer.toString('base64');
|
const payload = {
|
||||||
|
model,
|
||||||
const response = await axios.post(
|
|
||||||
this.OPENROUTER_URL,
|
|
||||||
{
|
|
||||||
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
|
||||||
messages: [
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
|
{ type: "text", text: userPrompt },
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: "input_audio",
|
||||||
text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.',
|
input_audio: {
|
||||||
},
|
data: base64Audio,
|
||||||
{
|
format,
|
||||||
type: 'image_url', // OpenRouter usa image_url para base64 de audio también
|
|
||||||
image_url: {
|
|
||||||
url: `data:${mimeType};base64,${base64Audio}`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
},
|
temperature: 0,
|
||||||
{ 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 =
|
const response = await axios.post(this.OPENROUTER_URL, payload, {
|
||||||
response.data.choices?.[0]?.message?.content?.trim();
|
headers: this.headers,
|
||||||
|
});
|
||||||
|
|
||||||
if (!transcripcion) {
|
const raw: string =
|
||||||
this.logger.warn('Claude devolvió respuesta vacía para el audio');
|
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;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const transcripcion = this.limpiarTranscripcion(raw);
|
||||||
|
|
||||||
this.logger.log(
|
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;
|
return transcripcion;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error transcribiendo audio: ${error.message}`,
|
`[AUDIO 3/4] Error transcribiendo audio: ${error.message}`,
|
||||||
error.response?.data,
|
error.response?.data,
|
||||||
);
|
);
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
@@ -84,58 +190,50 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infiere información de una imagen según el estado actual del lead.
|
* Infiere informacion de una imagen segun 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
|
|
||||||
*/
|
*/
|
||||||
async inferirImagen(
|
async inferirImagen(
|
||||||
imagenBuffer: Buffer,
|
imagenBuffer: Buffer,
|
||||||
mimeType = 'image/jpeg',
|
mimeType = "image/jpeg",
|
||||||
estadoActual: EstadoLead = 'en_proceso',
|
estadoActual: EstadoLead = "en_proceso",
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const FALLBACK =
|
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> = {
|
const promptPorEstado: Record<string, string> = {
|
||||||
nuevo:
|
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:
|
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:
|
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:
|
completado:
|
||||||
'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.',
|
"Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.",
|
||||||
no_viable:
|
no_viable: "Describe brevemente que muestra esta imagen.",
|
||||||
'Describe brevemente qué muestra esta imagen.',
|
perdido: "Describe brevemente que muestra esta imagen.",
|
||||||
perdido:
|
|
||||||
'Describe brevemente qué muestra esta imagen.',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptDeVisión =
|
const promptDeVision =
|
||||||
promptPorEstado[estadoActual] ||
|
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 {
|
try {
|
||||||
const base64Imagen = imagenBuffer.toString('base64');
|
const base64Imagen = imagenBuffer.toString("base64");
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
this.OPENROUTER_URL,
|
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: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
|
{ type: "text", text: promptDeVision },
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: "image_url",
|
||||||
text: promptDeVisión,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
image_url: {
|
||||||
url: `data:${mimeType};base64,${base64Imagen}`,
|
url: `data:${mimeType};base64,${base64Imagen}`,
|
||||||
},
|
},
|
||||||
@@ -152,7 +250,7 @@ export class MediaService {
|
|||||||
response.data.choices?.[0]?.message?.content?.trim();
|
response.data.choices?.[0]?.message?.content?.trim();
|
||||||
|
|
||||||
if (!inferencia) {
|
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;
|
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 { Module } from '@nestjs/common';
|
||||||
import { WhatsappService } from './whatsapp.service';
|
import { WhatsappService } from './whatsapp.service';
|
||||||
|
import { WhatsappDebounceService } from './whatsapp-debounce.service';
|
||||||
import { LeadsModule } from '../leads/leads.module';
|
import { LeadsModule } from '../leads/leads.module';
|
||||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||||
import { ClaudeModule } from '../claude/claude.module';
|
import { ClaudeModule } from '../claude/claude.module';
|
||||||
@@ -7,7 +8,7 @@ import { MediaModule } from '../media/media.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
|
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
|
||||||
providers: [WhatsappService],
|
providers: [WhatsappService, WhatsappDebounceService],
|
||||||
exports: [WhatsappService],
|
exports: [WhatsappService],
|
||||||
})
|
})
|
||||||
export class WhatsappModule {}
|
export class WhatsappModule {}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import makeWASocket, {
|
|||||||
fetchLatestBaileysVersion,
|
fetchLatestBaileysVersion,
|
||||||
WASocket,
|
WASocket,
|
||||||
downloadMediaMessage,
|
downloadMediaMessage,
|
||||||
|
normalizeMessageContent,
|
||||||
} from "@whiskeysockets/baileys";
|
} from "@whiskeysockets/baileys";
|
||||||
import { Boom } from "@hapi/boom";
|
import { Boom } from "@hapi/boom";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
@@ -19,6 +20,7 @@ import { LeadsService } from "../leads/leads.service";
|
|||||||
import { ConversacionService } from "../conversacion/conversacion.service";
|
import { ConversacionService } from "../conversacion/conversacion.service";
|
||||||
import { ClaudeService } from "../claude/claude.service";
|
import { ClaudeService } from "../claude/claude.service";
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from "../media/media.service";
|
||||||
|
import { WhatsappDebounceService } from "./whatsapp-debounce.service";
|
||||||
import { wrapSocket } from "baileys-antiban";
|
import { wrapSocket } from "baileys-antiban";
|
||||||
|
|
||||||
const ESTADOS_TERMINALES = [
|
const ESTADOS_TERMINALES = [
|
||||||
@@ -34,12 +36,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly logger = new Logger(WhatsappService.name);
|
private readonly logger = new Logger(WhatsappService.name);
|
||||||
private sock: WASocket | null = null;
|
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(
|
constructor(
|
||||||
private readonly leadsService: LeadsService,
|
private readonly leadsService: LeadsService,
|
||||||
private readonly conversacionService: ConversacionService,
|
private readonly conversacionService: ConversacionService,
|
||||||
private readonly claudeService: ClaudeService,
|
private readonly claudeService: ClaudeService,
|
||||||
private readonly mediaService: MediaService,
|
private readonly mediaService: MediaService,
|
||||||
|
private readonly debounceService: WhatsappDebounceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@@ -71,11 +76,13 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
||||||
const { version } = await fetchLatestBaileysVersion();
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
|
|
||||||
|
this.baileysLogger = pino({ level: "info" }) as any;
|
||||||
|
|
||||||
this.sock = makeWASocket({
|
this.sock = makeWASocket({
|
||||||
version,
|
version,
|
||||||
auth: state,
|
auth: state,
|
||||||
printQRInTerminal: false,
|
printQRInTerminal: false,
|
||||||
logger: pino({ level: "info" }) as any,
|
logger: this.baileysLogger,
|
||||||
markOnlineOnConnect: false,
|
markOnlineOnConnect: false,
|
||||||
generateHighQualityLinkPreview: false,
|
generateHighQualityLinkPreview: false,
|
||||||
syncFullHistory: false,
|
syncFullHistory: false,
|
||||||
@@ -132,11 +139,49 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.procesarMensaje(msg);
|
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> {
|
private async procesarMensaje(msg: any): Promise<void> {
|
||||||
const jid = msg.key.remoteJid!;
|
const jid = msg.key.remoteJid!;
|
||||||
|
|
||||||
@@ -155,7 +200,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let textoNormalizado = "";
|
let textoNormalizado = "";
|
||||||
const msgContent = msg.message;
|
const msgContent = normalizeMessageContent(msg.message);
|
||||||
|
|
||||||
if (!msgContent) return;
|
if (!msgContent) return;
|
||||||
|
|
||||||
@@ -163,24 +208,62 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
textoNormalizado =
|
textoNormalizado =
|
||||||
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
||||||
} else if (msgContent.audioMessage) {
|
} else if (msgContent.audioMessage) {
|
||||||
|
const audioMeta = msgContent.audioMessage;
|
||||||
|
const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus";
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Audio recibido de lead id=${lead.id}. Transcribiendo...`,
|
`[AUDIO 1/4] Recibido — lead=${lead.id}, ptt=${audioMeta.ptt ?? false}, seconds=${audioMeta.seconds ?? "?"}, mimetype=${mimeType}, fileLength=${audioMeta.fileLength ?? "?"}, url=${audioMeta.url ? "si" : "no"}`,
|
||||||
);
|
);
|
||||||
const buffer = await downloadMediaMessage(msg as any, "buffer", {});
|
|
||||||
const mimeType =
|
if (!this.sock) {
|
||||||
msgContent.audioMessage.mimetype || "audio/ogg; codecs=opus";
|
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(
|
textoNormalizado = await this.mediaService.transcribirAudio(
|
||||||
buffer as Buffer,
|
audioBuffer,
|
||||||
mimeType,
|
mimeType,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[AUDIO 1/4] Transcripcion recibida en procesarMensaje — "${textoNormalizado.slice(0, 200).replace(/\n/g, "\\n")}"`,
|
||||||
|
);
|
||||||
} else if (msgContent.imageMessage) {
|
} else if (msgContent.imageMessage) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
|
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
|
||||||
);
|
);
|
||||||
const buffer = await downloadMediaMessage(msg as any, "buffer", {});
|
|
||||||
|
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";
|
const mimeType = msgContent.imageMessage.mimetype || "image/jpeg";
|
||||||
textoNormalizado = await this.mediaService.inferirImagen(
|
textoNormalizado = await this.mediaService.inferirImagen(
|
||||||
buffer as Buffer,
|
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
|
||||||
mimeType,
|
mimeType,
|
||||||
lead.estado_actual,
|
lead.estado_actual,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user