Logia de agente de Whatsapp
This commit is contained in:
8
mvp/Whatsapp-bot/src/claude/claude.module.ts
Normal file
8
mvp/Whatsapp-bot/src/claude/claude.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClaudeService } from './claude.service';
|
||||
|
||||
@Module({
|
||||
providers: [ClaudeService],
|
||||
exports: [ClaudeService],
|
||||
})
|
||||
export class ClaudeModule {}
|
||||
172
mvp/Whatsapp-bot/src/claude/claude.service.ts
Normal file
172
mvp/Whatsapp-bot/src/claude/claude.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user