Configuracion de agente de whastapp paratrabajar con la estructura propuesta

This commit is contained in:
unknown
2026-06-07 17:51:53 -04:00
parent d3189d7277
commit fec365bb57
28 changed files with 5316 additions and 1748 deletions

View File

@@ -1,268 +1,99 @@
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';
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private readonly OPENROUTER_URL =
"https://openrouter.ai/api/v1/chat/completions";
private readonly OPENROUTER_URL = '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"
);
return process.env.MODEL_TRANSCRIPCION || 'google/gemini-2.5-flash';
}
/**
* 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";
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();
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";
}
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; codecs=opus",
): Promise<string> {
const FALLBACK =
"No te he oido bien, me lo repites?";
async transcribirAudio(audioBuffer: Buffer, mimeType = 'audio/ogg; codecs=opus'): Promise<string> {
const FALLBACK = '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 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) return FALLBACK;
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.";
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 payload = {
const response = await axios.post(this.OPENROUTER_URL, {
model,
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
{ type: "text", text: userPrompt },
{
type: "input_audio",
input_audio: {
data: base64Audio,
format,
},
},
],
},
{ role: 'system', content: systemPrompt },
{ role: 'user', content: [{ type: 'text', text: userPrompt }, { type: 'input_audio', input_audio: { data: base64Audio, format } }] },
],
max_tokens: 512,
temperature: 0,
};
this.logger.debug(
`[AUDIO 3/4] Enviando a OpenRouter — endpoint=${this.OPENROUTER_URL}, content_type=input_audio, format=${format}`,
);
const response = await axios.post(this.OPENROUTER_URL, payload, {
headers: this.headers,
});
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 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(
`[AUDIO 3/4] Error transcribiendo audio: ${error.message}`,
error.response?.data,
);
max_tokens: 512, temperature: 0,
}, { headers: this.headers });
const raw: string = response.data.choices?.[0]?.message?.content?.trim() ?? '';
if (!raw) return FALLBACK;
return this.limpiarTranscripcion(raw) || FALLBACK;
} catch (error: any) {
this.logger.error(`Error transcribiendo audio: ${error.message}`);
return FALLBACK;
}
}
/**
* Infiere informacion de una imagen segun el estado actual del lead.
*/
async inferirImagen(
imagenBuffer: Buffer,
mimeType = "image/jpeg",
estadoActual: EstadoLead = "en_proceso",
): Promise<string> {
const FALLBACK =
"Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?";
async inferirImagen(imagenBuffer: Buffer, mimeType = 'image/jpeg', estadoActual = 'en_proceso'): Promise<string> {
const FALLBACK = 'Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?';
const promptPorEstado: Record<string, string> = {
nuevo:
"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 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 conservacion.",
completado:
"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.",
nuevo: '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 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 conservacion.',
completado: '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 promptDeVision =
promptPorEstado[estadoActual] ||
"Describe que ves en esta imagen en el contexto de una reforma de hogar.";
const promptDeVision = promptPorEstado[estadoActual] || 'Describe que ves en esta imagen en el contexto de una reforma de hogar.';
try {
const base64Imagen = imagenBuffer.toString("base64");
const response = await axios.post(
this.OPENROUTER_URL,
{
model:
process.env.MODEL_GENERADOR ||
process.env.MODEL ||
"anthropic/claude-sonnet-4-5",
messages: [
{
role: "user",
content: [
{ type: "text", text: promptDeVision },
{
type: "image_url",
image_url: {
url: `data:${mimeType};base64,${base64Imagen}`,
},
},
],
},
],
max_tokens: 512,
},
{ headers: this.headers },
);
const inferencia: string =
response.data.choices?.[0]?.message?.content?.trim();
if (!inferencia) {
this.logger.warn("Claude devolvio respuesta vacia para la imagen");
return FALLBACK;
}
this.logger.log(
`Imagen inferida correctamente (${inferencia.length} chars)`,
);
return inferencia;
} catch (error) {
this.logger.error(
`Error analizando imagen: ${error.message}`,
error.response?.data,
);
const base64Imagen = imagenBuffer.toString('base64');
const response = await axios.post(this.OPENROUTER_URL, {
model: process.env.MODEL_GENERADOR || process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages: [{ role: 'user', content: [{ type: 'text', text: promptDeVision }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Imagen}` } }] }],
max_tokens: 512,
}, { headers: this.headers });
const inferencia: string = response.data.choices?.[0]?.message?.content?.trim();
return inferencia || FALLBACK;
} catch (error: any) {
this.logger.error(`Error analizando imagen: ${error.message}`);
return FALLBACK;
}
}