Actualizar server.js

This commit is contained in:
2026-03-08 15:18:56 +00:00
parent 235ed3e0c2
commit 3f2555cbfb

144
server.js
View File

@@ -479,6 +479,9 @@ async function registrarMovimiento(serviceId, userId, action, details) {
} catch (e) { console.error("Error Robot Notario:", e); }
}
// ==========================================
// 🧠 CEREBRO IA (WHATSAPP)
// ==========================================
async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
try {
const userQ = await pool.query("SELECT wa_settings, full_name, portal_settings FROM users WHERE id=$1", [ownerId]);
@@ -487,109 +490,73 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
const instruccionesExtra = settings.ai_custom_prompt || "";
const empresaNombre = userData?.full_name || "nuestra empresa";
// 👇 AÑADE ESTA LÍNEA EXACTAMENTE AQUÍ:
console.log("🕵️ CHIVATO PROMPT EXTRA:", instruccionesExtra);
// 🛠️ BLINDAJE DE HORARIOS: Extraemos campo a campo para evitar fallos de lectura
const pSettings = userData?.portal_settings || {};
const horarios = {
m_start: pSettings.m_start || "09:00",
m_end: pSettings.m_end || "14:00",
a_start: pSettings.a_start || "16:00",
a_end: pSettings.a_end || "19:00"
};
const horarios = { m_start: pSettings.m_start || "09:00", m_end: pSettings.m_end || "14:00", a_start: pSettings.a_start || "16:00", a_end: pSettings.a_end || "19:00" };
if (!settings.wa_ai_enabled) return null;
const ahora = new Date();
const fechaHoyTexto = ahora.toLocaleDateString('es-ES', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
// 🧠 MEMORIA: Traemos los últimos 8 mensajes
const historyQ = await pool.query(`
SELECT sender_role, message
FROM service_communications
WHERE scraped_id = $1
ORDER BY created_at DESC LIMIT 8
`, [datosExpediente.dbId]);
const historyQ = await pool.query("SELECT sender_role, message FROM service_communications WHERE scraped_id = $1 ORDER BY created_at DESC LIMIT 8", [datosExpediente.dbId]);
const historialChat = historyQ.rows.reverse().map(row => ({
role: (row.sender_role === 'ia' || row.sender_role === 'admin' || row.sender_role === 'operario') ? 'assistant' : 'user',
content: row.message
}));
const esPrimerMensaje = historialChat.length === 0;
// 🗓️ LECTURA DE AGENDA (Solo la usamos si realmente necesita agendar)
let agendaOcupadaTexto = "El técnico tiene la agenda libre en horario laboral.";
if (datosExpediente.worker_id) {
const agendaQ = await pool.query(`
SELECT raw_data->>'scheduled_date' as date, raw_data->>'scheduled_time' as time, raw_data->>'Población' as pob
FROM scraped_services
WHERE assigned_to = $1 AND raw_data->>'scheduled_date' >= CURRENT_DATE::text
AND status != 'archived' AND id != $2 ORDER BY date ASC, time ASC
`, [datosExpediente.worker_id, datosExpediente.dbId]);
const agendaQ = await pool.query("SELECT raw_data->>'scheduled_date' as date, raw_data->>'scheduled_time' as time, raw_data->>'Población' as pob FROM scraped_services WHERE assigned_to = $1 AND raw_data->>'scheduled_date' >= CURRENT_DATE::text AND status != 'archived' AND id != $2 ORDER BY date ASC, time ASC", [datosExpediente.worker_id, datosExpediente.dbId]);
if (agendaQ.rowCount > 0) {
const ocupaciones = {};
agendaQ.rows.forEach(r => {
if(r.date && r.time) {
if(!ocupaciones[r.date]) ocupaciones[r.date] = [];
ocupaciones[r.date].push(`${r.time} (en ${r.pob || 'otra zona'})`);
}
});
agendaQ.rows.forEach(r => { if(r.date && r.time) { if(!ocupaciones[r.date]) ocupaciones[r.date] = []; ocupaciones[r.date].push(`${r.time} (en ${r.pob || 'otra zona'})`); } });
const lineas = Object.keys(ocupaciones).map(d => `- Día ${d}: Ocupado a las ${ocupaciones[d].join(", ")}`);
if(lineas.length > 0) agendaOcupadaTexto = lineas.join("\n ");
}
}
// 🚦 ANÁLISIS DEL ESTADO (LA MAGIA EMPIEZA AQUÍ)
const hayCitaPendiente = datosExpediente.appointment_status === 'pending' && datosExpediente.cita_pendiente_fecha;
const tieneCitaConfirmada = datosExpediente.cita && datosExpediente.cita !== 'Ninguna';
const esUrgencia = datosExpediente.is_urgent;
// 🎯 INSTRUCCIÓN DINÁMICA: Le ponemos la camisa de fuerza a la IA
let directivaEstricta = "";
if (esUrgencia) {
directivaEstricta = `
🛑 ESTADO ACTUAL: SERVICIO DE URGENCIA.
TU ÚNICO OBJETIVO: Tranquilizar al cliente. Dile que al ser una urgencia, el técnico está avisado y contactará/acudirá lo antes posible.
PROHIBICIÓN ABSOLUTA: Bajo ningún concepto intentes dar cita, ni preguntes por fechas, ni propongas horarios.
`;
} else if (hayCitaPendiente) {
directivaEstricta = `
🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN POR EL TÉCNICO.
Datos de la propuesta actual: Día ${datosExpediente.cita_pendiente_fecha} a las ${datosExpediente.cita_pendiente_hora}.
TU ÚNICO OBJETIVO: Informar al cliente que ya le hemos pasado su propuesta al técnico y que estamos esperando a que él la valide en su aplicación.
PROHIBICIÓN ABSOLUTA: No agendes de nuevo. No ofrezcas más huecos. Si el cliente dice "vale", despídete amablemente y fin.
`;
} else if (tieneCitaConfirmada) {
directivaEstricta = `
🛑 ESTADO ACTUAL: CITA 100% CONFIRMADA.
Fecha de la cita: ${datosExpediente.cita}.
TU ÚNICO OBJETIVO: Resolver cualquier duda del cliente y recordarle que su cita es el ${datosExpediente.cita}.
PROHIBICIÓN ABSOLUTA: No intentes agendar. No menciones huecos libres. El trabajo ya está programado.
`;
} else {
directivaEstricta = `
🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.
TU OBJETIVO: Acordar una fecha y hora con el cliente.
REGLAS DE AGENDAMIENTO:
1. OFRECE HUECOS: Mira la "AGENDA DEL TÉCNICO". Nunca ofrezcas horas ocupadas (deja 1 hora de margen).
2. RUTAS INTELIGENTES: El cliente está en ${datosExpediente.poblacion || 'su domicilio'}. Si el técnico ya va a esa población un día concreto, ofrécele ese día para aprovechar el viaje.
3. FINES DE SEMANA CERRADO. Ofrece solo de L-V.
4. CÓDIGO DE CIERRE (VITAL): Si el cliente ACEPTA una propuesta (ej: tú dices "el lunes a las 10" y él dice "sí, perfecto"), confírmale que le pasas la nota al técnico y AÑADE AL FINAL DE TU MENSAJE: [PROPUESTA:YYYY-MM-DD HH:mm]
`;
// 🕐 CONVERTIMOS LA HORA PENDIENTE EN TRAMO DE 1 HORA
let tramoPendiente = datosExpediente.cita_pendiente_hora || "";
if (tramoPendiente && tramoPendiente.includes(":")) {
let [h, m] = tramoPendiente.split(':');
let hEnd = String((parseInt(h) + 1) % 24).padStart(2, '0');
tramoPendiente = `entre las ${h}:${m} y las ${hEnd}:${m} aprox`;
}
// 🕐 CONVERTIMOS LA HORA CONFIRMADA EN TRAMO DE 1 HORA
let tramoConfirmado = datosExpediente.hora_cita || "";
if (tramoConfirmado && tramoConfirmado.includes(":")) {
let [h, m] = tramoConfirmado.split(':');
let hEnd = String((parseInt(h) + 1) % 24).padStart(2, '0');
tramoConfirmado = `entre las ${h}:${m} y las ${hEnd}:${m} aprox`;
} else {
tramoConfirmado = 'una hora por confirmar';
}
let directivaEstricta = "";
if (esUrgencia) {
directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO DE URGENCIA.\nTU ÚNICO OBJETIVO: Tranquilizar al cliente. Dile que al ser urgencia el técnico está avisado.\nPROHIBICIÓN ABSOLUTA: No des cita ni propongas horas.`;
} else if (hayCitaPendiente) {
directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN POR TÉCNICO.\n📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}.\nTU ÚNICO OBJETIVO: Informar al cliente que estamos esperando confirmación del técnico.\n⚠️ REGLA CRÍTICA: Ignora el historial si no coincide con esta propuesta.\nPROHIBICIÓN ABSOLUTA: No agendes de nuevo.`;
} else if (tieneCitaConfirmada) {
directivaEstricta = `🛑 ESTADO ACTUAL: CITA 100% CONFIRMADA.\n📅 Día: ${datosExpediente.cita}.\n⏰ Tramo horario: ${tramoConfirmado}.\nTU ÚNICO OBJETIVO: Recordar la cita actual.\n⚠️ REGLA DE ORO (CRÍTICA): La hora indicada aquí arriba es LA ÚNICA VERDAD. Si en el historial se habló de otra hora o fecha, IGNORA EL HISTORIAL. Confírmale exclusivamente los datos actuales.\nPROHIBICIÓN ABSOLUTA: No agendes otra vez ni ofrezcas huecos.`;
} else {
directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.\nTU OBJETIVO: Acordar fecha y hora.\nREGLAS: Nunca ofrezcas horas ocupadas. Fines de semana cerrado. Si el cliente ACEPTA, añade AL FINAL el código: [PROPUESTA:YYYY-MM-DD HH:mm]`;
}
// 🧠 EL PROMPT MAESTRO
const promptSistema = `
Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y al grano por WhatsApp. Eres resolutivo y no suenas como un contestador automático.
Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y al grano por WhatsApp.
--- CONTEXTO BÁSICO ---
- Hoy es: ${fechaHoyTexto}. (Año 2026).
- Horario de la empresa: Lunes a Viernes de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}.
- Horario de la empresa: L-V de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}. Fines de semana cerrado.
--- AGENDA DEL TÉCNICO ASIGNADO ---
${agendaOcupadaTexto}
@@ -597,26 +564,26 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
--- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE ---
${directivaEstricta}
--- 🗓️ REGLAS ESTRICTAS DE FECHA Y HORA (FORMATO HUMANO) ---
1. DÍAS DE LA SEMANA: NUNCA uses fechas frías como "2026-03-10". Usa SIEMPRE el día de la semana y el mes (Ej: "el martes de la semana que viene", "el jueves día 12").
2. TRAMOS HORARIOS: NUNCA le des una hora exacta al cliente. Usa SIEMPRE un margen de 1 hora. (Ej: "entre las 10:00 y las 11:00 aprox").
3. (Si estás agendando): Aunque hables en tramos, tu código interno de cierre DEBE SER la hora exacta de inicio en formato reloj: [PROPUESTA:YYYY-MM-DD HH:mm]
--- 📝 INSTRUCCIONES PERSONALIZADAS DE LA EMPRESA ---
${instruccionesExtra ? instruccionesExtra : 'No hay reglas extra.'}
--- REGLAS DE ORO DE COMUNICACIÓN ---
1. Máximo 2 frases. Los mensajes de WhatsApp deben ser cortos.
2. Lee el historial de la conversación. Si el cliente solo responde "Ok" o "Gracias", dile "De nada, aquí estamos para lo que necesites" y cierra la charla. No le des la chapa.
3. NO TE PRESENTES si ya estáis conversando.
${esPrimerMensaje ? '4. Como es el primer mensaje del chat, preséntate brevemente diciendo que eres de ' + empresaNombre + ' y da su número de aviso (#' + datosExpediente.ref + ').' : ''}
0. LA BASE DE DATOS MANDA: Los datos del "ESTADO ACTUAL" son la única verdad. Si contradicen el historial, la oficina ha modificado la cita.
1. Máximo 2 frases. Mensajes cortos.
2. Lee el historial. Si el cliente dice "Ok" o "Gracias", despídete y no des la chapa.
3. NO TE PRESENTES si ya habéis intercambiado mensajes antes.
${esPrimerMensaje ? '4. Primer mensaje: preséntate brevemente diciendo de dónde eres y el aviso (#' + datosExpediente.ref + ').' : ''}
`;
const mensajesParaIA = [
{ role: "system", content: promptSistema },
...historialChat,
{ role: "user", content: mensajeCliente }
];
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: mensajesParaIA,
temperature: 0.1, // Congelado. No queremos que invente, queremos que obedezca.
messages: [{ role: "system", content: promptSistema }, ...historialChat, { role: "user", content: mensajeCliente }],
temperature: 0.1,
});
return completion.choices[0].message.content;
@@ -3072,7 +3039,6 @@ app.post("/services/:id/chat", authMiddleware, async (req, res) => {
}
});
// 🤖 WEBHOOK CON ESCUDO DE INTERVENCIÓN HUMANA
// 🤖 WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS
app.post("/webhook/evolution", async (req, res) => {
try {
@@ -3094,6 +3060,7 @@ app.post("/webhook/evolution", async (req, res) => {
const ownerId = instanceName.split("_")[1];
const cleanPhone = telefonoCliente.slice(-9);
// 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE (Ignorando finalizados/anulados)
// 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE (Ignorando finalizados/anulados)
const svcQ = await pool.query(`
SELECT s.id, s.service_ref, s.assigned_to, u.full_name as worker_name, s.is_urgent,
@@ -3110,17 +3077,18 @@ app.post("/webhook/evolution", async (req, res) => {
WHERE s.owner_id = $1
AND s.status != 'archived'
AND s.raw_data::text ILIKE $2
-- 👇 MAGIA: Excluimos de la búsqueda los estados muertos
-- 👇 MAGIA: Excluimos los estados muertos para que no coja un finalizado si tiene varios partes
AND (st.name IS NULL OR (st.name NOT ILIKE '%finalizado%' AND st.name NOT ILIKE '%anulado%' AND st.name NOT ILIKE '%desasignado%'))
ORDER BY s.created_at DESC LIMIT 1
`, [ownerId, `%${cleanPhone}%`]);
if (svcQ.rowCount > 0) {
const service = svcQ.rows[0];
// Si entra aquí, es 100% seguro que el expediente está vivo y la IA puede hablar.
// 🛑 SEMÁFORO ANTI-METRALLETA
if (candadosIA.has(service.id)) {
return;
}
candadosIA.add(service.id);
try {
@@ -3136,7 +3104,7 @@ app.post("/webhook/evolution", async (req, res) => {
if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 120) return;
}
// 🧠 LLAMADA A LA IA (Añadimos la hora aquí)
// 🧠 LLAMADA A LA IA (Con la hora inyectada)
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
dbId: service.id,
ref: service.service_ref,
@@ -3144,7 +3112,7 @@ app.post("/webhook/evolution", async (req, res) => {
operario: service.worker_name,
worker_id: service.assigned_to,
cita: service.cita,
hora_cita: service.hora_cita, // 👈 NUEVO: SE LO PASAMOS AL CEREBRO
hora_cita: service.hora_cita, // 👈 AHORA SÍ PASA LA HORA EXACTA
poblacion: service.poblacion || "",
is_urgent: service.is_urgent,
appointment_status: service.appointment_status,