Logia de agente de Whatsapp

This commit is contained in:
unknown
2026-05-31 22:02:58 -04:00
parent aa7555b49d
commit ef78d9a14c
28 changed files with 12736 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
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';
export interface ClaudeResponse {
respuesta: string;
entidad?: Partial<Lead>; // datos extraídos del turno
viable?: boolean; // flag si Claude decide el resultado final
}
@Injectable()
export class ClaudeService {
private readonly logger = new Logger(ClaudeService.name);
private readonly promptsDir = path.join(process.cwd(), 'prompts');
/**
* 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'];
const partes: string[] = [];
for (const archivo of archivos) {
const rutaCompleta = path.join(this.promptsDir, archivo);
try {
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
if (contenido.trim()) {
partes.push(`\n\n## ${archivo}\n${contenido}`);
}
} catch {
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
}
}
return partes.join('\n');
}
/**
* Serializa los datos actuales del lead para el contexto de Claude.
*/
private serializarLead(lead: Lead): string {
return [
`- ID: ${lead.id}`,
`- Telefono: ${lead.telefono}`,
`- Estado actual: ${lead.estado_actual}`,
`- Nombre: ${lead.nombre || 'no capturado'}`,
`- Email: ${lead.email || 'no capturado'}`,
`- Espacio: ${lead.espacio || 'no capturado'}`,
`- Rango m2: ${lead.rango_m2 || 'no capturado'}`,
`- Estilo: ${lead.estilo || 'no capturado'}`,
`- Urgencia: ${lead.urgencia || 'no capturado'}`,
`- Presupuesto declarado: ${lead.presupuesto_declarado || 'no capturado'}`,
`- Viable: ${lead.viable !== null && lead.viable !== undefined ? lead.viable : 'pendiente'}`,
].join('\n');
}
/**
* 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)
*/
async llamarClaude(
lead: Lead,
historial: Array<{ role: string; content: string }>,
mensajeActual: string,
): Promise<ClaudeResponse> {
const systemPrompt = this.leerPromptsSistema();
const contextoDeLead = `
## Contexto del lead actual
${this.serializarLead(lead)}
`;
const systemFinal = `${systemPrompt}
${contextoDeLead}
## 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.`;
const messages = [
...historial,
{ role: 'user', content: mensajeActual },
];
try {
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages,
system: systemFinal,
max_tokens: 1024,
temperature: 0.7,
},
{
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Luisa Bot',
},
},
);
const contenidoCompleto: string =
response.data.choices?.[0]?.message?.content || '';
// Separar la respuesta visible del bloque de datos extraídos
const regexDatos = /<DATOS_EXTRAIDOS>([\s\S]*?)<\/DATOS_EXTRAIDOS>/;
const match = contenidoCompleto.match(regexDatos);
let respuesta = contenidoCompleto
.replace(regexDatos, '')
.trim();
let entidad: Partial<Lead> = {};
let viableFlag: boolean | undefined = undefined;
if (match) {
try {
const datos = JSON.parse(match[1].trim());
// Solo incluir campos no nulos
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 {
this.logger.warn('No se pudo parsear DATOS_EXTRAIDOS');
}
}
return { respuesta, entidad, viable: viableFlag };
} catch (error) {
this.logger.error(
`Error llamando a Claude via OpenRouter: ${error.message}`,
error.response?.data,
);
throw error;
}
}
}