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); } } catch (e) { console.error("Error Robot Notario:", e); }
} }
// ==========================================
// 🧠 CEREBRO IA (WHATSAPP)
// ==========================================
async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
try { try {
const userQ = await pool.query("SELECT wa_settings, full_name, portal_settings FROM users WHERE id=$1", [ownerId]); 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 instruccionesExtra = settings.ai_custom_prompt || "";
const empresaNombre = userData?.full_name || "nuestra empresa"; const empresaNombre = userData?.full_name || "nuestra empresa";
// 👇 AÑADE ESTA LÍNEA EXACTAMENTE AQUÍ:
console.log("🕵️ CHIVATO PROMPT EXTRA:", instruccionesExtra); console.log("🕵️ CHIVATO PROMPT EXTRA:", instruccionesExtra);
// 🛠️ BLINDAJE DE HORARIOS: Extraemos campo a campo para evitar fallos de lectura
const pSettings = userData?.portal_settings || {}; const pSettings = userData?.portal_settings || {};
const horarios = { 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" };
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; if (!settings.wa_ai_enabled) return null;
const ahora = new Date(); const ahora = new Date();
const fechaHoyTexto = ahora.toLocaleDateString('es-ES', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); 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 => ({ const historialChat = historyQ.rows.reverse().map(row => ({
role: (row.sender_role === 'ia' || row.sender_role === 'admin' || row.sender_role === 'operario') ? 'assistant' : 'user', role: (row.sender_role === 'ia' || row.sender_role === 'admin' || row.sender_role === 'operario') ? 'assistant' : 'user',
content: row.message content: row.message
})); }));
const esPrimerMensaje = historialChat.length === 0; 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."; let agendaOcupadaTexto = "El técnico tiene la agenda libre en horario laboral.";
if (datosExpediente.worker_id) { if (datosExpediente.worker_id) {
const agendaQ = await pool.query(` 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]);
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) { if (agendaQ.rowCount > 0) {
const ocupaciones = {}; const ocupaciones = {};
agendaQ.rows.forEach(r => { 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'})`); } });
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(", ")}`); const lineas = Object.keys(ocupaciones).map(d => `- Día ${d}: Ocupado a las ${ocupaciones[d].join(", ")}`);
if(lineas.length > 0) agendaOcupadaTexto = lineas.join("\n "); 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 hayCitaPendiente = datosExpediente.appointment_status === 'pending' && datosExpediente.cita_pendiente_fecha;
const tieneCitaConfirmada = datosExpediente.cita && datosExpediente.cita !== 'Ninguna'; const tieneCitaConfirmada = datosExpediente.cita && datosExpediente.cita !== 'Ninguna';
const esUrgencia = datosExpediente.is_urgent; const esUrgencia = datosExpediente.is_urgent;
// 🎯 INSTRUCCIÓN DINÁMICA: Le ponemos la camisa de fuerza a la IA // 🕐 CONVERTIMOS LA HORA PENDIENTE EN TRAMO DE 1 HORA
let directivaEstricta = ""; let tramoPendiente = datosExpediente.cita_pendiente_hora || "";
if (tramoPendiente && tramoPendiente.includes(":")) {
if (esUrgencia) { let [h, m] = tramoPendiente.split(':');
directivaEstricta = ` let hEnd = String((parseInt(h) + 1) % 24).padStart(2, '0');
🛑 ESTADO ACTUAL: SERVICIO DE URGENCIA. tramoPendiente = `entre las ${h}:${m} y las ${hEnd}:${m} aprox`;
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.
`; // 🕐 CONVERTIMOS LA HORA CONFIRMADA EN TRAMO DE 1 HORA
} else if (hayCitaPendiente) { let tramoConfirmado = datosExpediente.hora_cita || "";
directivaEstricta = ` if (tramoConfirmado && tramoConfirmado.includes(":")) {
🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN POR EL TÉCNICO. let [h, m] = tramoConfirmado.split(':');
Datos de la propuesta actual: Día ${datosExpediente.cita_pendiente_fecha} a las ${datosExpediente.cita_pendiente_hora}. let hEnd = String((parseInt(h) + 1) % 24).padStart(2, '0');
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. tramoConfirmado = `entre las ${h}:${m} y las ${hEnd}:${m} aprox`;
PROHIBICIÓN ABSOLUTA: No agendes de nuevo. No ofrezcas más huecos. Si el cliente dice "vale", despídete amablemente y fin. } else {
`; tramoConfirmado = 'una hora por confirmar';
} else if (tieneCitaConfirmada) { }
directivaEstricta = `
🛑 ESTADO ACTUAL: CITA 100% CONFIRMADA. let directivaEstricta = "";
Fecha de la cita: ${datosExpediente.cita}. if (esUrgencia) {
TU ÚNICO OBJETIVO: Resolver cualquier duda del cliente y recordarle que su cita es el ${datosExpediente.cita}. 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.`;
PROHIBICIÓN ABSOLUTA: No intentes agendar. No menciones huecos libres. El trabajo ya está programado. } 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 { } else if (tieneCitaConfirmada) {
directivaEstricta = ` 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.`;
🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA. } else {
TU OBJETIVO: Acordar una fecha y hora con el cliente. 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]`;
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]
`;
} }
// 🧠 EL PROMPT MAESTRO
const promptSistema = ` 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 --- --- CONTEXTO BÁSICO ---
- Hoy es: ${fechaHoyTexto}. (Año 2026). - 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 --- --- AGENDA DEL TÉCNICO ASIGNADO ---
${agendaOcupadaTexto} ${agendaOcupadaTexto}
@@ -597,26 +564,26 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
--- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE --- --- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE ---
${directivaEstricta} ${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 --- --- 📝 INSTRUCCIONES PERSONALIZADAS DE LA EMPRESA ---
${instruccionesExtra ? instruccionesExtra : 'No hay reglas extra.'} ${instruccionesExtra ? instruccionesExtra : 'No hay reglas extra.'}
--- REGLAS DE ORO DE COMUNICACIÓN --- --- REGLAS DE ORO DE COMUNICACIÓN ---
1. Máximo 2 frases. Los mensajes de WhatsApp deben ser cortos. 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.
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. 1. Máximo 2 frases. Mensajes cortos.
3. NO TE PRESENTES si ya estáis conversando. 2. Lee el historial. Si el cliente dice "Ok" o "Gracias", despídete y no des la chapa.
${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 + ').' : ''} 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({ const completion = await openai.chat.completions.create({
model: "gpt-4o-mini", model: "gpt-4o-mini",
messages: mensajesParaIA, messages: [{ role: "system", content: promptSistema }, ...historialChat, { role: "user", content: mensajeCliente }],
temperature: 0.1, // Congelado. No queremos que invente, queremos que obedezca. temperature: 0.1,
}); });
return completion.choices[0].message.content; 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 // 🤖 WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS
app.post("/webhook/evolution", async (req, res) => { app.post("/webhook/evolution", async (req, res) => {
try { try {
@@ -3095,6 +3061,7 @@ app.post("/webhook/evolution", async (req, res) => {
const cleanPhone = telefonoCliente.slice(-9); 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)
// 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE (Ignorando finalizados/anulados)
const svcQ = await pool.query(` const svcQ = await pool.query(`
SELECT s.id, s.service_ref, s.assigned_to, u.full_name as worker_name, s.is_urgent, SELECT s.id, s.service_ref, s.assigned_to, u.full_name as worker_name, s.is_urgent,
st.name as status_name, st.name as status_name,
@@ -3110,17 +3077,18 @@ app.post("/webhook/evolution", async (req, res) => {
WHERE s.owner_id = $1 WHERE s.owner_id = $1
AND s.status != 'archived' AND s.status != 'archived'
AND s.raw_data::text ILIKE $2 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%')) 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 ORDER BY s.created_at DESC LIMIT 1
`, [ownerId, `%${cleanPhone}%`]); `, [ownerId, `%${cleanPhone}%`]);
if (svcQ.rowCount > 0) { if (svcQ.rowCount > 0) {
const service = svcQ.rows[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); candadosIA.add(service.id);
try { try {
@@ -3136,7 +3104,7 @@ app.post("/webhook/evolution", async (req, res) => {
if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 120) return; 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, { const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
dbId: service.id, dbId: service.id,
ref: service.service_ref, ref: service.service_ref,
@@ -3144,7 +3112,7 @@ app.post("/webhook/evolution", async (req, res) => {
operario: service.worker_name, operario: service.worker_name,
worker_id: service.assigned_to, worker_id: service.assigned_to,
cita: service.cita, 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 || "", poblacion: service.poblacion || "",
is_urgent: service.is_urgent, is_urgent: service.is_urgent,
appointment_status: service.appointment_status, appointment_status: service.appointment_status,