677 lines
21 KiB
TypeScript
677 lines
21 KiB
TypeScript
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 =
|
|
'Eres Luisa, asesora de Reformix en Madrid. Cualificas leads de reforma por WhatsApp. ' +
|
|
'Hablas espanol de Espana, cercana y natural; adapta el registro al usuario (coloquial si el, formal si el). ' +
|
|
'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 = [
|
|
/soy un (modelo|asistente)/i,
|
|
/desarrollado por openai/i,
|
|
/\bopenai\b/i,
|
|
/\bchatgpt\b/i,
|
|
/inteligencia artificial/i,
|
|
/no tengo un nombre propio/i,
|
|
/en qu[eé] puedo ayudarte/i,
|
|
];
|
|
|
|
export interface ClasificacionResultado {
|
|
responde_pregunta: boolean;
|
|
valor_extraido: string | null;
|
|
es_desvio: boolean;
|
|
intencion: 'respuesta' | 'desvio' | 'despedida' | 'insulto' | 'pregunta';
|
|
}
|
|
|
|
export interface ValidacionResultado {
|
|
valido: boolean;
|
|
valorNormalizado: string | null;
|
|
viable?: boolean;
|
|
}
|
|
|
|
export interface ClaudeResponse {
|
|
respuesta: string;
|
|
entidad?: Partial<Lead>;
|
|
viable?: boolean;
|
|
nuevoEstado?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ClaudeService implements OnModuleInit {
|
|
private readonly logger = new Logger(ClaudeService.name);
|
|
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
|
private systemPromptCache = '';
|
|
private reglasPromptCache = '';
|
|
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.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
|
|
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;
|
|
}
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = {
|
|
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];
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
messages: Array<{ role: string; content: string }>,
|
|
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' };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private parsearJson<T>(texto: string): T | null {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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';
|
|
|
|
return {
|
|
responde_pregunta: raw.responde_pregunta,
|
|
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> {
|
|
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.
|
|
|
|
Estado actual del lead: ${estadoActual}
|
|
Valores permitidos para este estado (si aplica): ${valoresPermitidos.length ? valoresPermitidos.join(', ') : 'ninguno (estado sin valor concreto)'}
|
|
|
|
Formato exacto:
|
|
{
|
|
"responde_pregunta": true,
|
|
"valor_extraido": null,
|
|
"es_desvio": false,
|
|
"intencion": "respuesta"
|
|
}
|
|
|
|
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
|
|
|
|
Reglas para valor_extraido:
|
|
- espacio: cocina, bano, salon, integral, otro
|
|
- tamano: menos10, 10a20, 20a40, mas40
|
|
- estilo: funcional, cuidado, exclusivo
|
|
- urgencia: urgente, medio_plazo, frio
|
|
- 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 },
|
|
];
|
|
|
|
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) ?? {},
|
|
);
|
|
|
|
if (parsed) return parsed;
|
|
|
|
this.logger.warn(
|
|
`Clasificador JSON invalido (intento, modelo=${this.getModelo('clasificador')}): ${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',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Capa 2 — Validador en codigo: valida valor_extraido contra valores permitidos.
|
|
*/
|
|
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'
|
|
) {
|
|
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 };
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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;
|
|
|
|
return { valido: true, valorNormalizado };
|
|
}
|
|
|
|
private normalizarTexto(valor: string): string {
|
|
return valor
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '');
|
|
}
|
|
|
|
private claveReintento(leadId: number, 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);
|
|
return entry?.estado === estado ? entry.count : 0;
|
|
}
|
|
|
|
private incrementarReintentos(leadId: number, estado: string): number {
|
|
const clave = this.claveReintento(leadId, estado);
|
|
const actual = this.obtenerReintentos(leadId, estado);
|
|
const count = actual + 1;
|
|
this.reintentosPorLead.set(clave, { estado, count });
|
|
return count;
|
|
}
|
|
|
|
private resetearReintentos(leadId: number, 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,
|
|
historial: Array<{ role: string; content: string }>,
|
|
mensajeActual: string,
|
|
clasificacion: ClasificacionResultado,
|
|
validacion: ValidacionResultado,
|
|
reintentos: number,
|
|
avanzarEstado: boolean,
|
|
siguienteEstado: string | null,
|
|
forzarApertura = false,
|
|
): Promise<string> {
|
|
const systemPrompt = this.leerPromptsSistema();
|
|
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
|
lead.estado_actual,
|
|
);
|
|
|
|
const contextoGeneracion = `
|
|
## Contexto del lead
|
|
${this.serializarLead(lead)}
|
|
|
|
## Estado del turno
|
|
- Estado de flujo: ${estadoFlujo}
|
|
- Clasificacion: ${JSON.stringify(clasificacion)}
|
|
- Validacion valida: ${validacion.valido}
|
|
- Valor validado: ${validacion.valorNormalizado ?? 'ninguno'}
|
|
- Reintentos en este estado: ${reintentos}
|
|
- Avanzar estado: ${avanzarEstado}
|
|
- Siguiente estado (si avanza): ${siguienteEstado ?? 'sin cambio'}
|
|
- Forzar mensaje de apertura: ${forzarApertura}
|
|
|
|
## 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).
|
|
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.
|
|
Si validacion valida es false y reintentos >= 2, repite la pregunta del estado actual de forma directa.
|
|
Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y redirige al flujo sin avanzar.
|
|
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,
|
|
{ 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();
|
|
|
|
const system = `${reglas}
|
|
|
|
## Tu tarea
|
|
Recibes un borrador de respuesta para WhatsApp. Debes corregirlo para que cumpla TODAS las reglas de Luisa.
|
|
Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
|
|
|
## Contexto
|
|
- Estado del lead: ${estadoFlujo}
|
|
- Nombre lead: ${lead.nombre || 'desconocido'}
|
|
- Intencion del usuario: ${clasificacion.intencion}
|
|
|
|
## 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
|
|
- 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,
|
|
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
|
|
{ temperature: 0.3 },
|
|
);
|
|
|
|
return contenido.trim() || borrador;
|
|
}
|
|
|
|
private contieneFraseProhibida(texto: string): boolean {
|
|
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
|
|
}
|
|
|
|
private mensajeFallback(estadoFlujo: string, lead: Lead): 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?`,
|
|
apertura: `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?`,
|
|
espacio: 'Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?',
|
|
tamano: 'Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?',
|
|
estilo: 'Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?',
|
|
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.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Orquesta las 4 capas: clasificar, validar, generar y aplicar reglas.
|
|
*/
|
|
async llamarClaude(
|
|
lead: Lead,
|
|
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,
|
|
);
|
|
|
|
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,
|
|
);
|
|
return {
|
|
respuesta: this.contieneFraseProhibida(respuesta)
|
|
? this.mensajeFallback('nuevo', lead)
|
|
: respuesta,
|
|
nuevoEstado: 'apertura',
|
|
};
|
|
}
|
|
|
|
const clasificacion = await this.clasificar(mensajeActual, estadoFlujo);
|
|
const validacion = this.validar(clasificacion, estadoFlujo);
|
|
|
|
let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
|
|
let avanzarEstado = false;
|
|
let siguienteEstado: string | null = null;
|
|
let entidad: Partial<Lead> = {};
|
|
let viable: boolean | undefined;
|
|
|
|
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() };
|
|
}
|
|
}
|
|
|
|
if (estadoFlujo === 'presupuesto') {
|
|
viable = validacion.viable;
|
|
siguienteEstado = this.leadsService.getSiguienteEstado(
|
|
estadoFlujo,
|
|
viable,
|
|
);
|
|
} else {
|
|
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
|
}
|
|
} else if (
|
|
!validacion.valido &&
|
|
clasificacion.responde_pregunta &&
|
|
!clasificacion.es_desvio
|
|
) {
|
|
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
|
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,
|
|
);
|
|
|
|
if (this.contieneFraseProhibida(respuesta)) {
|
|
this.logger.warn(
|
|
`Respuesta final viola reglas de identidad, usando fallback para estado=${estadoFlujo}`,
|
|
);
|
|
respuesta = this.mensajeFallback(estadoFlujo, lead);
|
|
}
|
|
|
|
return {
|
|
respuesta,
|
|
entidad: Object.keys(entidad).length > 0 ? entidad : undefined,
|
|
viable,
|
|
nuevoEstado: avanzarEstado ? siguienteEstado ?? undefined : undefined,
|
|
};
|
|
}
|
|
}
|