Configuracion de agente de whastapp paratrabajar con la estructura propuesta
This commit is contained in:
@@ -2,7 +2,6 @@ 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 { LeadsService } from '../leads/leads.service';
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT =
|
||||
@@ -34,9 +33,23 @@ export interface ValidacionResultado {
|
||||
viable?: boolean;
|
||||
}
|
||||
|
||||
export interface LeadBasico {
|
||||
id: string;
|
||||
telefono: string;
|
||||
nombre: string;
|
||||
estado_actual: string;
|
||||
espacio: string | null;
|
||||
rango_m2: string | null;
|
||||
estilo: string | null;
|
||||
urgencia: string | null;
|
||||
presupuesto_declarado: string | null;
|
||||
viable: boolean | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeResponse {
|
||||
respuesta: string;
|
||||
entidad?: Partial<Lead>;
|
||||
entidad?: Partial<LeadBasico>;
|
||||
viable?: boolean;
|
||||
nuevoEstado?: string;
|
||||
}
|
||||
@@ -47,73 +60,39 @@ export class ClaudeService implements OnModuleInit {
|
||||
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
||||
private systemPromptCache = '';
|
||||
private reglasPromptCache = '';
|
||||
private readonly reintentosPorLead = new Map<
|
||||
string,
|
||||
{ estado: string; count: number }
|
||||
>();
|
||||
private readonly reintentosPorLead = new Map<string, { estado: string; count: number }>();
|
||||
|
||||
constructor(private readonly leadsService: LeadsService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.systemPromptCache = this.cargarPrompts([
|
||||
'luisa_core.md',
|
||||
'luisa_flujo.md',
|
||||
'luisa_casos.md',
|
||||
]);
|
||||
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`,
|
||||
);
|
||||
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;
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
|
||||
}
|
||||
if (contenido.trim()) partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||
} catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); }
|
||||
}
|
||||
|
||||
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;
|
||||
return partes.join('\n').trim() || 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 = {
|
||||
const envMap: Record<string, string | undefined> = {
|
||||
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];
|
||||
return envMap[clave] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5');
|
||||
}
|
||||
|
||||
private serializarLead(lead: Lead): string {
|
||||
private serializarLead(lead: LeadBasico): string {
|
||||
return [
|
||||
`- ID: ${lead.id}`,
|
||||
`- Telefono: ${lead.telefono}`,
|
||||
@@ -129,10 +108,6 @@ export class ClaudeService implements OnModuleInit {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter requiere system dentro de messages[] para modelos OpenAI.
|
||||
* El campo system en la raiz del payload no siempre se aplica.
|
||||
*/
|
||||
private async llamarOpenRouter(
|
||||
model: string,
|
||||
system: string,
|
||||
@@ -140,95 +115,47 @@ export class ClaudeService implements OnModuleInit {
|
||||
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' };
|
||||
|
||||
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 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;
|
||||
});
|
||||
return response.data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
private parsearJson<T>(texto: string): T | null {
|
||||
const limpio = texto
|
||||
.replace(/```json\s*/gi, '')
|
||||
.replace(/```\s*/g, '')
|
||||
.trim();
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
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';
|
||||
|
||||
? (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),
|
||||
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> {
|
||||
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.
|
||||
@@ -247,132 +174,63 @@ Formato exacto:
|
||||
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
|
||||
|
||||
Reglas para valor_extraido:
|
||||
- espacio: cocina, bano, salon, integral, otro
|
||||
- espacio: cocina, bano, salon, comedor, integral, otro
|
||||
- tamano: menos10, 10a20, 20a40, mas40
|
||||
- estilo: funcional, cuidado, exclusivo
|
||||
- urgencia: urgente, medio_plazo, frio
|
||||
- urgencia: alta, media, baja
|
||||
- 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 },
|
||||
];
|
||||
- Extrae el valor semantico aunque venga en lenguaje coloquial`;
|
||||
|
||||
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) ?? {},
|
||||
);
|
||||
|
||||
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 JSON invalido (intento): ${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',
|
||||
};
|
||||
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 {
|
||||
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'
|
||||
) {
|
||||
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 === '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 };
|
||||
return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, 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 };
|
||||
if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null };
|
||||
return { valido: true, valorNormalizado: valor, viable: this.leadsService.evaluarViabilidad(valor) };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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, '');
|
||||
return valor.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
private claveReintento(leadId: number, estado: string): string {
|
||||
return `${leadId}:${estado}`;
|
||||
}
|
||||
private claveReintento(leadId: string, 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);
|
||||
private obtenerReintentos(leadId: string, estado: string): number {
|
||||
const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado));
|
||||
return entry?.estado === estado ? entry.count : 0;
|
||||
}
|
||||
|
||||
private incrementarReintentos(leadId: number, estado: string): number {
|
||||
private incrementarReintentos(leadId: string, estado: string): number {
|
||||
const clave = this.claveReintento(leadId, estado);
|
||||
const actual = this.obtenerReintentos(leadId, estado);
|
||||
const count = actual + 1;
|
||||
@@ -380,15 +238,12 @@ Reglas para valor_extraido:
|
||||
return count;
|
||||
}
|
||||
|
||||
private resetearReintentos(leadId: number, estado: string): void {
|
||||
private resetearReintentos(leadId: string, 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,
|
||||
lead: LeadBasico,
|
||||
historial: Array<{ role: string; content: string }>,
|
||||
mensajeActual: string,
|
||||
clasificacion: ClasificacionResultado,
|
||||
@@ -398,11 +253,7 @@ Reglas para valor_extraido:
|
||||
siguienteEstado: string | null,
|
||||
forzarApertura = false,
|
||||
): Promise<string> {
|
||||
const systemPrompt = this.leerPromptsSistema();
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
||||
lead.estado_actual,
|
||||
);
|
||||
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||
const contextoGeneracion = `
|
||||
## Contexto del lead
|
||||
${this.serializarLead(lead)}
|
||||
@@ -419,11 +270,10 @@ ${this.serializarLead(lead)}
|
||||
|
||||
## 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).
|
||||
Habla espanol de Espana; suena natural y cercana.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -432,32 +282,16 @@ Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y r
|
||||
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 },
|
||||
];
|
||||
|
||||
const contenido = await this.llamarOpenRouter(
|
||||
this.getModelo('generador'),
|
||||
`${systemPrompt}\n${contextoGeneracion}`,
|
||||
messages,
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
|
||||
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
|
||||
[...historial, { role: 'user', content: mensajeActual }],
|
||||
{ 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();
|
||||
|
||||
private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise<string> {
|
||||
const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||
const system = `${reglas}
|
||||
|
||||
## Tu tarea
|
||||
@@ -471,19 +305,16 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
|
||||
## 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
|
||||
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja
|
||||
- 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,
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
|
||||
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
|
||||
{ temperature: 0.3 },
|
||||
);
|
||||
|
||||
return contenido.trim() || borrador;
|
||||
}
|
||||
|
||||
@@ -491,7 +322,7 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
|
||||
}
|
||||
|
||||
private mensajeFallback(estadoFlujo: string, lead: Lead): string {
|
||||
private mensajeFallback(estadoFlujo: string, lead: LeadBasico): 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?`,
|
||||
@@ -502,91 +333,23 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
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?',
|
||||
};
|
||||
|
||||
return (
|
||||
fallbacks[estadoFlujo] ??
|
||||
'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.'
|
||||
);
|
||||
return fallbacks[estadoFlujo] ?? 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Orquesta las 4 capas: clasificar, validar, generar y aplicar reglas.
|
||||
*/
|
||||
async llamarClaude(
|
||||
lead: Lead,
|
||||
lead: LeadBasico,
|
||||
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,
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
||||
lead.estado_actual,
|
||||
);
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||
|
||||
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,
|
||||
);
|
||||
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,
|
||||
respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta,
|
||||
nuevoEstado: 'apertura',
|
||||
};
|
||||
}
|
||||
@@ -597,72 +360,38 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
|
||||
let avanzarEstado = false;
|
||||
let siguienteEstado: string | null = null;
|
||||
let entidad: Partial<Lead> = {};
|
||||
let entidad: Partial<LeadBasico> = {};
|
||||
let viable: boolean | undefined;
|
||||
|
||||
const puedeAvanzar =
|
||||
validacion.valido &&
|
||||
!clasificacion.es_desvio &&
|
||||
clasificacion.intencion === 'respuesta';
|
||||
const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta';
|
||||
|
||||
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() };
|
||||
(entidad as any)[campo] = validacion.valorNormalizado;
|
||||
} else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) {
|
||||
entidad.nombre = clasificacion.valor_extraido.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (estadoFlujo === 'presupuesto') {
|
||||
viable = validacion.viable;
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(
|
||||
estadoFlujo,
|
||||
viable,
|
||||
);
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo, viable);
|
||||
} else {
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
||||
}
|
||||
} else if (
|
||||
!validacion.valido &&
|
||||
clasificacion.responde_pregunta &&
|
||||
!clasificacion.es_desvio
|
||||
) {
|
||||
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
|
||||
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
||||
if (reintentos > 2) {
|
||||
reintentos = 2;
|
||||
}
|
||||
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,
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
this.logger.warn(`Respuesta final viola reglas, usando fallback para estado=${estadoFlujo}`);
|
||||
respuesta = this.mensajeFallback(estadoFlujo, lead);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user