Files
fulgenciochat-v3-glm-versio…/fulgenciochat-v3-glm-version-final.json
T

1132 lines
144 KiB
JSON

{
"updatedAt": "2026-06-27T17:39:03.097Z",
"createdAt": "2026-06-24T18:44:11.355Z",
"id": "KRF6cPYGgmYT68rF",
"name": "FulgencioChat V3 - GLM - Versión Final",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"respondWith": "json",
"responseBody": "{\n \"text\": \"⏳ Procesando tu solicitud, dame un momento...\"\n}",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
-512,
864
],
"id": "0f2904c5-1488-4c75-97b2-64b4cb2bd911",
"name": "Respond to Webhook"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const webhookData = $json || {};\n\n// En algunos casos Google Chat llega dentro de body.\n// En otros, puede venir directo en el objeto principal.\nconst body = webhookData.body || webhookData;\n\nconst message = body.message || {};\nconst user = body.user || message.sender || {};\nconst space = body.space || message.space || {};\nconst thread = body.thread || message.thread || {};\n\nconst texto =\n message.argumentText ||\n message.text ||\n body.text ||\n '';\n\nconst pregunta = String(texto || '')\n .replace(/^@\\S+\\s*/, '')\n .trim();\n\nreturn {\n json: {\n pregunta,\n usuario_nombre: user.displayName || message.sender?.displayName || '',\n usuario_email: user.email || message.sender?.email || '',\n space_name: space.name || message.space?.name || '',\n thread_name: thread.name || message.thread?.name || '',\n message_name: message.name || '',\n event_type: body.type || '',\n fecha_evento: body.eventTime || new Date().toISOString()\n }\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-320,
864
],
"id": "85040629-41da-4ccb-b4cc-f4d7b15d9e7d",
"name": "Code - Preparar mensaje"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng",
"mode": "list",
"cachedResultName": "BANCO DE PROPUESTAS DE CDC PARA FULGENCIO FUMADO",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": "gid=0",
"mode": "list",
"cachedResultName": "propuestas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng/edit#gid=0"
},
"filtersUI": {
"values": []
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
80,
864
],
"id": "f7cea213-1a5a-476a-8f87-3bc3b04ab1b2",
"name": "Sheets - Leer propuestas",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst MAX_CANDIDATOS_DEFAULT = 25;\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction norm(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nfunction getFirstValue(row, keys = []) {\n for (const key of keys) {\n const value = clean(row?.[key]);\n if (value) return value;\n }\n return '';\n}\n\nfunction getEnlacesEjecutadas(row) {\n return getFirstValue(row, [\n 'Enlaces a propuestas ejecutadas',\n 'Enlaces propuestas ejecutadas',\n 'Enlace a propuestas ejecutadas',\n 'Enlace propuesta ejecutada',\n 'Propuestas ejecutadas',\n 'Enlaces a ejecuciones',\n 'Ejecuciones',\n ]);\n}\n\nconst preguntaNorm = norm(pregunta);\n\nfunction detectarIntencionEspecial(texto) {\n const t = norm(texto);\n\n const esSaludoSimple = [\n // Español\n 'HOLA',\n 'BUENAS',\n 'BUENOS DIAS',\n 'BUENOS DÍAS',\n 'BUEN DIA',\n 'BUEN DÍA',\n 'BUENAS TARDES',\n 'BUENAS NOCHES',\n 'SALUDOS',\n 'HEY',\n\n // Inglés\n 'HI',\n 'HELLO',\n 'HEY THERE',\n 'GOOD MORNING',\n 'GOOD AFTERNOON',\n 'GOOD EVENING',\n\n // Genéricos / cortesía\n 'OK',\n 'OKAY',\n 'OKEY',\n 'LISTO',\n 'DALE',\n 'PERFECTO',\n 'GRACIAS',\n 'THANKS',\n 'THANK YOU',\n 'ENTENDIDO',\n 'VALE'\n ].includes(t);\n\n if (esSaludoSimple) {\n return {\n tipo: 'saludo_ayuda',\n prompt: `\nEl usuario solo saludó o envió un mensaje muy corto.\n\nResponde de forma breve, cercana y útil. No busques propuestas, briefs ni ejecuciones.\n\nDebes decir que eres Fulgencio Fumado, asistente interno para consultar propuestas, briefs, propuestas ejecutadas, propuestas externas y recursos creativos de GomezLee Marketing.\n\nIncluye ejemplos cortos de lo que puede preguntar:\n1. Dame las 5 últimas propuestas de Motorola.\n2. Busca propuestas de Nestlé en República Dominicana.\n3. Dame propuestas aprobadas de activación en PDV.\n4. Dame los últimos briefs creados.\n5. Busca briefs relacionados con Motorola.\n6. Envíame la presentación de la propuesta ejecutada de Pollo Campero.\n7. Dame propuestas externas registradas.\n8. Necesito crear un brief.\n9. Necesito acceder al Tablero CDC.\n\nNo incluyas propuestas específicas, briefs específicos, ejecuciones específicas ni enlaces de archivos.\nSolo incluye estos enlaces generales si aplica:\n- Crear brief: https://digitalcompass.agency/brief\n- Tablero CDC: https://digitalcompass.agency/tablero-cdc/\n`\n };\n }\n\n // IMPORTANTE:\n // No tratamos cualquier mención de \"brief\" como intención de crear un brief,\n // porque Fulgencio también puede buscar briefs existentes en el Sheet de CDC Briefs.\n const pideCrearBrief =\n t.includes('CREAR BRIEF') ||\n t.includes('CREAR UN BRIEF') ||\n t.includes('CREAR UNA SOLICITUD') ||\n t.includes('CREAR SOLICITUD') ||\n t.includes('LLENAR BRIEF') ||\n t.includes('LLENAR UN BRIEF') ||\n t.includes('HACER BRIEF') ||\n t.includes('HACER UN BRIEF') ||\n t.includes('NUEVO BRIEF') ||\n t.includes('NUEVA SOLICITUD') ||\n t.includes('FORMULARIO DE BRIEF') ||\n t.includes('FORMULARIO PARA BRIEF') ||\n t.includes('SOLICITUD CREATIVA');\n\n const pideImagen =\n t.includes('CREAR IMAGEN') ||\n t.includes('GENERAR IMAGEN') ||\n t.includes('HAZME UNA IMAGEN') ||\n t.includes('DISEÑA UNA IMAGEN') ||\n t.includes('DISENA UNA IMAGEN') ||\n t.includes('IMAGEN NUEVA') ||\n t.includes('ARTE NUEVO');\n\n const pideAyuda =\n t.includes('QUE PUEDES HACER') ||\n t.includes('QUÉ PUEDES HACER') ||\n t.includes('AYUDA') ||\n t.includes('COMO TE USO') ||\n t.includes('CÓMO TE USO') ||\n t.includes('COMO FUNCIONAS') ||\n t.includes('CÓMO FUNCIONAS') ||\n t.includes('COMO FUNCIONA') ||\n t.includes('CÓMO FUNCIONA');\n\n if (pideCrearBrief) {\n return {\n tipo: 'brief',\n prompt: `\nEl usuario necesita crear o llenar un brief.\n\nResponde de forma breve indicando que puede crear la solicitud en este enlace:\nhttps://digitalcompass.agency/brief\n\nNo busques propuestas. No inventes información adicional.\n`\n };\n }\n\n if (pideImagen) {\n return {\n tipo: 'imagen_no_disponible',\n prompt: `\nEl usuario está pidiendo crear o generar una imagen.\n\nResponde claramente que la creación de imágenes no está disponible actualmente en Fulgencio.\nLuego ofrece ayudarle a buscar referencias, propuestas, campañas, briefs, propuestas ejecutadas, propuestas externas o ideas dentro de las fuentes disponibles.\n`\n };\n }\n\n if (pideAyuda) {\n return {\n tipo: 'ayuda',\n prompt: `\nEl usuario está pidiendo ayuda sobre cómo usar Fulgencio.\n\nExplica brevemente que Fulgencio puede ayudar a buscar información en:\n- banco de propuestas\n- briefs o solicitudes creativas de CDC Brief\n- propuestas ejecutadas\n- propuestas externas\n- evidencias y presentaciones de ejecución\n\nPuede buscar por:\n- cliente\n- marca\n- país\n- año\n- tipo de acción\n- estado de aprobación\n- canal\n- etiquetas o temas\n- brief ID\n- solicitante\n- tema del brief\n- nombre de propuesta ejecutada\n- presentación de ejecución\n- propuesta externa\n\nDa 8 ejemplos de preguntas útiles:\n1. Dame las 5 últimas propuestas de Motorola.\n2. Busca propuestas de Nestlé en República Dominicana.\n3. Dame propuestas aprobadas de activación en PDV.\n4. Tienes propuestas de Kit Kat en Costa Rica?\n5. Dame los últimos briefs creados.\n6. Busca briefs relacionados con Motorola.\n7. Envíame la presentación de la propuesta ejecutada de Pollo Campero Guatemala.\n8. Dame propuestas externas registradas.\n`\n };\n }\n\n return null;\n}\n\nfunction extraerCantidad(texto) {\n const t = norm(texto);\n\n const numero = t.match(/\\b(\\d{1,2})\\b/);\n if (numero) return Number(numero[1]);\n\n const palabras = {\n UNA: 1,\n UNO: 1,\n DOS: 2,\n TRES: 3,\n CUATRO: 4,\n CINCO: 5,\n SEIS: 6,\n SIETE: 7,\n OCHO: 8,\n NUEVE: 9,\n DIEZ: 10,\n QUINCE: 15,\n VEINTE: 20,\n };\n\n for (const [palabra, valor] of Object.entries(palabras)) {\n if (t.includes(palabra)) return valor;\n }\n\n return null;\n}\n\nfunction detectarUltimas(texto) {\n const t = norm(texto);\n\n return [\n 'ULTIMA',\n 'ULTIMAS',\n 'ULTIMO',\n 'ULTIMOS',\n 'RECIENTE',\n 'RECIENTES',\n 'NUEVA',\n 'NUEVAS',\n 'NUEVO',\n 'NUEVOS',\n ].some(x => t.includes(x));\n}\n\nfunction detectarSeguimiento(texto) {\n const t = norm(texto);\n\n return [\n 'DE ESAS',\n 'DE ESTAS',\n 'DE LOS ANTERIORES',\n 'DE LAS ANTERIORES',\n 'LA ANTERIOR',\n 'EL ANTERIOR',\n 'LA PRIMERA',\n 'LA SEGUNDA',\n 'LA TERCERA',\n 'ESAS',\n 'ESTAS',\n 'ESOS',\n 'ESTOS',\n 'LOS QUE ME MOSTRASTE',\n 'LAS QUE ME MOSTRASTE',\n ].some(x => t.includes(x));\n}\n\nfunction detectarEstado(texto) {\n const t = norm(texto);\n\n if (t.includes('NO APROBADA') || t.includes('NO APROBADAS')) {\n return 'NO APROBADA';\n }\n\n if (t.includes('APROBADA') || t.includes('APROBADAS')) {\n return 'APROBADA';\n }\n\n if (\n t.includes('PENDIENTE') ||\n t.includes('PENDIENTES') ||\n t.includes('PENDIENTE DE APROBACION') ||\n t.includes('PENDIENTE DE APROBACIÓN')\n ) {\n return 'PENDIENTE DE APROBACION';\n }\n\n return '';\n}\n\nfunction detectarAnios(texto) {\n const matches = norm(texto).match(/\\b(20\\d{2}|19\\d{2})\\b/g);\n return matches ? [...new Set(matches)] : [];\n}\n\nfunction detectarPaises(texto) {\n const t = norm(texto);\n\n const paises = [\n 'EL SALVADOR',\n 'PANAMA',\n 'REPUBLICA DOMINICANA',\n 'COLOMBIA',\n 'PUERTO RICO',\n 'HONDURAS',\n 'MEXICO',\n 'VENEZUELA',\n 'JAMAICA',\n 'TRINIDAD Y TOBAGO',\n 'COSTA RICA',\n 'NICARAGUA',\n 'GUATEMALA',\n ];\n\n const encontrados = [];\n\n for (const pais of paises) {\n if (t.includes(norm(pais))) {\n encontrados.push(pais);\n }\n }\n\n if (/\\bRD\\b/.test(t) || t.includes('REP DOM') || t.includes('DOMINICANA')) {\n if (!encontrados.includes('REPUBLICA DOMINICANA')) {\n encontrados.push('REPUBLICA DOMINICANA');\n }\n }\n\n return encontrados;\n}\n\nfunction detectarCanales(texto) {\n const t = norm(texto);\n\n const canales = [];\n\n if (t.includes('MODERNO')) canales.push('MODERNO');\n if (t.includes('TRADICIONAL')) canales.push('TRADICIONAL');\n if (t.includes('ONLINE') || t.includes('ON LINE')) canales.push('ONLINE');\n if (t.includes('PDV') || t.includes('PUNTO DE VENTA')) canales.push('PDV');\n if (t.includes('NO APLICA')) canales.push('NO APLICA');\n\n return canales;\n}\n\nfunction detectarInteresPropuestas(texto) {\n const t = norm(texto);\n\n return [\n 'PROPUESTA',\n 'PROPUESTAS',\n 'BANCO DE PROPUESTAS',\n 'CASO',\n 'CASOS',\n 'ACTIVACION',\n 'ACTIVACIONES',\n 'ACTIVACIÓN',\n 'CAMPAÑA',\n 'CAMPAÑAS',\n 'CAMPANA',\n 'CAMPANAS',\n 'PRESENTACION',\n 'PRESENTACIONES',\n 'PRESENTACIÓN',\n ].some(x => t.includes(norm(x)));\n}\n\nconst intencionEspecial = detectarIntencionEspecial(pregunta);\n\nif (intencionEspecial) {\n return [\n {\n json: {\n source_type: 'propuestas',\n pregunta,\n intencion_especial: intencionEspecial.tipo,\n tokens: [],\n cantidad_solicitada: null,\n quiere_ultimas: false,\n es_seguimiento: false,\n estado_solicitado: '',\n paises_solicitados: [],\n anios_solicitados: [],\n canales_solicitados: [],\n total_propuestas: rows.length,\n total_candidatos: 0,\n sin_resultados: false,\n candidatos: [],\n contexto_propuestas: '',\n prompt_usuario: intencionEspecial.prompt\n }\n }\n ];\n}\n\nconst cantidadSolicitada = extraerCantidad(pregunta);\nconst quiereUltimas = detectarUltimas(pregunta);\nconst esSeguimiento = detectarSeguimiento(pregunta);\nconst estadoSolicitado = detectarEstado(pregunta);\nconst aniosSolicitados = detectarAnios(pregunta);\nconst paisesSolicitados = detectarPaises(pregunta);\nconst canalesSolicitados = detectarCanales(pregunta);\nconst mencionaPropuestas = detectarInteresPropuestas(pregunta);\n\nconst stopwords = new Set([\n 'DAME', 'DARME', 'MUESTRA', 'MUESTRAME', 'BUSCA', 'BUSCAME',\n 'ENVIAME', 'ENVÍAME', 'QUIERO', 'NECESITO',\n 'PROPUESTA', 'PROPUESTAS', 'IDEA', 'IDEAS', 'DOCUMENTO', 'DOCUMENTOS',\n 'ULTIMA', 'ULTIMAS', 'ULTIMO', 'ULTIMOS', 'RECIENTE', 'RECIENTES',\n 'NUEVA', 'NUEVAS', 'NUEVO', 'NUEVOS',\n 'APROBADA', 'APROBADAS', 'PENDIENTE', 'PENDIENTES',\n 'PRESENTACION', 'PRESENTACIONES', 'PRESENTACIÓN',\n 'EJECUTADA', 'EJECUTADAS', 'EJECUCION', 'EJECUCIONES',\n 'EVIDENCIA', 'EVIDENCIAS',\n 'LINK', 'ENLACE',\n 'DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'PARA', 'POR', 'CON',\n 'EN', 'UN', 'UNA', 'Y', 'O', 'QUE', 'ME', 'TENGO', 'TIENES',\n 'ALGUNA', 'ALGUNAS', 'ALGUN', 'SOBRE', 'RELACIONADA', 'RELACIONADAS',\n 'ESAS', 'ESTAS', 'ESOS', 'ESTOS',\n]);\n\nconst tokens = preguntaNorm\n .split(/[^A-Z0-9Ñ]+/)\n .map(t => t.trim())\n .filter(t => t.length >= 3 && !stopwords.has(t))\n .filter(t => !aniosSolicitados.includes(t));\n\nfunction getYear(value) {\n const year = clean(value).match(/\\b(20\\d{2}|19\\d{2})\\b/);\n return year ? Number(year[1]) : 0;\n}\n\nfunction fieldContains(value, expected) {\n return norm(value).includes(norm(expected));\n}\n\nfunction matchesAny(value, expectedList) {\n if (!expectedList.length) return true;\n return expectedList.some(expected => fieldContains(value, expected));\n}\n\nfunction hardFilterPass(p) {\n if (paisesSolicitados.length && !matchesAny(p.PAIS, paisesSolicitados)) {\n return false;\n }\n\n if (aniosSolicitados.length && !aniosSolicitados.includes(String(p.ANIO))) {\n return false;\n }\n\n if (estadoSolicitado && norm(p.APROBADA) !== norm(estadoSolicitado)) {\n return false;\n }\n\n if (canalesSolicitados.length && !matchesAny(p.CANAL, canalesSolicitados)) {\n return false;\n }\n\n return true;\n}\n\nfunction scoreRow(row) {\n const enlacesEjecutadas = getEnlacesEjecutadas(row);\n\n const camposGenerales = [\n row.NOMBRE,\n row.CLIENTE,\n row.MARCA,\n row.PAIS,\n row['TIPO DE ACCION'],\n row.CANAL,\n row['AMBIENTE DE COMPRA (RE)'],\n row['TÁCTICA PROMOCIONAL'],\n row.APROBADA,\n row.ETIQUETAS,\n row['AÑO'],\n row.Descripcion,\n row.nombre_archivo,\n row['Enlace a la propuesta'],\n enlacesEjecutadas,\n ];\n\n const camposFuertes = [\n row.NOMBRE,\n row.CLIENTE,\n row.MARCA,\n row.PAIS,\n row['TIPO DE ACCION'],\n row.ETIQUETAS,\n row['AÑO'],\n ];\n\n const textoGeneral = norm(camposGenerales.join(' '));\n const textoFuerte = norm(camposFuertes.join(' '));\n\n let score = 0;\n\n for (const token of tokens) {\n if (textoGeneral.includes(token)) score += 1;\n if (textoFuerte.includes(token)) score += 2;\n }\n\n if (paisesSolicitados.length && matchesAny(row.PAIS, paisesSolicitados)) {\n score += 8;\n }\n\n if (aniosSolicitados.length && aniosSolicitados.includes(clean(row['AÑO']))) {\n score += 8;\n }\n\n if (estadoSolicitado && norm(row.APROBADA) === norm(estadoSolicitado)) {\n score += 8;\n }\n\n if (canalesSolicitados.length && matchesAny(row.CANAL, canalesSolicitados)) {\n score += 5;\n }\n\n if (enlacesEjecutadas) {\n score += 3;\n }\n\n return score;\n}\n\nconst propuestas = rows\n .map((item, index) => {\n const row = item.json || {};\n\n const fila =\n row.row_number ||\n row.__row_number ||\n row.__rowNumber ||\n row.rowNumber ||\n index + 2;\n\n const anio = clean(row['AÑO']);\n const enlacesEjecutadas = getEnlacesEjecutadas(row);\n\n return {\n fila,\n score: scoreRow(row),\n\n NOMBRE: clean(row.NOMBRE),\n CLIENTE: clean(row.CLIENTE),\n MARCA: clean(row.MARCA),\n PAIS: clean(row.PAIS),\n TIPO_DE_ACCION: clean(row['TIPO DE ACCION']),\n CANAL: clean(row.CANAL),\n AMBIENTE_DE_COMPRA: clean(row['AMBIENTE DE COMPRA (RE)']),\n TACTICA_PROMOCIONAL: clean(row['TÁCTICA PROMOCIONAL']),\n APROBADA: clean(row.APROBADA),\n ETIQUETAS: clean(row.ETIQUETAS),\n ANIO: anio,\n ANIO_NUM: getYear(anio),\n ENLACE: clean(row['Enlace a la propuesta']),\n ENLACES_EJECUTADAS: enlacesEjecutadas,\n DESCRIPCION: clean(row.Descripcion),\n };\n })\n .filter(p => p.NOMBRE || p.DESCRIPCION || p.ENLACE || p.ENLACES_EJECUTADAS);\n\nlet candidatos = propuestas\n .filter(p => hardFilterPass(p))\n .filter(p => {\n if (tokens.length > 0) return p.score > 0;\n\n if (\n paisesSolicitados.length ||\n aniosSolicitados.length ||\n estadoSolicitado ||\n canalesSolicitados.length\n ) {\n return true;\n }\n\n if (mencionaPropuestas) {\n return true;\n }\n\n return false;\n });\n\nif (candidatos.length === 0 && tokens.length === 0 && !esSeguimiento) {\n candidatos = [];\n}\n\nif (quiereUltimas) {\n candidatos = candidatos.sort((a, b) => b.fila - a.fila);\n} else {\n candidatos = candidatos.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n return b.fila - a.fila;\n });\n}\n\nconst limiteCandidatos = cantidadSolicitada\n ? Math.min(Math.max(cantidadSolicitada, 1), 25)\n : MAX_CANDIDATOS_DEFAULT;\n\ncandidatos = candidatos.slice(0, limiteCandidatos);\n\nconst contexto = candidatos.map((p, index) => {\n return [\n `RESULTADO ${index + 1}`,\n `Fila: ${p.fila}`,\n `Nombre: ${p.NOMBRE}`,\n `Cliente: ${p.CLIENTE}`,\n `Marca: ${p.MARCA}`,\n `País: ${p.PAIS}`,\n `Tipo de acción: ${p.TIPO_DE_ACCION}`,\n `Canal: ${p.CANAL}`,\n `Ambiente de compra: ${p.AMBIENTE_DE_COMPRA}`,\n `Táctica promocional: ${p.TACTICA_PROMOCIONAL}`,\n `Estado aprobación: ${p.APROBADA}`,\n `Etiquetas: ${p.ETIQUETAS}`,\n `Año: ${p.ANIO}`,\n `Descripción: ${p.DESCRIPCION}`,\n `Enlace: ${p.ENLACE}`,\n `Enlaces propuestas ejecutadas: ${p.ENLACES_EJECUTADAS}`,\n ].join('\\n');\n}).join('\\n\\n---\\n\\n');\n\nconst sinResultados = candidatos.length === 0;\n\nconst promptUsuario = `\nPregunta del usuario:\n${pregunta}\n\nInstrucción de búsqueda detectada:\n- Cantidad solicitada: ${cantidadSolicitada || 'No especificada'}\n- Quiere últimas/recientes: ${quiereUltimas ? 'Sí' : 'No'}\n- Es pregunta de seguimiento: ${esSeguimiento ? 'Sí' : 'No'}\n- Estado solicitado: ${estadoSolicitado || 'No especificado'}\n- Países solicitados: ${paisesSolicitados.length ? paisesSolicitados.join(', ') : 'No especificado'}\n- Años solicitados: ${aniosSolicitados.length ? aniosSolicitados.join(', ') : 'No especificado'}\n- Canales solicitados: ${canalesSolicitados.length ? canalesSolicitados.join(', ') : 'No especificado'}\n\nResultado de búsqueda:\n${sinResultados ? 'No se encontraron propuestas candidatas con los filtros detectados.' : 'Se encontraron propuestas candidatas.'}\n\nContexto encontrado en el banco de propuestas:\n${contexto || 'No se encontraron propuestas candidatas con la búsqueda.'}\n\nInstrucción para responder:\n${sinResultados\n ? 'Indica que no encontraste coincidencias. No inventes propuestas. Sugiere probar con otro cliente, marca, país, año, canal o tipo de acción.'\n : 'Responde usando únicamente las propuestas del contexto.'}\n`;\n\nreturn [\n {\n json: {\n source_type: 'propuestas',\n pregunta,\n tokens,\n cantidad_solicitada: cantidadSolicitada,\n quiere_ultimas: quiereUltimas,\n es_seguimiento: esSeguimiento,\n estado_solicitado: estadoSolicitado,\n paises_solicitados: paisesSolicitados,\n anios_solicitados: aniosSolicitados,\n canales_solicitados: canalesSolicitados,\n total_propuestas: propuestas.length,\n total_candidatos: candidatos.length,\n sin_resultados: sinResultados,\n intencion_especial: '',\n candidatos,\n contexto_propuestas: contexto,\n prompt_usuario: promptUsuario,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
304,
864
],
"id": "cd454ec5-035b-43ce-bb92-9a9e135ffbd3",
"name": "Code - Filtrar propuestas relevantes"
},
{
"parameters": {
"promptType": "define",
"text": "={{ $json.prompt_usuario }}",
"options": {
"systemMessage": "=Eres Fulgencio Fumado, asistente interno de creatividad, briefs y propuestas de GomezLee Marketing.\n\nTu función es responder preguntas usando únicamente el contexto entregado por el sistema.\n\nActualmente puedes recibir contexto de tres fuentes:\n\n1. Banco de propuestas:\nContiene propuestas originales, presentaciones base, activaciones, casos, campañas y referencias internas de GomezLee Marketing.\n\n2. CDC Briefs:\nContiene briefs, solicitudes creativas y requerimientos generados desde la app CDC Brief.\n\n3. Propuestas Ejecutadas / Externas:\nContiene reportes reales de ejecución, evidencias fotográficas, videos opcionales, presentaciones de ejecución generadas, propuestas externas recibidas y relaciones con propuestas del banco original cuando exista match.\n\nNo confundas ambas fuentes:\n- Una propuesta es un caso, presentación, activación o referencia interna del banco de propuestas.\n- Un brief es una solicitud o requerimiento creativo.\n- Si el usuario pregunta por propuestas, casos, activaciones, presentaciones o campañas, prioriza el banco de propuestas.\n- Si el usuario pregunta por briefs, solicitudes, requerimientos creativos, pedidos o temas registrados en CDC Brief, prioriza CDC Briefs.\n- Si hay información útil en ambas fuentes, separa la respuesta por secciones:\n Banco de propuestas\n CDC Briefs\n\nReglas obligatorias:\n1. No inventes propuestas, briefs, enlaces, países, clientes, marcas, años, estados, fechas ni solicitantes.\n2. Si no hay información suficiente en el contexto, dilo claramente.\n3. No digas que puedes crear imágenes. La creación de imágenes no está disponible actualmente en Fulgencio.\n4. No menciones n8n, workflows, Google Sheets, nodos, prompts, memoria técnica ni procesos internos.\n5. Responde en español, con tono profesional, cercano, directo y útil. Evita sonar demasiado formal o robótico.\n6. Cuando incluyas enlaces, ponlos completos.\n7. Si algún dato aparece como PENDIENTE, vacío o no disponible, no lo inventes; indica que ese dato está pendiente o no disponible.\n8. Usa únicamente la información entregada en el contexto o en la memoria reciente cuando sea una pregunta de seguimiento.\n9. No prometas enviar archivos, crear documentos, generar imágenes, modificar propuestas, actualizar información o hacer cambios en sistemas. Solo puedes consultar, resumir, comparar y sugerir usando el contexto disponible.\n10. Si el usuario combina varios filtros y hay pocos o ningún resultado, explica qué filtros se aplicaron y sugiere quitar o cambiar algún filtro.\n\nReglas para propuestas:\n- Si el usuario pide propuestas, entrega una lista clara con:\n - Nombre\n - Cliente/Marca\n - País\n - Año si está disponible\n - Tipo de acción si está disponible\n - Breve descripción\n - Enlace\n- Si una propuesta tiene enlace disponible, inclúyelo siempre.\n- Si no tiene enlace, indica “Enlace no disponible”.\n- Si el usuario pide una cantidad específica, respeta esa cantidad cuando el contexto la tenga disponible.\n- Si el usuario pide las últimas o más recientes, respeta el orden entregado en el contexto.\n- Cuando la instrucción de búsqueda detectada indique país, año, canal, estado o cantidad solicitada, respeta esos filtros estrictamente.\n- Si no hay candidatos de propuestas en el contexto, no inventes resultados. Indica que no encontraste coincidencias y sugiere ajustar la búsqueda por cliente, marca, país, año, canal, estado o tipo de acción.\n\nReglas para CDC Briefs:\n- Si el usuario pide briefs, solicitudes, requerimientos o pedidos creativos existentes, responde usando la sección CDC Briefs del contexto.\n- Cuando respondas sobre briefs, incluye cuando esté disponible:\n - Brief ID\n - Fecha de creación\n - Solicitante\n - Correo\n - Estado\n - Fecha de entrega\n - Resumen\n - Link del documento brief\n - Link de carpeta Drive\n- No trates un brief como si fuera una propuesta.\n- No trates una propuesta como si fuera un brief.\n- Si el usuario pide los últimos briefs o briefs recientes, respeta el orden entregado en el contexto.\n- Si no hay briefs candidatos en el contexto, indica que no encontraste briefs relacionados y sugiere buscar por brief ID, solicitante, tema, estado o fecha.\n- Si el usuario pide crear un brief, llenar un brief, hacer una nueva solicitud o abrir el formulario de brief, comparte este enlace:\nhttps://digitalcompass.agency/brief\n- Si el usuario solo pregunta por briefs existentes, no respondas únicamente con el enlace del formulario. Busca y responde con los briefs del contexto.\n\nReglas para Propuestas Ejecutadas / Externas:\n- Si el usuario pregunta por propuesta ejecutada, ejecución, evidencias, presentación ejecutada, presentación de ejecución, fotos, videos, implementación o propuesta externa, prioriza la fuente Propuestas Ejecutadas / Externas.\n- Si el usuario pide “Envíame la presentación de la propuesta ejecutada de X”, entrega el link de Presentación ejecución cuando esté disponible.\n- Si existe Propuesta original link, inclúyelo como referencia adicional.\n- Si existe Carpeta evidencias, inclúyela cuando el usuario pida evidencias, fotos, videos o soporte de la ejecución.\n- Si la ejecución tiene Resumen IA o Descripción ejecución, úsalo para dar un resumen claro.\n- Si hay varias ejecuciones relacionadas, muéstralas en lista y no elijas una sola sin aclararlo.\n- Si el registro es PROPUESTA_EXTERNA, aclara que es una propuesta externa registrada, no una propuesta original del banco.\n- Si el match es bajo, manual, pendiente o no determinado, no afirmes que corresponde definitivamente a una propuesta original.\n- No inventes presentaciones ejecutadas ni enlaces de evidencia.\n\nFormato recomendado:\n- Resumen breve al inicio.\n- Lista numerada cuando aplique.\n- Si usas ambas fuentes, separa en secciones:\n Banco de propuestas\n CDC Briefs\n- Cierre breve ofreciendo filtrar por país, marca, cliente, año, estado, tipo de acción, brief ID, solicitante o tema.\n\nFormato especial:\n- Si el usuario pide “tabla”, “formato tabla” o “en una tabla”, responde directamente en tabla Markdown.\n- Para propuestas en tabla, usa columnas útiles como:\n Nombre, Cliente/Marca, País, Año, Tipo de acción y Enlace.\n- Para briefs en tabla, usa columnas útiles como:\n Brief ID, Fecha, Solicitante, Estado, Fecha de entrega, Resumen y Enlace.\n- Si el usuario pide bullets, responde en bullets.\n\nAnálisis, patrones e ideas:\n- Si el usuario pide análisis, patrones, insights o ideas creativas, analiza únicamente la información entregada en el contexto.\n- Resume patrones repetidos, tipos de activación, mecánicas, formatos, canales, oportunidades y temas relevantes sin inventar datos fuera del contexto.\n- Si el usuario pide ideas nuevas, usa el contexto como referencia.\n- Aclara que son ideas inspiradas en propuestas o briefs existentes y no propuestas ya aprobadas, salvo que el contexto indique lo contrario.\n\nComparaciones:\n- Si el usuario pide comparar propuestas, organiza la respuesta por diferencias y similitudes:\n - Tipo de acción\n - País\n - Marca\n - Canal\n - Objetivo\n - Elementos creativos\n- Si el usuario pide comparar briefs, organiza la respuesta por:\n - Solicitante\n - Estado\n - Fecha de entrega\n - Objetivo o tema\n - Materiales o insumos disponibles\n - Diferencias principales\n- Usa solo el contexto disponible.\n\nSeguimiento y memoria:\n- Si el usuario dice “de esas”, “la segunda”, “la anterior”, “dame los enlaces”, “resume esas”, “de esos briefs” o “las que me mostraste”, usa la memoria reciente para entender la referencia.\n- Responde solo con información que aparezca en el contexto o memoria entregada.\n- No mezcles resultados nuevos con resultados de memoria si el usuario claramente se refiere a resultados ya mostrados.\n- Si no puedes identificar a qué se refiere, pide una aclaración breve.\n\nMensajes ambiguos:\n\n* Si el usuario envía un saludo, cortesía, confirmación o mensaje muy corto sin una intención clara de búsqueda, no muestres propuestas ni briefs específicos aunque exista contexto disponible.\n* Esto aplica a mensajes como “hola”, “hi”, “hello”, “buenas”, “hey”, “ok”, “gracias”, “thanks”, “listo”, “perfecto”, “dale”, “saludos” o mensajes similares.\n* En esos casos, responde con una bienvenida breve, explica cómo puede usar Fulgencio y da ejemplos de preguntas útiles.\n* No digas que revisaste el banco de propuestas ni CDC Briefs si el usuario solo saludó o envió una cortesía.\n* No incluyas propuestas, briefs, tablas, enlaces de archivos ni resultados específicos en respuestas a saludos o mensajes genéricos.\n\nEjemplos de preguntas útiles:\n\n1. Dame las 5 últimas propuestas de Motorola.\n2. Busca propuestas de Nestlé en República Dominicana.\n3. Dame propuestas aprobadas de activación en PDV.\n4. Tienes propuestas de Kit Kat en Costa Rica?\n5. Dame los últimos briefs creados.\n6. Busca briefs relacionados con Motorola.\n7. Necesito crear un brief.\n8. Necesito acceder al Tablero CDC.\n9. ¿Dónde registro un proyecto creativo?\n\n\nImágenes:\n- Si el usuario solicita crear o generar imágenes, indica que la creación de imágenes no está disponible actualmente en Fulgencio.\n- Ofrece buscar referencias, propuestas, briefs o ideas existentes dentro del contexto disponible.\n\nBrief nuevo:\n- Si el usuario pide crear un brief, llenar un brief, hacer una solicitud creativa o abrir el formulario, comparte este enlace:\nhttps://digitalcompass.agency/brief\n\nTablero CDC / Aprobaciones Proyectos:\n- Si el usuario pide acceder al Tablero CDC, abrir Aprobaciones Proyectos, entrar al tablero de proyectos o registrar un proyecto creativo, comparte este enlace:\nhttps://digitalcompass.agency/tablero-cdc/\n- Indica brevemente que corresponde al Tablero CDC / Aprobaciones Proyectos.\n- No afirmes que puedes crear, modificar, aprobar, eliminar o consultar proyectos dentro del Tablero CDC desde el chat.\n- No confundas el Tablero CDC con CDC Brief ni con el Banco de Propuestas.\n\nRestricción final:\nSi el contexto indica que no se encontraron candidatos, no inventes resultados. Responde que no encontraste coincidencias y sugiere ajustar la búsqueda según la fuente:\n- Para propuestas: cliente, marca, país, año, canal, estado o tipo de acción.\n- Para briefs: brief ID, solicitante, estado, fecha, tema o palabras clave del brief."
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 2,
"position": [
1104,
848
],
"id": "e8fa7895-75d2-4f0b-b0bb-7214ff424c9e",
"name": "AI Agent - Gemini Fulgencio"
},
{
"parameters": {
"modelName": "models/gemini-3.1-pro-preview",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"typeVersion": 1,
"position": [
1104,
1072
],
"id": "af32c118-9f2f-473b-89a6-cd4a3b653be6",
"name": "Google Gemini Chat Model",
"credentials": {
"googlePalmApi": {
"id": "jvsXYwL6IOoY2DBU",
"name": "Isaac - Gemini Api Pago"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const data = $json || {};\n\nlet respuesta =\n data.output ||\n data.text ||\n data.response ||\n data.content ||\n data.message ||\n '';\n\nrespuesta = String(respuesta || '').trim();\n\nif (!respuesta) {\n respuesta = 'No pude generar una respuesta con la información disponible.';\n}\n\nfunction limpiarMarkdownParaGoogleChat(texto) {\n return String(texto || '')\n // Links Markdown: [texto](url) -> texto: url\n .replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '$1: $2')\n\n // Negritas Markdown: **texto** -> texto\n .replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n\n // Negritas con __texto__ -> texto\n .replace(/__([^_]+)__/g, '$1')\n\n // Cursivas simples: *texto* -> texto\n .replace(/\\*([^*\\n]+)\\*/g, '$1')\n\n // Títulos Markdown: ### Título -> Título\n .replace(/^#{1,6}\\s+/gm, '')\n\n // Código inline: `texto` -> texto\n .replace(/`([^`]+)`/g, '$1')\n\n // Separadores Markdown\n .replace(/^\\s*---+\\s*$/gm, '')\n\n // Convertir bullets markdown a bullets limpios\n .replace(/^\\s*[-*]\\s+/gm, '• ')\n\n // Limpiar espacios antes de saltos\n .replace(/[ \\t]+\\n/g, '\\n')\n\n // Evitar demasiadas líneas vacías seguidas\n .replace(/\\n{4,}/g, '\\n\\n\\n')\n\n .trim();\n}\n\nrespuesta = limpiarMarkdownParaGoogleChat(respuesta);\n\nconst MAX_CHARS = 7000;\n\nif (respuesta.length > MAX_CHARS) {\n respuesta =\n respuesta.slice(0, MAX_CHARS) +\n '\\n\\nRespuesta recortada por longitud. Puedes pedirme un filtro más específico por cliente, marca, país, año, estado o tipo de acción.';\n}\n\nreturn {\n json: {\n respuesta\n }\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1456,
848
],
"id": "18c1db5a-5290-4901-8a56-100bf7ef9da8",
"name": "Code - Formatear respuesta Chat"
},
{
"parameters": {
"spaceId": "={{ $('Code - Preparar mensaje').first().json.space_name }}",
"messageUi": {
"text": "={{ $json.respuesta }}"
},
"additionalFields": {}
},
"type": "n8n-nodes-base.googleChat",
"typeVersion": 1,
"position": [
1664,
848
],
"id": "8f68c6cb-507b-4ecb-b767-5071ee81a6c9",
"name": "Chat - Enviar respuesta",
"webhookId": "0147a967-96b1-4410-8fc8-b15ccbce7995",
"credentials": {
"googleApi": {
"id": "u5FLl9Ysd4oNofFB",
"name": "Google Chat - Fulgencio Service Account"
}
}
},
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $('Code - Preparar mensaje').first().json.thread_name || ($('Code - Preparar mensaje').first().json.space_name + '::' + $('Code - Preparar mensaje').first().json.usuario_email) }}",
"contextWindowLength": 6
},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"position": [
1248,
1072
],
"id": "e6091e31-bcee-4afe-aad4-37fc8d3d0b18",
"name": "Simple Memory"
},
{
"parameters": {
"content": "## 💬 Entrada Google Chat\n\nRecibe el mensaje del usuario desde Google Chat, responde rápido con “procesando” y prepara los datos de la conversación: pregunta, usuario, espacio, hilo y session key.",
"height": 448,
"width": 608,
"color": 2
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-768,
688
],
"id": "72856778-43d5-4cf3-b1d1-3423f8dde3ba",
"name": "Sticky Note"
},
{
"parameters": {
"content": "## 🔎 Búsqueda y contexto de Fulgencio\n\nLee el banco de propuestas y el Sheet de CDC Briefs.\n\nFiltra candidatos relevantes antes de enviarlos a Gemini:\n- propuestas, casos, activaciones y campañas\n- briefs, solicitudes creativas y requerimientos\n\nLuego une ambas fuentes en un solo contexto, manteniéndolas separadas para no mezclar propuestas con briefs.",
"height": 944,
"width": 960,
"color": 4
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-32,
624
],
"id": "0490b0b2-83de-4962-b8eb-c832c97285f8",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "## 🤖 Respuesta con Gemini\n\nGemini responde usando solo el contexto filtrado y combinado.\n\nPuede responder sobre propuestas y CDC Briefs, manteniendo cada fuente separada cuando aplique.\n\nNo crea imágenes, no inventa datos y conserva enlaces reales cuando estén disponibles.",
"height": 544,
"width": 864,
"color": 5
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1040,
704
],
"id": "be8a8afc-da65-468f-a1fc-9002974d07f9",
"name": "Sticky Note2"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1h_d4l4FYkLvyy_Zp7bEQFKpy5hx6pVj9xZ98mjMHHSU",
"mode": "list",
"cachedResultName": "CDC Brief - Base para Fulgencio",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1h_d4l4FYkLvyy_Zp7bEQFKpy5hx6pVj9xZ98mjMHHSU/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": "gid=0",
"mode": "list",
"cachedResultName": "Briefs",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1h_d4l4FYkLvyy_Zp7bEQFKpy5hx6pVj9xZ98mjMHHSU/edit#gid=0"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
48,
1056
],
"id": "04aac58a-ef25-4df0-ac38-46d279db1ff1",
"name": "Sheets - Leer briefs CDC",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst MAX_BRIEFS_DEFAULT = 10;\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction norm(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nconst preguntaNorm = norm(pregunta);\n\nfunction extraerCantidad(texto) {\n const t = norm(texto);\n\n const numero = t.match(/\\b(\\d{1,2})\\b/);\n if (numero) return Number(numero[1]);\n\n const palabras = {\n UNA: 1,\n UNO: 1,\n DOS: 2,\n TRES: 3,\n CUATRO: 4,\n CINCO: 5,\n SEIS: 6,\n SIETE: 7,\n OCHO: 8,\n NUEVE: 9,\n DIEZ: 10,\n };\n\n for (const [palabra, valor] of Object.entries(palabras)) {\n if (t.includes(palabra)) return valor;\n }\n\n return null;\n}\n\nfunction detectarUltimas(texto) {\n const t = norm(texto);\n\n return [\n 'ULTIMA',\n 'ULTIMAS',\n 'ULTIMO',\n 'ULTIMOS',\n 'RECIENTE',\n 'RECIENTES',\n 'NUEVA',\n 'NUEVAS',\n 'NUEVO',\n 'NUEVOS',\n ].some(x => t.includes(x));\n}\n\nfunction detectarInteresBriefs(texto) {\n const t = norm(texto);\n\n return [\n 'BRIEF',\n 'BRIEFS',\n 'SOLICITUD',\n 'SOLICITUDES',\n 'REQUERIMIENTO',\n 'REQUERIMIENTOS',\n 'PEDIDO CREATIVO',\n 'CDC',\n 'FORMULARIO',\n ].some(x => t.includes(x));\n}\n\nfunction detectarInteresPropuestas(texto) {\n const t = norm(texto);\n\n return [\n 'PROPUESTA',\n 'PROPUESTAS',\n 'BANCO DE PROPUESTAS',\n 'CASO',\n 'CASOS',\n 'ACTIVACION',\n 'ACTIVACIONES',\n ].some(x => t.includes(x));\n}\n\nconst cantidadSolicitada = extraerCantidad(pregunta);\nconst quiereUltimas = detectarUltimas(pregunta);\nconst mencionaBriefs = detectarInteresBriefs(pregunta);\nconst mencionaPropuestas = detectarInteresPropuestas(pregunta);\n\nconst stopwords = new Set([\n 'DAME', 'DARME', 'MUESTRA', 'MUESTRAME', 'BUSCA', 'BUSCAME',\n 'BRIEF', 'BRIEFS', 'SOLICITUD', 'SOLICITUDES', 'REQUERIMIENTO', 'REQUERIMIENTOS',\n 'PROPUESTA', 'PROPUESTAS', 'DOCUMENTO', 'DOCUMENTOS',\n 'ULTIMA', 'ULTIMAS', 'ULTIMO', 'ULTIMOS', 'RECIENTE', 'RECIENTES',\n 'NUEVA', 'NUEVAS', 'NUEVO', 'NUEVOS',\n 'DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'PARA', 'POR', 'CON',\n 'EN', 'UN', 'UNA', 'Y', 'O', 'QUE', 'ME', 'TENGO', 'TIENES',\n 'ALGUNA', 'ALGUNAS', 'ALGUN', 'SOBRE', 'RELACIONADA', 'RELACIONADAS',\n 'CDC', 'FORMULARIO',\n]);\n\nconst tokens = preguntaNorm\n .split(/[^A-Z0-9Ñ]+/)\n .map(t => t.trim())\n .filter(t => t.length >= 3 && !stopwords.has(t));\n\nfunction getDateValue(value) {\n const raw = clean(value);\n const d = new Date(raw);\n return Number.isNaN(d.getTime()) ? 0 : d.getTime();\n}\n\nfunction scoreBrief(row) {\n const camposFuertes = [\n row.brief_id,\n row.nombre_carpeta,\n row.enviado_por,\n row.correo,\n row.estado,\n ];\n\n const camposGenerales = [\n row.brief_id,\n row.nombre_carpeta,\n row.enviado_por,\n row.correo,\n row.estado,\n row.resumen_chatbot,\n row.texto_brief,\n row.texto_busqueda,\n row.fecha_entrega,\n ];\n\n const textoFuerte = norm(camposFuertes.join(' '));\n const textoGeneral = norm(camposGenerales.join(' '));\n\n let score = 0;\n\n for (const token of tokens) {\n if (textoGeneral.includes(token)) score += 1;\n if (textoFuerte.includes(token)) score += 2;\n }\n\n if (mencionaBriefs) score += 3;\n\n return score;\n}\n\nconst debeBuscarBriefs = mencionaBriefs || (!mencionaPropuestas && tokens.length > 0);\n\nlet briefs = rows\n .map((item, index) => {\n const row = item.json || {};\n\n return {\n fila: row.row_number || row.__row_number || row.__rowNumber || row.rowNumber || index + 2,\n score: scoreBrief(row),\n\n fecha_creacion: clean(row.fecha_creacion),\n brief_id: clean(row.brief_id),\n nombre_carpeta: clean(row.nombre_carpeta),\n enviado_por: clean(row.enviado_por),\n correo: clean(row.correo),\n link_carpeta_drive: clean(row.link_carpeta_drive),\n link_documento_brief: clean(row.link_documento_brief),\n estado: clean(row.estado),\n cantidad_audios: clean(row.cantidad_audios),\n cantidad_imagenes: clean(row.cantidad_imagenes),\n cantidad_documentos: clean(row.cantidad_documentos),\n cantidad_links: clean(row.cantidad_links),\n resumen_chatbot: clean(row.resumen_chatbot),\n texto_brief: clean(row.texto_brief),\n texto_busqueda: clean(row.texto_busqueda),\n fecha_entrega: clean(row.fecha_entrega),\n\n fecha_sort: getDateValue(row.fecha_creacion),\n };\n })\n .filter(b => b.brief_id || b.nombre_carpeta || b.texto_brief || b.resumen_chatbot);\n\nlet candidatos = [];\n\nif (debeBuscarBriefs) {\n candidatos = briefs.filter(b => {\n if (tokens.length > 0) return b.score > 0;\n if (mencionaBriefs) return true;\n return false;\n });\n}\n\nif (quiereUltimas) {\n candidatos = candidatos.sort((a, b) => {\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n} else {\n candidatos = candidatos.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n}\n\nconst limite = cantidadSolicitada\n ? Math.min(Math.max(cantidadSolicitada, 1), 25)\n : MAX_BRIEFS_DEFAULT;\n\ncandidatos = candidatos.slice(0, limite);\n\nconst contextoBriefs = candidatos.map((b, index) => {\n return [\n `BRIEF ${index + 1}`,\n `Fila: ${b.fila}`,\n `Fecha creación: ${b.fecha_creacion}`,\n `Brief ID: ${b.brief_id}`,\n `Nombre carpeta: ${b.nombre_carpeta}`,\n `Enviado por: ${b.enviado_por}`,\n `Correo: ${b.correo}`,\n `Estado: ${b.estado}`,\n `Fecha entrega: ${b.fecha_entrega}`,\n `Cantidad audios: ${b.cantidad_audios}`,\n `Cantidad imágenes: ${b.cantidad_imagenes}`,\n `Cantidad documentos: ${b.cantidad_documentos}`,\n `Cantidad links: ${b.cantidad_links}`,\n `Resumen chatbot: ${b.resumen_chatbot}`,\n `Texto brief: ${b.texto_brief}`,\n `Texto búsqueda: ${b.texto_busqueda}`,\n `Link carpeta Drive: ${b.link_carpeta_drive}`,\n `Link documento brief: ${b.link_documento_brief}`,\n ].join('\\n');\n}).join('\\n\\n---\\n\\n');\n\nreturn [\n {\n json: {\n source_type: 'briefs_cdc',\n pregunta,\n tokens,\n menciona_briefs: mencionaBriefs,\n menciona_propuestas: mencionaPropuestas,\n cantidad_solicitada: cantidadSolicitada,\n quiere_ultimas: quiereUltimas,\n total_briefs: briefs.length,\n total_briefs_candidatos: candidatos.length,\n briefs_candidatos: candidatos,\n contexto_briefs: contextoBriefs,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
256,
1056
],
"id": "d44ec6a2-e62a-4a0d-a976-2df5408d2621",
"name": "Code - Filtrar briefs CDC"
},
{
"parameters": {
"jsCode": "const items = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst propuestasItem =\n items.find(i => i.json.source_type === 'propuestas')?.json || {};\n\nconst briefsItem =\n items.find(i => i.json.source_type === 'briefs_cdc')?.json || {};\n\nconst ejecucionesItem =\n items.find(i => i.json.source_type === 'propuestas_ejecutadas')?.json || {};\n\nif (propuestasItem.intencion_especial) {\n return [\n {\n json: {\n ...propuestasItem,\n prompt_usuario: propuestasItem.prompt_usuario,\n total_fuentes: 1,\n fuentes_usadas: ['intencion_especial'],\n },\n },\n ];\n}\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction construirContextoPropuestas(candidatos = []) {\n if (!Array.isArray(candidatos) || candidatos.length === 0) return '';\n\n return candidatos.map((p, index) => {\n return [\n `PROPUESTA ${index + 1}`,\n `Fila: ${clean(p.fila)}`,\n `Nombre: ${clean(p.NOMBRE)}`,\n `Cliente: ${clean(p.CLIENTE)}`,\n `Marca: ${clean(p.MARCA)}`,\n `País: ${clean(p.PAIS)}`,\n `Tipo de acción: ${clean(p.TIPO_DE_ACCION)}`,\n `Canal: ${clean(p.CANAL)}`,\n `Ambiente de compra: ${clean(p.AMBIENTE_DE_COMPRA)}`,\n `Táctica promocional: ${clean(p.TACTICA_PROMOCIONAL)}`,\n `Estado aprobación: ${clean(p.APROBADA)}`,\n `Etiquetas: ${clean(p.ETIQUETAS)}`,\n `Año: ${clean(p.ANIO)}`,\n `Descripción: ${clean(p.DESCRIPCION)}`,\n `Enlace: ${clean(p.ENLACE)}`,\n `Enlaces propuestas ejecutadas: ${clean(p.ENLACES_EJECUTADAS)}`,\n ].join('\\n');\n }).join('\\n\\n---\\n\\n');\n}\n\nfunction construirContextoEjecuciones(candidatos = []) {\n if (!Array.isArray(candidatos) || candidatos.length === 0) return '';\n\n return candidatos.map((e, index) => {\n return [\n `EJECUCION ${index + 1}`,\n `Fila: ${clean(e.fila)}`,\n `Ejecución ID: ${clean(e.ejecucion_id)}`,\n `Tipo reporte: ${clean(e.tipo_reporte || e.tipo_normalizado)}`,\n `Fecha recepción: ${clean(e.fecha_recepcion)}`,\n `Fecha ejecución: ${clean(e.fecha_ejecucion)}`,\n `Manager: ${clean(e.manager_nombre)}`,\n `Propuesta referencia: ${clean(e.propuesta_referencia)}`,\n `Match estado: ${clean(e.propuesta_match_estado)}`,\n `Match confianza: ${clean(e.propuesta_match_confianza)}`,\n `Propuesta nombre banco: ${clean(e.propuesta_nombre_banco)}`,\n `Propuesta original link: ${clean(e.propuesta_link_banco)}`,\n `Marca: ${clean(e.marca)}`,\n `Cliente: ${clean(e.cliente)}`,\n `País: ${clean(e.pais)}`,\n `Ubicación: ${clean(e.ubicacion)}`,\n `Resumen IA: ${clean(e.resumen_ia)}`,\n `Descripción ejecución: ${clean(e.descripcion_ejecucion)}`,\n `Elementos detectados: ${clean(e.elementos_detectados)}`,\n `Tags: ${clean(e.tags)}`,\n `Fotos: ${clean(e.fotos_count)}`,\n `Videos: ${clean(e.videos_count)}`,\n `Audios: ${clean(e.audios_count)}`,\n `Carpeta evidencias: ${clean(e.media_folder_url)}`,\n `Presentación ejecución: ${clean(e.presentacion_ejecucion_url)}`,\n `Estado revisión: ${clean(e.estado_revision)}`,\n `Motivo revisión: ${clean(e.motivo_revision)}`,\n ].join('\\n');\n }).join('\\n\\n---\\n\\n');\n}\n\nconst contextoPropuestas =\n clean(propuestasItem.contexto_propuestas) ||\n construirContextoPropuestas(propuestasItem.candidatos || []);\n\nconst contextoBriefs = clean(briefsItem.contexto_briefs);\n\nconst contextoEjecuciones =\n clean(ejecucionesItem.contexto_ejecuciones) ||\n construirContextoEjecuciones(ejecucionesItem.ejecuciones_candidatas || []);\n\nconst totalPropuestas = Number(propuestasItem.total_candidatos || 0);\nconst totalBriefs = Number(briefsItem.total_briefs_candidatos || 0);\nconst totalEjecuciones = Number(ejecucionesItem.total_ejecuciones_candidatas || 0);\n\nconst sinResultados =\n totalPropuestas === 0 &&\n totalBriefs === 0 &&\n totalEjecuciones === 0;\n\nconst promptUsuario = `\nPregunta del usuario:\n${pregunta}\n\nFuentes disponibles para responder:\n\n1. Banco de propuestas:\n - Contiene propuestas originales, casos, presentaciones, activaciones y referencias internas de GomezLee Marketing.\n - Total de candidatos encontrados: ${totalPropuestas}\n\n2. CDC Briefs:\n - Contiene briefs y solicitudes creativas generadas desde la app CDC Brief.\n - Total de candidatos encontrados: ${totalBriefs}\n\n3. Propuestas Ejecutadas / Externas:\n - Contiene reportes reales de ejecución, evidencias, propuestas externas recibidas y presentaciones generadas desde WhatsApp.\n - Total de candidatos encontrados: ${totalEjecuciones}\n\nReglas para elegir fuente:\n- Si el usuario pregunta por propuestas originales, casos, activaciones o presentaciones base, prioriza el Banco de propuestas.\n- Si el usuario pregunta por briefs, solicitudes, requerimientos creativos, pedidos o formularios CDC, prioriza CDC Briefs.\n- Si el usuario pregunta por propuesta ejecutada, ejecución, evidencias, presentación de ejecución, fotos, videos, propuesta externa o link de ejecución, prioriza Propuestas Ejecutadas / Externas.\n- Si el usuario pide “la presentación de la propuesta ejecutada de X”, busca primero en Propuestas Ejecutadas / Externas.\n- Si una ejecución está relacionada con una propuesta original, puedes mostrar ambos enlaces: presentación ejecutada y propuesta original.\n- Si hay información útil en varias fuentes, separa la respuesta por secciones.\n- No mezcles un brief con una propuesta.\n- No mezcles una propuesta original con una propuesta ejecutada como si fueran lo mismo.\n- Usa únicamente la información entregada abajo.\n- No inventes enlaces ni datos.\n\nResultado de búsqueda:\n${sinResultados ? 'No se encontraron candidatos en ninguna fuente.' : 'Se encontraron candidatos en una o más fuentes.'}\n\nContexto del Banco de propuestas:\n${contextoPropuestas || 'No se encontraron propuestas candidatas.'}\n\nContexto de CDC Briefs:\n${contextoBriefs || 'No se encontraron briefs candidatos.'}\n\nContexto de Propuestas Ejecutadas / Externas:\n${contextoEjecuciones || 'No se encontraron propuestas ejecutadas o externas candidatas.'}\n\nInstrucción para responder:\n${sinResultados\n ? 'Indica que no encontraste coincidencias. Sugiere buscar por cliente, marca, país, año, tipo de acción, estado, brief ID, solicitante, tema del brief, nombre de propuesta ejecutada o propuesta externa.'\n : 'Responde usando únicamente el contexto anterior. Si usas varias fuentes, separa la respuesta por secciones.'}\n`;\n\nreturn [\n {\n json: {\n pregunta,\n\n source_type: 'contexto_fulgencio_combinado',\n\n total_propuestas_candidatas: totalPropuestas,\n total_briefs_candidatos: totalBriefs,\n total_ejecuciones_candidatas: totalEjecuciones,\n sin_resultados: sinResultados,\n\n propuestas: propuestasItem.candidatos || [],\n briefs: briefsItem.briefs_candidatos || [],\n ejecuciones: ejecucionesItem.ejecuciones_candidatas || [],\n\n contexto_propuestas: contextoPropuestas,\n contexto_briefs: contextoBriefs,\n contexto_ejecuciones: contextoEjecuciones,\n\n prompt_usuario: promptUsuario,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
768,
896
],
"id": "12528b2b-1d6e-43f6-9573-5b7dccd8cab6",
"name": "Code - Construir contexto Fulgencio"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1nbeuaW2lxvWJYVFD-E0uJRhTGzUa-nuH2WYItHdTsKk",
"mode": "list",
"cachedResultName": "Fulgencio - Propuestas Ejecutadas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nbeuaW2lxvWJYVFD-E0uJRhTGzUa-nuH2WYItHdTsKk/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 737153956,
"mode": "list",
"cachedResultName": "propuestas_ejecutadas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nbeuaW2lxvWJYVFD-E0uJRhTGzUa-nuH2WYItHdTsKk/edit#gid=737153956"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
64,
1280
],
"id": "96b7e960-b6b2-4a19-8707-31ee42c6cfcd",
"name": "Sheets - Leer propuestas ejecutadas",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst MAX_EJECUCIONES_DEFAULT = 15;\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction norm(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nconst preguntaNorm = norm(pregunta);\n\nfunction extraerCantidad(texto) {\n const t = norm(texto);\n\n const numero = t.match(/\\b(\\d{1,2})\\b/);\n if (numero) return Number(numero[1]);\n\n const palabras = {\n UNA: 1,\n UNO: 1,\n DOS: 2,\n TRES: 3,\n CUATRO: 4,\n CINCO: 5,\n SEIS: 6,\n SIETE: 7,\n OCHO: 8,\n NUEVE: 9,\n DIEZ: 10,\n QUINCE: 15,\n VEINTE: 20,\n };\n\n for (const [palabra, valor] of Object.entries(palabras)) {\n if (t.includes(palabra)) return valor;\n }\n\n return null;\n}\n\nfunction detectarUltimas(texto) {\n const t = norm(texto);\n\n return [\n 'ULTIMA',\n 'ULTIMAS',\n 'ULTIMO',\n 'ULTIMOS',\n 'RECIENTE',\n 'RECIENTES',\n 'NUEVA',\n 'NUEVAS',\n 'NUEVO',\n 'NUEVOS',\n ].some(x => t.includes(x));\n}\n\nfunction detectarInteresEjecuciones(texto) {\n const t = norm(texto);\n\n return [\n 'EJECUTADA',\n 'EJECUTADAS',\n 'EJECUCION',\n 'EJECUCIONES',\n 'EVIDENCIA',\n 'EVIDENCIAS',\n 'PROPUESTA EJECUTADA',\n 'PROPUESTAS EJECUTADAS',\n 'PRESENTACION EJECUTADA',\n 'PRESENTACION DE EJECUCION',\n 'PRESENTACION DE LA EJECUCION',\n 'MUESTRAME LA EJECUCION',\n 'ENVIAME LA PRESENTACION',\n 'LINK DE LA EJECUCION',\n 'PROPUESTA EXTERNA',\n 'PROPUESTAS EXTERNAS',\n 'EXTERNA',\n 'EXTERNAS',\n 'FOTOS',\n 'VIDEOS',\n 'IMPLEMENTACION',\n 'IMPLEMENTADA',\n 'IMPLEMENTADAS',\n ].some(x => t.includes(norm(x)));\n}\n\nfunction detectarTipoSolicitado(texto) {\n const t = norm(texto);\n\n if (\n t.includes('PROPUESTA EXTERNA') ||\n t.includes('PROPUESTAS EXTERNAS') ||\n t.includes(' EXTERNA') ||\n t.includes(' EXTERNAS')\n ) {\n return 'PROPUESTA_EXTERNA';\n }\n\n if (\n t.includes('PROPUESTA EJECUTADA') ||\n t.includes('PROPUESTAS EJECUTADAS') ||\n t.includes('EJECUCION') ||\n t.includes('EJECUCIONES') ||\n t.includes('EJECUTADA') ||\n t.includes('EJECUTADAS')\n ) {\n return 'PROPUESTA_EJECUTADA';\n }\n\n return '';\n}\n\nfunction detectarPaises(texto) {\n const t = norm(texto);\n\n const paises = [\n 'EL SALVADOR',\n 'PANAMA',\n 'REPUBLICA DOMINICANA',\n 'COLOMBIA',\n 'PUERTO RICO',\n 'HONDURAS',\n 'MEXICO',\n 'VENEZUELA',\n 'JAMAICA',\n 'TRINIDAD Y TOBAGO',\n 'COSTA RICA',\n 'NICARAGUA',\n 'GUATEMALA',\n ];\n\n const encontrados = [];\n\n for (const pais of paises) {\n if (t.includes(norm(pais))) {\n encontrados.push(pais);\n }\n }\n\n if (/\\\\bRD\\\\b/.test(t) || t.includes('REP DOM') || t.includes('DOMINICANA')) {\n if (!encontrados.includes('REPUBLICA DOMINICANA')) {\n encontrados.push('REPUBLICA DOMINICANA');\n }\n }\n\n return encontrados;\n}\n\nfunction detectarAnios(texto) {\n const matches = norm(texto).match(/\\b(20\\d{2}|19\\d{2})\\b/g);\n return matches ? [...new Set(matches)] : [];\n}\n\nconst cantidadSolicitada = extraerCantidad(pregunta);\nconst quiereUltimas = detectarUltimas(pregunta);\nconst mencionaEjecuciones = detectarInteresEjecuciones(pregunta);\nconst tipoSolicitado = detectarTipoSolicitado(pregunta);\nconst paisesSolicitados = detectarPaises(pregunta);\nconst aniosSolicitados = detectarAnios(pregunta);\n\nconst stopwords = new Set([\n 'DAME', 'DARME', 'MUESTRA', 'MUESTRAME', 'BUSCA', 'BUSCAME',\n 'ENVIAME', 'ENVIAR', 'QUIERO', 'NECESITO',\n 'PROPUESTA', 'PROPUESTAS', 'EJECUTADA', 'EJECUTADAS',\n 'EJECUCION', 'EJECUCIONES', 'PRESENTACION', 'PRESENTACIONES',\n 'EXTERNA', 'EXTERNAS', 'EVIDENCIA', 'EVIDENCIAS',\n 'LINK', 'ENLACE', 'RESUMEN',\n 'ULTIMA', 'ULTIMAS', 'ULTIMO', 'ULTIMOS', 'RECIENTE', 'RECIENTES',\n 'NUEVA', 'NUEVAS', 'NUEVO', 'NUEVOS',\n 'DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'PARA', 'POR', 'CON',\n 'EN', 'UN', 'UNA', 'Y', 'O', 'QUE', 'ME', 'TIENES', 'TENGO',\n 'ALGUNA', 'ALGUNAS', 'ALGUN', 'SOBRE', 'RELACIONADA', 'RELACIONADAS',\n]);\n\nconst tokens = preguntaNorm\n .split(/[^A-Z0-9Ñ]+/)\n .map(t => t.trim())\n .filter(t => t.length >= 3 && !stopwords.has(t))\n .filter(t => !aniosSolicitados.includes(t));\n\nfunction getDateValue(...values) {\n for (const value of values) {\n const raw = clean(value);\n if (!raw) continue;\n\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.getTime();\n }\n\n return 0;\n}\n\nfunction fieldContains(value, expected) {\n return norm(value).includes(norm(expected));\n}\n\nfunction matchesAny(value, expectedList) {\n if (!expectedList.length) return true;\n return expectedList.some(expected => fieldContains(value, expected));\n}\n\nfunction normalizarTipo(row) {\n const campos = [\n row.tipo_reporte,\n row.clasificacion_reporte,\n row.propuesta_match_estado,\n row.estado_revision,\n row.motivo_revision,\n row.propuesta_match_revision,\n ].map(norm).join(' ');\n\n if (\n campos.includes('PROPUESTA_EXTERNA') ||\n campos.includes('EXTERNA') ||\n campos.includes('SIN MATCH') ||\n campos.includes('MATCH BAJO')\n ) {\n return 'PROPUESTA_EXTERNA';\n }\n\n if (\n campos.includes('PROPUESTA_EJECUTADA') ||\n campos.includes('MATCH ALTO') ||\n campos.includes('MATCH_ALTO') ||\n campos.includes('EJECUTADA')\n ) {\n return 'PROPUESTA_EJECUTADA';\n }\n\n return clean(row.tipo_reporte || row.clasificacion_reporte || row.propuesta_match_estado || '');\n}\n\nfunction hardFilterPass(e) {\n if (tipoSolicitado === 'PROPUESTA_EXTERNA' && e.tipo_normalizado !== 'PROPUESTA_EXTERNA') {\n return false;\n }\n\n if (tipoSolicitado === 'PROPUESTA_EJECUTADA' && e.tipo_normalizado === 'PROPUESTA_EXTERNA') {\n return false;\n }\n\n if (paisesSolicitados.length && !matchesAny(e.pais, paisesSolicitados)) {\n return false;\n }\n\n if (aniosSolicitados.length) {\n const textoFechas = norm(`${e.fecha_recepcion} ${e.fecha_ejecucion} ${e.fecha_sort_raw}`);\n const tieneAnio = aniosSolicitados.some(anio => textoFechas.includes(anio));\n if (!tieneAnio) return false;\n }\n\n return true;\n}\n\nfunction scoreRow(row) {\n const camposFuertes = [\n row.propuesta_nombre_banco,\n row.propuesta_referencia,\n row.propuesta_nombre,\n row.nombre_propuesta,\n row.marca,\n row.cliente,\n row.pais,\n row.ubicacion,\n ];\n\n const camposGenerales = [\n row.propuesta_nombre_banco,\n row.propuesta_referencia,\n row.propuesta_nombre,\n row.nombre_propuesta,\n row.propuesta_link_banco,\n row.marca,\n row.cliente,\n row.pais,\n row.ubicacion,\n row.comentario_original,\n row.resumen_ia,\n row.descripcion_ejecucion,\n row.elementos_detectados,\n row.tags,\n row.transcripcion_audio,\n row.tipo_reporte,\n row.propuesta_match_estado,\n row.propuesta_match_revision,\n row.estado_revision,\n row.motivo_revision,\n ];\n\n const textoFuerte = norm(camposFuertes.join(' '));\n const textoGeneral = norm(camposGenerales.join(' '));\n\n let score = 0;\n\n for (const token of tokens) {\n if (textoGeneral.includes(token)) score += 1;\n if (textoFuerte.includes(token)) score += 3;\n }\n\n if (mencionaEjecuciones) score += 4;\n\n if (tipoSolicitado) {\n score += 5;\n }\n\n if (paisesSolicitados.length && matchesAny(row.pais, paisesSolicitados)) {\n score += 8;\n }\n\n if (clean(row.presentacion_ejecucion_url)) {\n score += 4;\n }\n\n if (clean(row.propuesta_nombre_banco)) {\n score += 3;\n }\n\n return score;\n}\n\nconst ejecuciones = rows\n .map((item, index) => {\n const row = item.json || {};\n\n const fila =\n row.row_number ||\n row.__row_number ||\n row.__rowNumber ||\n row.rowNumber ||\n index + 2;\n\n const tipoNormalizado = normalizarTipo(row);\n\n const fechaSort = getDateValue(\n row.fecha_ejecucion,\n row.fecha_recepcion,\n row.ultima_actualizacion\n );\n\n return {\n fila,\n score: scoreRow(row),\n\n ejecucion_id: clean(row.ejecucion_id),\n session_id: clean(row.session_id),\n\n tipo_reporte: clean(row.tipo_reporte || row.clasificacion_reporte),\n tipo_normalizado: tipoNormalizado,\n\n fecha_recepcion: clean(row.fecha_recepcion),\n fecha_ejecucion: clean(row.fecha_ejecucion),\n fecha_sort,\n fecha_sort_raw: clean(row.fecha_ejecucion || row.fecha_recepcion || row.ultima_actualizacion),\n\n manager_nombre: clean(row.manager_nombre),\n manager_telefono: clean(row.manager_telefono),\n\n propuesta_referencia: clean(row.propuesta_referencia),\n propuesta_match_estado: clean(row.propuesta_match_estado),\n propuesta_match_confianza: clean(row.propuesta_match_confianza),\n propuesta_nombre_banco: clean(row.propuesta_nombre_banco),\n propuesta_link_banco: clean(row.propuesta_link_banco),\n propuesta_match_revision: clean(row.propuesta_match_revision),\n\n marca: clean(row.marca),\n cliente: clean(row.cliente),\n pais: clean(row.pais),\n ubicacion: clean(row.ubicacion),\n\n comentario_original: clean(row.comentario_original),\n resumen_ia: clean(row.resumen_ia),\n descripcion_ejecucion: clean(row.descripcion_ejecucion),\n elementos_detectados: clean(row.elementos_detectados),\n tags: clean(row.tags),\n transcripcion_audio: clean(row.transcripcion_audio),\n\n media_folder_url: clean(row.media_folder_url),\n presentacion_ejecucion_url: clean(row.presentacion_ejecucion_url),\n\n fotos_count: clean(row.fotos_count),\n videos_count: clean(row.videos_count),\n audios_count: clean(row.audios_count),\n\n estado_revision: clean(row.estado_revision),\n motivo_revision: clean(row.motivo_revision),\n canal_origen: clean(row.canal_origen),\n };\n })\n .filter(e =>\n e.ejecucion_id ||\n e.presentacion_ejecucion_url ||\n e.resumen_ia ||\n e.descripcion_ejecucion ||\n e.propuesta_nombre_banco\n );\n\nlet candidatos = ejecuciones\n .filter(hardFilterPass)\n .filter(e => {\n if (tokens.length > 0) return e.score > 0;\n if (mencionaEjecuciones) return true;\n if (tipoSolicitado) return true;\n return false;\n });\n\nif (quiereUltimas) {\n candidatos = candidatos.sort((a, b) => {\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n} else {\n candidatos = candidatos.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n}\n\nconst limite = cantidadSolicitada\n ? Math.min(Math.max(cantidadSolicitada, 1), 25)\n : MAX_EJECUCIONES_DEFAULT;\n\ncandidatos = candidatos.slice(0, limite);\n\nconst contextoEjecuciones = candidatos.map((e, index) => {\n return [\n `EJECUCION ${index + 1}`,\n `Fila: ${e.fila}`,\n `Ejecución ID: ${e.ejecucion_id}`,\n `Tipo reporte: ${e.tipo_reporte || e.tipo_normalizado}`,\n `Fecha recepción: ${e.fecha_recepcion}`,\n `Fecha ejecución: ${e.fecha_ejecucion}`,\n `Manager: ${e.manager_nombre}`,\n `Propuesta referencia: ${e.propuesta_referencia}`,\n `Match estado: ${e.propuesta_match_estado}`,\n `Match confianza: ${e.propuesta_match_confianza}`,\n `Propuesta nombre banco: ${e.propuesta_nombre_banco}`,\n `Propuesta original link: ${e.propuesta_link_banco}`,\n `Marca: ${e.marca}`,\n `Cliente: ${e.cliente}`,\n `País: ${e.pais}`,\n `Ubicación: ${e.ubicacion}`,\n `Resumen IA: ${e.resumen_ia}`,\n `Descripción ejecución: ${e.descripcion_ejecucion}`,\n `Elementos detectados: ${e.elementos_detectados}`,\n `Tags: ${e.tags}`,\n `Fotos: ${e.fotos_count}`,\n `Videos: ${e.videos_count}`,\n `Audios: ${e.audios_count}`,\n `Carpeta evidencias: ${e.media_folder_url}`,\n `Presentación ejecución: ${e.presentacion_ejecucion_url}`,\n `Estado revisión: ${e.estado_revision}`,\n `Motivo revisión: ${e.motivo_revision}`,\n ].join('\\n');\n}).join('\\n\\n---\\n\\n');\n\nreturn [\n {\n json: {\n source_type: 'propuestas_ejecutadas',\n pregunta,\n tokens,\n menciona_ejecuciones: mencionaEjecuciones,\n tipo_solicitado: tipoSolicitado,\n cantidad_solicitada: cantidadSolicitada,\n quiere_ultimas: quiereUltimas,\n paises_solicitados: paisesSolicitados,\n anios_solicitados: aniosSolicitados,\n total_ejecuciones: ejecuciones.length,\n total_ejecuciones_candidatas: candidatos.length,\n ejecuciones_candidatas: candidatos,\n contexto_ejecuciones: contextoEjecuciones,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
272,
1280
],
"id": "79aa045d-603c-44b3-abcc-f58471fac1a4",
"name": "Code - Filtrar propuestas ejecutadas relevantes"
},
{
"parameters": {
"numberInputs": 3
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
560,
896
],
"id": "722b0de9-c0ad-4ee9-981c-390df1c9b6c9",
"name": "Merge - Unir propuestas y briefs Fulgencio"
},
{
"parameters": {
"httpMethod": "POST",
"path": "googlechat",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-720,
864
],
"id": "511df9a0-6f14-4141-b277-bfbf04ac8967",
"name": "Webhook1",
"webhookId": "85f57925-7a74-4686-9b6b-1d022ec8bc37"
}
],
"connections": {
"Respond to Webhook": {
"main": [
[
{
"node": "Code - Preparar mensaje",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar mensaje": {
"main": [
[
{
"node": "Sheets - Leer propuestas",
"type": "main",
"index": 0
},
{
"node": "Sheets - Leer briefs CDC",
"type": "main",
"index": 0
},
{
"node": "Sheets - Leer propuestas ejecutadas",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Leer propuestas": {
"main": [
[
{
"node": "Code - Filtrar propuestas relevantes",
"type": "main",
"index": 0
}
]
]
},
"Code - Filtrar propuestas relevantes": {
"main": [
[
{
"node": "Merge - Unir propuestas y briefs Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"AI Agent - Gemini Fulgencio": {
"main": [
[
{
"node": "Code - Formatear respuesta Chat",
"type": "main",
"index": 0
}
]
]
},
"Google Gemini Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent - Gemini Fulgencio",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Code - Formatear respuesta Chat": {
"main": [
[
{
"node": "Chat - Enviar respuesta",
"type": "main",
"index": 0
}
]
]
},
"Chat - Enviar respuesta": {
"main": [
[]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent - Gemini Fulgencio",
"type": "ai_memory",
"index": 0
}
]
]
},
"Sheets - Leer briefs CDC": {
"main": [
[
{
"node": "Code - Filtrar briefs CDC",
"type": "main",
"index": 0
}
]
]
},
"Code - Filtrar briefs CDC": {
"main": [
[
{
"node": "Merge - Unir propuestas y briefs Fulgencio",
"type": "main",
"index": 1
}
]
]
},
"Code - Construir contexto Fulgencio": {
"main": [
[
{
"node": "AI Agent - Gemini Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Leer propuestas ejecutadas": {
"main": [
[
{
"node": "Code - Filtrar propuestas ejecutadas relevantes",
"type": "main",
"index": 0
}
]
]
},
"Merge - Unir propuestas y briefs Fulgencio": {
"main": [
[
{
"node": "Code - Construir contexto Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"Code - Filtrar propuestas ejecutadas relevantes": {
"main": [
[
{
"node": "Merge - Unir propuestas y briefs Fulgencio",
"type": "main",
"index": 2
}
]
]
},
"Webhook1": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": true
},
"versionId": "ab4d8488-2e85-44bd-bff1-13f1501c74ea",
"activeVersionId": "ab4d8488-2e85-44bd-bff1-13f1501c74ea",
"versionCounter": 79,
"triggerCount": 1,
"shared": [
{
"updatedAt": "2026-06-24T18:44:11.359Z",
"createdAt": "2026-06-24T18:44:11.359Z",
"role": "workflow:owner",
"workflowId": "KRF6cPYGgmYT68rF",
"projectId": "PJpTANzTXIFibWsW",
"project": {
"updatedAt": "2026-04-22T14:25:09.686Z",
"createdAt": "2026-04-22T14:22:54.790Z",
"id": "PJpTANzTXIFibWsW",
"name": "Isaac Aracena <iaracena@gomezleemarketing.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
}
],
"tags": [],
"activeVersion": {
"updatedAt": "2026-06-27T17:39:04.000Z",
"createdAt": "2026-06-27T17:39:03.102Z",
"versionId": "ab4d8488-2e85-44bd-bff1-13f1501c74ea",
"workflowId": "KRF6cPYGgmYT68rF",
"nodes": [
{
"parameters": {
"respondWith": "json",
"responseBody": "{\n \"text\": \"⏳ Procesando tu solicitud, dame un momento...\"\n}",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
-512,
864
],
"id": "0f2904c5-1488-4c75-97b2-64b4cb2bd911",
"name": "Respond to Webhook"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const webhookData = $json || {};\n\n// En algunos casos Google Chat llega dentro de body.\n// En otros, puede venir directo en el objeto principal.\nconst body = webhookData.body || webhookData;\n\nconst message = body.message || {};\nconst user = body.user || message.sender || {};\nconst space = body.space || message.space || {};\nconst thread = body.thread || message.thread || {};\n\nconst texto =\n message.argumentText ||\n message.text ||\n body.text ||\n '';\n\nconst pregunta = String(texto || '')\n .replace(/^@\\S+\\s*/, '')\n .trim();\n\nreturn {\n json: {\n pregunta,\n usuario_nombre: user.displayName || message.sender?.displayName || '',\n usuario_email: user.email || message.sender?.email || '',\n space_name: space.name || message.space?.name || '',\n thread_name: thread.name || message.thread?.name || '',\n message_name: message.name || '',\n event_type: body.type || '',\n fecha_evento: body.eventTime || new Date().toISOString()\n }\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-320,
864
],
"id": "85040629-41da-4ccb-b4cc-f4d7b15d9e7d",
"name": "Code - Preparar mensaje"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng",
"mode": "list",
"cachedResultName": "BANCO DE PROPUESTAS DE CDC PARA FULGENCIO FUMADO",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": "gid=0",
"mode": "list",
"cachedResultName": "propuestas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng/edit#gid=0"
},
"filtersUI": {
"values": []
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
80,
864
],
"id": "f7cea213-1a5a-476a-8f87-3bc3b04ab1b2",
"name": "Sheets - Leer propuestas",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst MAX_CANDIDATOS_DEFAULT = 25;\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction norm(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nfunction getFirstValue(row, keys = []) {\n for (const key of keys) {\n const value = clean(row?.[key]);\n if (value) return value;\n }\n return '';\n}\n\nfunction getEnlacesEjecutadas(row) {\n return getFirstValue(row, [\n 'Enlaces a propuestas ejecutadas',\n 'Enlaces propuestas ejecutadas',\n 'Enlace a propuestas ejecutadas',\n 'Enlace propuesta ejecutada',\n 'Propuestas ejecutadas',\n 'Enlaces a ejecuciones',\n 'Ejecuciones',\n ]);\n}\n\nconst preguntaNorm = norm(pregunta);\n\nfunction detectarIntencionEspecial(texto) {\n const t = norm(texto);\n\n const esSaludoSimple = [\n // Español\n 'HOLA',\n 'BUENAS',\n 'BUENOS DIAS',\n 'BUENOS DÍAS',\n 'BUEN DIA',\n 'BUEN DÍA',\n 'BUENAS TARDES',\n 'BUENAS NOCHES',\n 'SALUDOS',\n 'HEY',\n\n // Inglés\n 'HI',\n 'HELLO',\n 'HEY THERE',\n 'GOOD MORNING',\n 'GOOD AFTERNOON',\n 'GOOD EVENING',\n\n // Genéricos / cortesía\n 'OK',\n 'OKAY',\n 'OKEY',\n 'LISTO',\n 'DALE',\n 'PERFECTO',\n 'GRACIAS',\n 'THANKS',\n 'THANK YOU',\n 'ENTENDIDO',\n 'VALE'\n ].includes(t);\n\n if (esSaludoSimple) {\n return {\n tipo: 'saludo_ayuda',\n prompt: `\nEl usuario solo saludó o envió un mensaje muy corto.\n\nResponde de forma breve, cercana y útil. No busques propuestas, briefs ni ejecuciones.\n\nDebes decir que eres Fulgencio Fumado, asistente interno para consultar propuestas, briefs, propuestas ejecutadas, propuestas externas y recursos creativos de GomezLee Marketing.\n\nIncluye ejemplos cortos de lo que puede preguntar:\n1. Dame las 5 últimas propuestas de Motorola.\n2. Busca propuestas de Nestlé en República Dominicana.\n3. Dame propuestas aprobadas de activación en PDV.\n4. Dame los últimos briefs creados.\n5. Busca briefs relacionados con Motorola.\n6. Envíame la presentación de la propuesta ejecutada de Pollo Campero.\n7. Dame propuestas externas registradas.\n8. Necesito crear un brief.\n9. Necesito acceder al Tablero CDC.\n\nNo incluyas propuestas específicas, briefs específicos, ejecuciones específicas ni enlaces de archivos.\nSolo incluye estos enlaces generales si aplica:\n- Crear brief: https://digitalcompass.agency/brief\n- Tablero CDC: https://digitalcompass.agency/tablero-cdc/\n`\n };\n }\n\n // IMPORTANTE:\n // No tratamos cualquier mención de \"brief\" como intención de crear un brief,\n // porque Fulgencio también puede buscar briefs existentes en el Sheet de CDC Briefs.\n const pideCrearBrief =\n t.includes('CREAR BRIEF') ||\n t.includes('CREAR UN BRIEF') ||\n t.includes('CREAR UNA SOLICITUD') ||\n t.includes('CREAR SOLICITUD') ||\n t.includes('LLENAR BRIEF') ||\n t.includes('LLENAR UN BRIEF') ||\n t.includes('HACER BRIEF') ||\n t.includes('HACER UN BRIEF') ||\n t.includes('NUEVO BRIEF') ||\n t.includes('NUEVA SOLICITUD') ||\n t.includes('FORMULARIO DE BRIEF') ||\n t.includes('FORMULARIO PARA BRIEF') ||\n t.includes('SOLICITUD CREATIVA');\n\n const pideImagen =\n t.includes('CREAR IMAGEN') ||\n t.includes('GENERAR IMAGEN') ||\n t.includes('HAZME UNA IMAGEN') ||\n t.includes('DISEÑA UNA IMAGEN') ||\n t.includes('DISENA UNA IMAGEN') ||\n t.includes('IMAGEN NUEVA') ||\n t.includes('ARTE NUEVO');\n\n const pideAyuda =\n t.includes('QUE PUEDES HACER') ||\n t.includes('QUÉ PUEDES HACER') ||\n t.includes('AYUDA') ||\n t.includes('COMO TE USO') ||\n t.includes('CÓMO TE USO') ||\n t.includes('COMO FUNCIONAS') ||\n t.includes('CÓMO FUNCIONAS') ||\n t.includes('COMO FUNCIONA') ||\n t.includes('CÓMO FUNCIONA');\n\n if (pideCrearBrief) {\n return {\n tipo: 'brief',\n prompt: `\nEl usuario necesita crear o llenar un brief.\n\nResponde de forma breve indicando que puede crear la solicitud en este enlace:\nhttps://digitalcompass.agency/brief\n\nNo busques propuestas. No inventes información adicional.\n`\n };\n }\n\n if (pideImagen) {\n return {\n tipo: 'imagen_no_disponible',\n prompt: `\nEl usuario está pidiendo crear o generar una imagen.\n\nResponde claramente que la creación de imágenes no está disponible actualmente en Fulgencio.\nLuego ofrece ayudarle a buscar referencias, propuestas, campañas, briefs, propuestas ejecutadas, propuestas externas o ideas dentro de las fuentes disponibles.\n`\n };\n }\n\n if (pideAyuda) {\n return {\n tipo: 'ayuda',\n prompt: `\nEl usuario está pidiendo ayuda sobre cómo usar Fulgencio.\n\nExplica brevemente que Fulgencio puede ayudar a buscar información en:\n- banco de propuestas\n- briefs o solicitudes creativas de CDC Brief\n- propuestas ejecutadas\n- propuestas externas\n- evidencias y presentaciones de ejecución\n\nPuede buscar por:\n- cliente\n- marca\n- país\n- año\n- tipo de acción\n- estado de aprobación\n- canal\n- etiquetas o temas\n- brief ID\n- solicitante\n- tema del brief\n- nombre de propuesta ejecutada\n- presentación de ejecución\n- propuesta externa\n\nDa 8 ejemplos de preguntas útiles:\n1. Dame las 5 últimas propuestas de Motorola.\n2. Busca propuestas de Nestlé en República Dominicana.\n3. Dame propuestas aprobadas de activación en PDV.\n4. Tienes propuestas de Kit Kat en Costa Rica?\n5. Dame los últimos briefs creados.\n6. Busca briefs relacionados con Motorola.\n7. Envíame la presentación de la propuesta ejecutada de Pollo Campero Guatemala.\n8. Dame propuestas externas registradas.\n`\n };\n }\n\n return null;\n}\n\nfunction extraerCantidad(texto) {\n const t = norm(texto);\n\n const numero = t.match(/\\b(\\d{1,2})\\b/);\n if (numero) return Number(numero[1]);\n\n const palabras = {\n UNA: 1,\n UNO: 1,\n DOS: 2,\n TRES: 3,\n CUATRO: 4,\n CINCO: 5,\n SEIS: 6,\n SIETE: 7,\n OCHO: 8,\n NUEVE: 9,\n DIEZ: 10,\n QUINCE: 15,\n VEINTE: 20,\n };\n\n for (const [palabra, valor] of Object.entries(palabras)) {\n if (t.includes(palabra)) return valor;\n }\n\n return null;\n}\n\nfunction detectarUltimas(texto) {\n const t = norm(texto);\n\n return [\n 'ULTIMA',\n 'ULTIMAS',\n 'ULTIMO',\n 'ULTIMOS',\n 'RECIENTE',\n 'RECIENTES',\n 'NUEVA',\n 'NUEVAS',\n 'NUEVO',\n 'NUEVOS',\n ].some(x => t.includes(x));\n}\n\nfunction detectarSeguimiento(texto) {\n const t = norm(texto);\n\n return [\n 'DE ESAS',\n 'DE ESTAS',\n 'DE LOS ANTERIORES',\n 'DE LAS ANTERIORES',\n 'LA ANTERIOR',\n 'EL ANTERIOR',\n 'LA PRIMERA',\n 'LA SEGUNDA',\n 'LA TERCERA',\n 'ESAS',\n 'ESTAS',\n 'ESOS',\n 'ESTOS',\n 'LOS QUE ME MOSTRASTE',\n 'LAS QUE ME MOSTRASTE',\n ].some(x => t.includes(x));\n}\n\nfunction detectarEstado(texto) {\n const t = norm(texto);\n\n if (t.includes('NO APROBADA') || t.includes('NO APROBADAS')) {\n return 'NO APROBADA';\n }\n\n if (t.includes('APROBADA') || t.includes('APROBADAS')) {\n return 'APROBADA';\n }\n\n if (\n t.includes('PENDIENTE') ||\n t.includes('PENDIENTES') ||\n t.includes('PENDIENTE DE APROBACION') ||\n t.includes('PENDIENTE DE APROBACIÓN')\n ) {\n return 'PENDIENTE DE APROBACION';\n }\n\n return '';\n}\n\nfunction detectarAnios(texto) {\n const matches = norm(texto).match(/\\b(20\\d{2}|19\\d{2})\\b/g);\n return matches ? [...new Set(matches)] : [];\n}\n\nfunction detectarPaises(texto) {\n const t = norm(texto);\n\n const paises = [\n 'EL SALVADOR',\n 'PANAMA',\n 'REPUBLICA DOMINICANA',\n 'COLOMBIA',\n 'PUERTO RICO',\n 'HONDURAS',\n 'MEXICO',\n 'VENEZUELA',\n 'JAMAICA',\n 'TRINIDAD Y TOBAGO',\n 'COSTA RICA',\n 'NICARAGUA',\n 'GUATEMALA',\n ];\n\n const encontrados = [];\n\n for (const pais of paises) {\n if (t.includes(norm(pais))) {\n encontrados.push(pais);\n }\n }\n\n if (/\\bRD\\b/.test(t) || t.includes('REP DOM') || t.includes('DOMINICANA')) {\n if (!encontrados.includes('REPUBLICA DOMINICANA')) {\n encontrados.push('REPUBLICA DOMINICANA');\n }\n }\n\n return encontrados;\n}\n\nfunction detectarCanales(texto) {\n const t = norm(texto);\n\n const canales = [];\n\n if (t.includes('MODERNO')) canales.push('MODERNO');\n if (t.includes('TRADICIONAL')) canales.push('TRADICIONAL');\n if (t.includes('ONLINE') || t.includes('ON LINE')) canales.push('ONLINE');\n if (t.includes('PDV') || t.includes('PUNTO DE VENTA')) canales.push('PDV');\n if (t.includes('NO APLICA')) canales.push('NO APLICA');\n\n return canales;\n}\n\nfunction detectarInteresPropuestas(texto) {\n const t = norm(texto);\n\n return [\n 'PROPUESTA',\n 'PROPUESTAS',\n 'BANCO DE PROPUESTAS',\n 'CASO',\n 'CASOS',\n 'ACTIVACION',\n 'ACTIVACIONES',\n 'ACTIVACIÓN',\n 'CAMPAÑA',\n 'CAMPAÑAS',\n 'CAMPANA',\n 'CAMPANAS',\n 'PRESENTACION',\n 'PRESENTACIONES',\n 'PRESENTACIÓN',\n ].some(x => t.includes(norm(x)));\n}\n\nconst intencionEspecial = detectarIntencionEspecial(pregunta);\n\nif (intencionEspecial) {\n return [\n {\n json: {\n source_type: 'propuestas',\n pregunta,\n intencion_especial: intencionEspecial.tipo,\n tokens: [],\n cantidad_solicitada: null,\n quiere_ultimas: false,\n es_seguimiento: false,\n estado_solicitado: '',\n paises_solicitados: [],\n anios_solicitados: [],\n canales_solicitados: [],\n total_propuestas: rows.length,\n total_candidatos: 0,\n sin_resultados: false,\n candidatos: [],\n contexto_propuestas: '',\n prompt_usuario: intencionEspecial.prompt\n }\n }\n ];\n}\n\nconst cantidadSolicitada = extraerCantidad(pregunta);\nconst quiereUltimas = detectarUltimas(pregunta);\nconst esSeguimiento = detectarSeguimiento(pregunta);\nconst estadoSolicitado = detectarEstado(pregunta);\nconst aniosSolicitados = detectarAnios(pregunta);\nconst paisesSolicitados = detectarPaises(pregunta);\nconst canalesSolicitados = detectarCanales(pregunta);\nconst mencionaPropuestas = detectarInteresPropuestas(pregunta);\n\nconst stopwords = new Set([\n 'DAME', 'DARME', 'MUESTRA', 'MUESTRAME', 'BUSCA', 'BUSCAME',\n 'ENVIAME', 'ENVÍAME', 'QUIERO', 'NECESITO',\n 'PROPUESTA', 'PROPUESTAS', 'IDEA', 'IDEAS', 'DOCUMENTO', 'DOCUMENTOS',\n 'ULTIMA', 'ULTIMAS', 'ULTIMO', 'ULTIMOS', 'RECIENTE', 'RECIENTES',\n 'NUEVA', 'NUEVAS', 'NUEVO', 'NUEVOS',\n 'APROBADA', 'APROBADAS', 'PENDIENTE', 'PENDIENTES',\n 'PRESENTACION', 'PRESENTACIONES', 'PRESENTACIÓN',\n 'EJECUTADA', 'EJECUTADAS', 'EJECUCION', 'EJECUCIONES',\n 'EVIDENCIA', 'EVIDENCIAS',\n 'LINK', 'ENLACE',\n 'DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'PARA', 'POR', 'CON',\n 'EN', 'UN', 'UNA', 'Y', 'O', 'QUE', 'ME', 'TENGO', 'TIENES',\n 'ALGUNA', 'ALGUNAS', 'ALGUN', 'SOBRE', 'RELACIONADA', 'RELACIONADAS',\n 'ESAS', 'ESTAS', 'ESOS', 'ESTOS',\n]);\n\nconst tokens = preguntaNorm\n .split(/[^A-Z0-9Ñ]+/)\n .map(t => t.trim())\n .filter(t => t.length >= 3 && !stopwords.has(t))\n .filter(t => !aniosSolicitados.includes(t));\n\nfunction getYear(value) {\n const year = clean(value).match(/\\b(20\\d{2}|19\\d{2})\\b/);\n return year ? Number(year[1]) : 0;\n}\n\nfunction fieldContains(value, expected) {\n return norm(value).includes(norm(expected));\n}\n\nfunction matchesAny(value, expectedList) {\n if (!expectedList.length) return true;\n return expectedList.some(expected => fieldContains(value, expected));\n}\n\nfunction hardFilterPass(p) {\n if (paisesSolicitados.length && !matchesAny(p.PAIS, paisesSolicitados)) {\n return false;\n }\n\n if (aniosSolicitados.length && !aniosSolicitados.includes(String(p.ANIO))) {\n return false;\n }\n\n if (estadoSolicitado && norm(p.APROBADA) !== norm(estadoSolicitado)) {\n return false;\n }\n\n if (canalesSolicitados.length && !matchesAny(p.CANAL, canalesSolicitados)) {\n return false;\n }\n\n return true;\n}\n\nfunction scoreRow(row) {\n const enlacesEjecutadas = getEnlacesEjecutadas(row);\n\n const camposGenerales = [\n row.NOMBRE,\n row.CLIENTE,\n row.MARCA,\n row.PAIS,\n row['TIPO DE ACCION'],\n row.CANAL,\n row['AMBIENTE DE COMPRA (RE)'],\n row['TÁCTICA PROMOCIONAL'],\n row.APROBADA,\n row.ETIQUETAS,\n row['AÑO'],\n row.Descripcion,\n row.nombre_archivo,\n row['Enlace a la propuesta'],\n enlacesEjecutadas,\n ];\n\n const camposFuertes = [\n row.NOMBRE,\n row.CLIENTE,\n row.MARCA,\n row.PAIS,\n row['TIPO DE ACCION'],\n row.ETIQUETAS,\n row['AÑO'],\n ];\n\n const textoGeneral = norm(camposGenerales.join(' '));\n const textoFuerte = norm(camposFuertes.join(' '));\n\n let score = 0;\n\n for (const token of tokens) {\n if (textoGeneral.includes(token)) score += 1;\n if (textoFuerte.includes(token)) score += 2;\n }\n\n if (paisesSolicitados.length && matchesAny(row.PAIS, paisesSolicitados)) {\n score += 8;\n }\n\n if (aniosSolicitados.length && aniosSolicitados.includes(clean(row['AÑO']))) {\n score += 8;\n }\n\n if (estadoSolicitado && norm(row.APROBADA) === norm(estadoSolicitado)) {\n score += 8;\n }\n\n if (canalesSolicitados.length && matchesAny(row.CANAL, canalesSolicitados)) {\n score += 5;\n }\n\n if (enlacesEjecutadas) {\n score += 3;\n }\n\n return score;\n}\n\nconst propuestas = rows\n .map((item, index) => {\n const row = item.json || {};\n\n const fila =\n row.row_number ||\n row.__row_number ||\n row.__rowNumber ||\n row.rowNumber ||\n index + 2;\n\n const anio = clean(row['AÑO']);\n const enlacesEjecutadas = getEnlacesEjecutadas(row);\n\n return {\n fila,\n score: scoreRow(row),\n\n NOMBRE: clean(row.NOMBRE),\n CLIENTE: clean(row.CLIENTE),\n MARCA: clean(row.MARCA),\n PAIS: clean(row.PAIS),\n TIPO_DE_ACCION: clean(row['TIPO DE ACCION']),\n CANAL: clean(row.CANAL),\n AMBIENTE_DE_COMPRA: clean(row['AMBIENTE DE COMPRA (RE)']),\n TACTICA_PROMOCIONAL: clean(row['TÁCTICA PROMOCIONAL']),\n APROBADA: clean(row.APROBADA),\n ETIQUETAS: clean(row.ETIQUETAS),\n ANIO: anio,\n ANIO_NUM: getYear(anio),\n ENLACE: clean(row['Enlace a la propuesta']),\n ENLACES_EJECUTADAS: enlacesEjecutadas,\n DESCRIPCION: clean(row.Descripcion),\n };\n })\n .filter(p => p.NOMBRE || p.DESCRIPCION || p.ENLACE || p.ENLACES_EJECUTADAS);\n\nlet candidatos = propuestas\n .filter(p => hardFilterPass(p))\n .filter(p => {\n if (tokens.length > 0) return p.score > 0;\n\n if (\n paisesSolicitados.length ||\n aniosSolicitados.length ||\n estadoSolicitado ||\n canalesSolicitados.length\n ) {\n return true;\n }\n\n if (mencionaPropuestas) {\n return true;\n }\n\n return false;\n });\n\nif (candidatos.length === 0 && tokens.length === 0 && !esSeguimiento) {\n candidatos = [];\n}\n\nif (quiereUltimas) {\n candidatos = candidatos.sort((a, b) => b.fila - a.fila);\n} else {\n candidatos = candidatos.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n return b.fila - a.fila;\n });\n}\n\nconst limiteCandidatos = cantidadSolicitada\n ? Math.min(Math.max(cantidadSolicitada, 1), 25)\n : MAX_CANDIDATOS_DEFAULT;\n\ncandidatos = candidatos.slice(0, limiteCandidatos);\n\nconst contexto = candidatos.map((p, index) => {\n return [\n `RESULTADO ${index + 1}`,\n `Fila: ${p.fila}`,\n `Nombre: ${p.NOMBRE}`,\n `Cliente: ${p.CLIENTE}`,\n `Marca: ${p.MARCA}`,\n `País: ${p.PAIS}`,\n `Tipo de acción: ${p.TIPO_DE_ACCION}`,\n `Canal: ${p.CANAL}`,\n `Ambiente de compra: ${p.AMBIENTE_DE_COMPRA}`,\n `Táctica promocional: ${p.TACTICA_PROMOCIONAL}`,\n `Estado aprobación: ${p.APROBADA}`,\n `Etiquetas: ${p.ETIQUETAS}`,\n `Año: ${p.ANIO}`,\n `Descripción: ${p.DESCRIPCION}`,\n `Enlace: ${p.ENLACE}`,\n `Enlaces propuestas ejecutadas: ${p.ENLACES_EJECUTADAS}`,\n ].join('\\n');\n}).join('\\n\\n---\\n\\n');\n\nconst sinResultados = candidatos.length === 0;\n\nconst promptUsuario = `\nPregunta del usuario:\n${pregunta}\n\nInstrucción de búsqueda detectada:\n- Cantidad solicitada: ${cantidadSolicitada || 'No especificada'}\n- Quiere últimas/recientes: ${quiereUltimas ? 'Sí' : 'No'}\n- Es pregunta de seguimiento: ${esSeguimiento ? 'Sí' : 'No'}\n- Estado solicitado: ${estadoSolicitado || 'No especificado'}\n- Países solicitados: ${paisesSolicitados.length ? paisesSolicitados.join(', ') : 'No especificado'}\n- Años solicitados: ${aniosSolicitados.length ? aniosSolicitados.join(', ') : 'No especificado'}\n- Canales solicitados: ${canalesSolicitados.length ? canalesSolicitados.join(', ') : 'No especificado'}\n\nResultado de búsqueda:\n${sinResultados ? 'No se encontraron propuestas candidatas con los filtros detectados.' : 'Se encontraron propuestas candidatas.'}\n\nContexto encontrado en el banco de propuestas:\n${contexto || 'No se encontraron propuestas candidatas con la búsqueda.'}\n\nInstrucción para responder:\n${sinResultados\n ? 'Indica que no encontraste coincidencias. No inventes propuestas. Sugiere probar con otro cliente, marca, país, año, canal o tipo de acción.'\n : 'Responde usando únicamente las propuestas del contexto.'}\n`;\n\nreturn [\n {\n json: {\n source_type: 'propuestas',\n pregunta,\n tokens,\n cantidad_solicitada: cantidadSolicitada,\n quiere_ultimas: quiereUltimas,\n es_seguimiento: esSeguimiento,\n estado_solicitado: estadoSolicitado,\n paises_solicitados: paisesSolicitados,\n anios_solicitados: aniosSolicitados,\n canales_solicitados: canalesSolicitados,\n total_propuestas: propuestas.length,\n total_candidatos: candidatos.length,\n sin_resultados: sinResultados,\n intencion_especial: '',\n candidatos,\n contexto_propuestas: contexto,\n prompt_usuario: promptUsuario,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
304,
864
],
"id": "cd454ec5-035b-43ce-bb92-9a9e135ffbd3",
"name": "Code - Filtrar propuestas relevantes"
},
{
"parameters": {
"promptType": "define",
"text": "={{ $json.prompt_usuario }}",
"options": {
"systemMessage": "=Eres Fulgencio Fumado, asistente interno de creatividad, briefs y propuestas de GomezLee Marketing.\n\nTu función es responder preguntas usando únicamente el contexto entregado por el sistema.\n\nActualmente puedes recibir contexto de tres fuentes:\n\n1. Banco de propuestas:\nContiene propuestas originales, presentaciones base, activaciones, casos, campañas y referencias internas de GomezLee Marketing.\n\n2. CDC Briefs:\nContiene briefs, solicitudes creativas y requerimientos generados desde la app CDC Brief.\n\n3. Propuestas Ejecutadas / Externas:\nContiene reportes reales de ejecución, evidencias fotográficas, videos opcionales, presentaciones de ejecución generadas, propuestas externas recibidas y relaciones con propuestas del banco original cuando exista match.\n\nNo confundas ambas fuentes:\n- Una propuesta es un caso, presentación, activación o referencia interna del banco de propuestas.\n- Un brief es una solicitud o requerimiento creativo.\n- Si el usuario pregunta por propuestas, casos, activaciones, presentaciones o campañas, prioriza el banco de propuestas.\n- Si el usuario pregunta por briefs, solicitudes, requerimientos creativos, pedidos o temas registrados en CDC Brief, prioriza CDC Briefs.\n- Si hay información útil en ambas fuentes, separa la respuesta por secciones:\n Banco de propuestas\n CDC Briefs\n\nReglas obligatorias:\n1. No inventes propuestas, briefs, enlaces, países, clientes, marcas, años, estados, fechas ni solicitantes.\n2. Si no hay información suficiente en el contexto, dilo claramente.\n3. No digas que puedes crear imágenes. La creación de imágenes no está disponible actualmente en Fulgencio.\n4. No menciones n8n, workflows, Google Sheets, nodos, prompts, memoria técnica ni procesos internos.\n5. Responde en español, con tono profesional, cercano, directo y útil. Evita sonar demasiado formal o robótico.\n6. Cuando incluyas enlaces, ponlos completos.\n7. Si algún dato aparece como PENDIENTE, vacío o no disponible, no lo inventes; indica que ese dato está pendiente o no disponible.\n8. Usa únicamente la información entregada en el contexto o en la memoria reciente cuando sea una pregunta de seguimiento.\n9. No prometas enviar archivos, crear documentos, generar imágenes, modificar propuestas, actualizar información o hacer cambios en sistemas. Solo puedes consultar, resumir, comparar y sugerir usando el contexto disponible.\n10. Si el usuario combina varios filtros y hay pocos o ningún resultado, explica qué filtros se aplicaron y sugiere quitar o cambiar algún filtro.\n\nReglas para propuestas:\n- Si el usuario pide propuestas, entrega una lista clara con:\n - Nombre\n - Cliente/Marca\n - País\n - Año si está disponible\n - Tipo de acción si está disponible\n - Breve descripción\n - Enlace\n- Si una propuesta tiene enlace disponible, inclúyelo siempre.\n- Si no tiene enlace, indica “Enlace no disponible”.\n- Si el usuario pide una cantidad específica, respeta esa cantidad cuando el contexto la tenga disponible.\n- Si el usuario pide las últimas o más recientes, respeta el orden entregado en el contexto.\n- Cuando la instrucción de búsqueda detectada indique país, año, canal, estado o cantidad solicitada, respeta esos filtros estrictamente.\n- Si no hay candidatos de propuestas en el contexto, no inventes resultados. Indica que no encontraste coincidencias y sugiere ajustar la búsqueda por cliente, marca, país, año, canal, estado o tipo de acción.\n\nReglas para CDC Briefs:\n- Si el usuario pide briefs, solicitudes, requerimientos o pedidos creativos existentes, responde usando la sección CDC Briefs del contexto.\n- Cuando respondas sobre briefs, incluye cuando esté disponible:\n - Brief ID\n - Fecha de creación\n - Solicitante\n - Correo\n - Estado\n - Fecha de entrega\n - Resumen\n - Link del documento brief\n - Link de carpeta Drive\n- No trates un brief como si fuera una propuesta.\n- No trates una propuesta como si fuera un brief.\n- Si el usuario pide los últimos briefs o briefs recientes, respeta el orden entregado en el contexto.\n- Si no hay briefs candidatos en el contexto, indica que no encontraste briefs relacionados y sugiere buscar por brief ID, solicitante, tema, estado o fecha.\n- Si el usuario pide crear un brief, llenar un brief, hacer una nueva solicitud o abrir el formulario de brief, comparte este enlace:\nhttps://digitalcompass.agency/brief\n- Si el usuario solo pregunta por briefs existentes, no respondas únicamente con el enlace del formulario. Busca y responde con los briefs del contexto.\n\nReglas para Propuestas Ejecutadas / Externas:\n- Si el usuario pregunta por propuesta ejecutada, ejecución, evidencias, presentación ejecutada, presentación de ejecución, fotos, videos, implementación o propuesta externa, prioriza la fuente Propuestas Ejecutadas / Externas.\n- Si el usuario pide “Envíame la presentación de la propuesta ejecutada de X”, entrega el link de Presentación ejecución cuando esté disponible.\n- Si existe Propuesta original link, inclúyelo como referencia adicional.\n- Si existe Carpeta evidencias, inclúyela cuando el usuario pida evidencias, fotos, videos o soporte de la ejecución.\n- Si la ejecución tiene Resumen IA o Descripción ejecución, úsalo para dar un resumen claro.\n- Si hay varias ejecuciones relacionadas, muéstralas en lista y no elijas una sola sin aclararlo.\n- Si el registro es PROPUESTA_EXTERNA, aclara que es una propuesta externa registrada, no una propuesta original del banco.\n- Si el match es bajo, manual, pendiente o no determinado, no afirmes que corresponde definitivamente a una propuesta original.\n- No inventes presentaciones ejecutadas ni enlaces de evidencia.\n\nFormato recomendado:\n- Resumen breve al inicio.\n- Lista numerada cuando aplique.\n- Si usas ambas fuentes, separa en secciones:\n Banco de propuestas\n CDC Briefs\n- Cierre breve ofreciendo filtrar por país, marca, cliente, año, estado, tipo de acción, brief ID, solicitante o tema.\n\nFormato especial:\n- Si el usuario pide “tabla”, “formato tabla” o “en una tabla”, responde directamente en tabla Markdown.\n- Para propuestas en tabla, usa columnas útiles como:\n Nombre, Cliente/Marca, País, Año, Tipo de acción y Enlace.\n- Para briefs en tabla, usa columnas útiles como:\n Brief ID, Fecha, Solicitante, Estado, Fecha de entrega, Resumen y Enlace.\n- Si el usuario pide bullets, responde en bullets.\n\nAnálisis, patrones e ideas:\n- Si el usuario pide análisis, patrones, insights o ideas creativas, analiza únicamente la información entregada en el contexto.\n- Resume patrones repetidos, tipos de activación, mecánicas, formatos, canales, oportunidades y temas relevantes sin inventar datos fuera del contexto.\n- Si el usuario pide ideas nuevas, usa el contexto como referencia.\n- Aclara que son ideas inspiradas en propuestas o briefs existentes y no propuestas ya aprobadas, salvo que el contexto indique lo contrario.\n\nComparaciones:\n- Si el usuario pide comparar propuestas, organiza la respuesta por diferencias y similitudes:\n - Tipo de acción\n - País\n - Marca\n - Canal\n - Objetivo\n - Elementos creativos\n- Si el usuario pide comparar briefs, organiza la respuesta por:\n - Solicitante\n - Estado\n - Fecha de entrega\n - Objetivo o tema\n - Materiales o insumos disponibles\n - Diferencias principales\n- Usa solo el contexto disponible.\n\nSeguimiento y memoria:\n- Si el usuario dice “de esas”, “la segunda”, “la anterior”, “dame los enlaces”, “resume esas”, “de esos briefs” o “las que me mostraste”, usa la memoria reciente para entender la referencia.\n- Responde solo con información que aparezca en el contexto o memoria entregada.\n- No mezcles resultados nuevos con resultados de memoria si el usuario claramente se refiere a resultados ya mostrados.\n- Si no puedes identificar a qué se refiere, pide una aclaración breve.\n\nMensajes ambiguos:\n\n* Si el usuario envía un saludo, cortesía, confirmación o mensaje muy corto sin una intención clara de búsqueda, no muestres propuestas ni briefs específicos aunque exista contexto disponible.\n* Esto aplica a mensajes como “hola”, “hi”, “hello”, “buenas”, “hey”, “ok”, “gracias”, “thanks”, “listo”, “perfecto”, “dale”, “saludos” o mensajes similares.\n* En esos casos, responde con una bienvenida breve, explica cómo puede usar Fulgencio y da ejemplos de preguntas útiles.\n* No digas que revisaste el banco de propuestas ni CDC Briefs si el usuario solo saludó o envió una cortesía.\n* No incluyas propuestas, briefs, tablas, enlaces de archivos ni resultados específicos en respuestas a saludos o mensajes genéricos.\n\nEjemplos de preguntas útiles:\n\n1. Dame las 5 últimas propuestas de Motorola.\n2. Busca propuestas de Nestlé en República Dominicana.\n3. Dame propuestas aprobadas de activación en PDV.\n4. Tienes propuestas de Kit Kat en Costa Rica?\n5. Dame los últimos briefs creados.\n6. Busca briefs relacionados con Motorola.\n7. Necesito crear un brief.\n8. Necesito acceder al Tablero CDC.\n9. ¿Dónde registro un proyecto creativo?\n\n\nImágenes:\n- Si el usuario solicita crear o generar imágenes, indica que la creación de imágenes no está disponible actualmente en Fulgencio.\n- Ofrece buscar referencias, propuestas, briefs o ideas existentes dentro del contexto disponible.\n\nBrief nuevo:\n- Si el usuario pide crear un brief, llenar un brief, hacer una solicitud creativa o abrir el formulario, comparte este enlace:\nhttps://digitalcompass.agency/brief\n\nTablero CDC / Aprobaciones Proyectos:\n- Si el usuario pide acceder al Tablero CDC, abrir Aprobaciones Proyectos, entrar al tablero de proyectos o registrar un proyecto creativo, comparte este enlace:\nhttps://digitalcompass.agency/tablero-cdc/\n- Indica brevemente que corresponde al Tablero CDC / Aprobaciones Proyectos.\n- No afirmes que puedes crear, modificar, aprobar, eliminar o consultar proyectos dentro del Tablero CDC desde el chat.\n- No confundas el Tablero CDC con CDC Brief ni con el Banco de Propuestas.\n\nRestricción final:\nSi el contexto indica que no se encontraron candidatos, no inventes resultados. Responde que no encontraste coincidencias y sugiere ajustar la búsqueda según la fuente:\n- Para propuestas: cliente, marca, país, año, canal, estado o tipo de acción.\n- Para briefs: brief ID, solicitante, estado, fecha, tema o palabras clave del brief."
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 2,
"position": [
1104,
848
],
"id": "e8fa7895-75d2-4f0b-b0bb-7214ff424c9e",
"name": "AI Agent - Gemini Fulgencio"
},
{
"parameters": {
"modelName": "models/gemini-3.1-pro-preview",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"typeVersion": 1,
"position": [
1104,
1072
],
"id": "af32c118-9f2f-473b-89a6-cd4a3b653be6",
"name": "Google Gemini Chat Model",
"credentials": {
"googlePalmApi": {
"id": "jvsXYwL6IOoY2DBU",
"name": "Isaac - Gemini Api Pago"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const data = $json || {};\n\nlet respuesta =\n data.output ||\n data.text ||\n data.response ||\n data.content ||\n data.message ||\n '';\n\nrespuesta = String(respuesta || '').trim();\n\nif (!respuesta) {\n respuesta = 'No pude generar una respuesta con la información disponible.';\n}\n\nfunction limpiarMarkdownParaGoogleChat(texto) {\n return String(texto || '')\n // Links Markdown: [texto](url) -> texto: url\n .replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '$1: $2')\n\n // Negritas Markdown: **texto** -> texto\n .replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n\n // Negritas con __texto__ -> texto\n .replace(/__([^_]+)__/g, '$1')\n\n // Cursivas simples: *texto* -> texto\n .replace(/\\*([^*\\n]+)\\*/g, '$1')\n\n // Títulos Markdown: ### Título -> Título\n .replace(/^#{1,6}\\s+/gm, '')\n\n // Código inline: `texto` -> texto\n .replace(/`([^`]+)`/g, '$1')\n\n // Separadores Markdown\n .replace(/^\\s*---+\\s*$/gm, '')\n\n // Convertir bullets markdown a bullets limpios\n .replace(/^\\s*[-*]\\s+/gm, '• ')\n\n // Limpiar espacios antes de saltos\n .replace(/[ \\t]+\\n/g, '\\n')\n\n // Evitar demasiadas líneas vacías seguidas\n .replace(/\\n{4,}/g, '\\n\\n\\n')\n\n .trim();\n}\n\nrespuesta = limpiarMarkdownParaGoogleChat(respuesta);\n\nconst MAX_CHARS = 7000;\n\nif (respuesta.length > MAX_CHARS) {\n respuesta =\n respuesta.slice(0, MAX_CHARS) +\n '\\n\\nRespuesta recortada por longitud. Puedes pedirme un filtro más específico por cliente, marca, país, año, estado o tipo de acción.';\n}\n\nreturn {\n json: {\n respuesta\n }\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1456,
848
],
"id": "18c1db5a-5290-4901-8a56-100bf7ef9da8",
"name": "Code - Formatear respuesta Chat"
},
{
"parameters": {
"spaceId": "={{ $('Code - Preparar mensaje').first().json.space_name }}",
"messageUi": {
"text": "={{ $json.respuesta }}"
},
"additionalFields": {}
},
"type": "n8n-nodes-base.googleChat",
"typeVersion": 1,
"position": [
1664,
848
],
"id": "8f68c6cb-507b-4ecb-b767-5071ee81a6c9",
"name": "Chat - Enviar respuesta",
"webhookId": "0147a967-96b1-4410-8fc8-b15ccbce7995",
"credentials": {
"googleApi": {
"id": "u5FLl9Ysd4oNofFB",
"name": "Google Chat - Fulgencio Service Account"
}
}
},
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $('Code - Preparar mensaje').first().json.thread_name || ($('Code - Preparar mensaje').first().json.space_name + '::' + $('Code - Preparar mensaje').first().json.usuario_email) }}",
"contextWindowLength": 6
},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"position": [
1248,
1072
],
"id": "e6091e31-bcee-4afe-aad4-37fc8d3d0b18",
"name": "Simple Memory"
},
{
"parameters": {
"content": "## 💬 Entrada Google Chat\n\nRecibe el mensaje del usuario desde Google Chat, responde rápido con “procesando” y prepara los datos de la conversación: pregunta, usuario, espacio, hilo y session key.",
"height": 448,
"width": 608,
"color": 2
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-768,
688
],
"id": "72856778-43d5-4cf3-b1d1-3423f8dde3ba",
"name": "Sticky Note"
},
{
"parameters": {
"content": "## 🔎 Búsqueda y contexto de Fulgencio\n\nLee el banco de propuestas y el Sheet de CDC Briefs.\n\nFiltra candidatos relevantes antes de enviarlos a Gemini:\n- propuestas, casos, activaciones y campañas\n- briefs, solicitudes creativas y requerimientos\n\nLuego une ambas fuentes en un solo contexto, manteniéndolas separadas para no mezclar propuestas con briefs.",
"height": 944,
"width": 960,
"color": 4
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-32,
624
],
"id": "0490b0b2-83de-4962-b8eb-c832c97285f8",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "## 🤖 Respuesta con Gemini\n\nGemini responde usando solo el contexto filtrado y combinado.\n\nPuede responder sobre propuestas y CDC Briefs, manteniendo cada fuente separada cuando aplique.\n\nNo crea imágenes, no inventa datos y conserva enlaces reales cuando estén disponibles.",
"height": 544,
"width": 864,
"color": 5
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1040,
704
],
"id": "be8a8afc-da65-468f-a1fc-9002974d07f9",
"name": "Sticky Note2"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1h_d4l4FYkLvyy_Zp7bEQFKpy5hx6pVj9xZ98mjMHHSU",
"mode": "list",
"cachedResultName": "CDC Brief - Base para Fulgencio",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1h_d4l4FYkLvyy_Zp7bEQFKpy5hx6pVj9xZ98mjMHHSU/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": "gid=0",
"mode": "list",
"cachedResultName": "Briefs",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1h_d4l4FYkLvyy_Zp7bEQFKpy5hx6pVj9xZ98mjMHHSU/edit#gid=0"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
48,
1056
],
"id": "04aac58a-ef25-4df0-ac38-46d279db1ff1",
"name": "Sheets - Leer briefs CDC",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst MAX_BRIEFS_DEFAULT = 10;\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction norm(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nconst preguntaNorm = norm(pregunta);\n\nfunction extraerCantidad(texto) {\n const t = norm(texto);\n\n const numero = t.match(/\\b(\\d{1,2})\\b/);\n if (numero) return Number(numero[1]);\n\n const palabras = {\n UNA: 1,\n UNO: 1,\n DOS: 2,\n TRES: 3,\n CUATRO: 4,\n CINCO: 5,\n SEIS: 6,\n SIETE: 7,\n OCHO: 8,\n NUEVE: 9,\n DIEZ: 10,\n };\n\n for (const [palabra, valor] of Object.entries(palabras)) {\n if (t.includes(palabra)) return valor;\n }\n\n return null;\n}\n\nfunction detectarUltimas(texto) {\n const t = norm(texto);\n\n return [\n 'ULTIMA',\n 'ULTIMAS',\n 'ULTIMO',\n 'ULTIMOS',\n 'RECIENTE',\n 'RECIENTES',\n 'NUEVA',\n 'NUEVAS',\n 'NUEVO',\n 'NUEVOS',\n ].some(x => t.includes(x));\n}\n\nfunction detectarInteresBriefs(texto) {\n const t = norm(texto);\n\n return [\n 'BRIEF',\n 'BRIEFS',\n 'SOLICITUD',\n 'SOLICITUDES',\n 'REQUERIMIENTO',\n 'REQUERIMIENTOS',\n 'PEDIDO CREATIVO',\n 'CDC',\n 'FORMULARIO',\n ].some(x => t.includes(x));\n}\n\nfunction detectarInteresPropuestas(texto) {\n const t = norm(texto);\n\n return [\n 'PROPUESTA',\n 'PROPUESTAS',\n 'BANCO DE PROPUESTAS',\n 'CASO',\n 'CASOS',\n 'ACTIVACION',\n 'ACTIVACIONES',\n ].some(x => t.includes(x));\n}\n\nconst cantidadSolicitada = extraerCantidad(pregunta);\nconst quiereUltimas = detectarUltimas(pregunta);\nconst mencionaBriefs = detectarInteresBriefs(pregunta);\nconst mencionaPropuestas = detectarInteresPropuestas(pregunta);\n\nconst stopwords = new Set([\n 'DAME', 'DARME', 'MUESTRA', 'MUESTRAME', 'BUSCA', 'BUSCAME',\n 'BRIEF', 'BRIEFS', 'SOLICITUD', 'SOLICITUDES', 'REQUERIMIENTO', 'REQUERIMIENTOS',\n 'PROPUESTA', 'PROPUESTAS', 'DOCUMENTO', 'DOCUMENTOS',\n 'ULTIMA', 'ULTIMAS', 'ULTIMO', 'ULTIMOS', 'RECIENTE', 'RECIENTES',\n 'NUEVA', 'NUEVAS', 'NUEVO', 'NUEVOS',\n 'DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'PARA', 'POR', 'CON',\n 'EN', 'UN', 'UNA', 'Y', 'O', 'QUE', 'ME', 'TENGO', 'TIENES',\n 'ALGUNA', 'ALGUNAS', 'ALGUN', 'SOBRE', 'RELACIONADA', 'RELACIONADAS',\n 'CDC', 'FORMULARIO',\n]);\n\nconst tokens = preguntaNorm\n .split(/[^A-Z0-9Ñ]+/)\n .map(t => t.trim())\n .filter(t => t.length >= 3 && !stopwords.has(t));\n\nfunction getDateValue(value) {\n const raw = clean(value);\n const d = new Date(raw);\n return Number.isNaN(d.getTime()) ? 0 : d.getTime();\n}\n\nfunction scoreBrief(row) {\n const camposFuertes = [\n row.brief_id,\n row.nombre_carpeta,\n row.enviado_por,\n row.correo,\n row.estado,\n ];\n\n const camposGenerales = [\n row.brief_id,\n row.nombre_carpeta,\n row.enviado_por,\n row.correo,\n row.estado,\n row.resumen_chatbot,\n row.texto_brief,\n row.texto_busqueda,\n row.fecha_entrega,\n ];\n\n const textoFuerte = norm(camposFuertes.join(' '));\n const textoGeneral = norm(camposGenerales.join(' '));\n\n let score = 0;\n\n for (const token of tokens) {\n if (textoGeneral.includes(token)) score += 1;\n if (textoFuerte.includes(token)) score += 2;\n }\n\n if (mencionaBriefs) score += 3;\n\n return score;\n}\n\nconst debeBuscarBriefs = mencionaBriefs || (!mencionaPropuestas && tokens.length > 0);\n\nlet briefs = rows\n .map((item, index) => {\n const row = item.json || {};\n\n return {\n fila: row.row_number || row.__row_number || row.__rowNumber || row.rowNumber || index + 2,\n score: scoreBrief(row),\n\n fecha_creacion: clean(row.fecha_creacion),\n brief_id: clean(row.brief_id),\n nombre_carpeta: clean(row.nombre_carpeta),\n enviado_por: clean(row.enviado_por),\n correo: clean(row.correo),\n link_carpeta_drive: clean(row.link_carpeta_drive),\n link_documento_brief: clean(row.link_documento_brief),\n estado: clean(row.estado),\n cantidad_audios: clean(row.cantidad_audios),\n cantidad_imagenes: clean(row.cantidad_imagenes),\n cantidad_documentos: clean(row.cantidad_documentos),\n cantidad_links: clean(row.cantidad_links),\n resumen_chatbot: clean(row.resumen_chatbot),\n texto_brief: clean(row.texto_brief),\n texto_busqueda: clean(row.texto_busqueda),\n fecha_entrega: clean(row.fecha_entrega),\n\n fecha_sort: getDateValue(row.fecha_creacion),\n };\n })\n .filter(b => b.brief_id || b.nombre_carpeta || b.texto_brief || b.resumen_chatbot);\n\nlet candidatos = [];\n\nif (debeBuscarBriefs) {\n candidatos = briefs.filter(b => {\n if (tokens.length > 0) return b.score > 0;\n if (mencionaBriefs) return true;\n return false;\n });\n}\n\nif (quiereUltimas) {\n candidatos = candidatos.sort((a, b) => {\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n} else {\n candidatos = candidatos.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n}\n\nconst limite = cantidadSolicitada\n ? Math.min(Math.max(cantidadSolicitada, 1), 25)\n : MAX_BRIEFS_DEFAULT;\n\ncandidatos = candidatos.slice(0, limite);\n\nconst contextoBriefs = candidatos.map((b, index) => {\n return [\n `BRIEF ${index + 1}`,\n `Fila: ${b.fila}`,\n `Fecha creación: ${b.fecha_creacion}`,\n `Brief ID: ${b.brief_id}`,\n `Nombre carpeta: ${b.nombre_carpeta}`,\n `Enviado por: ${b.enviado_por}`,\n `Correo: ${b.correo}`,\n `Estado: ${b.estado}`,\n `Fecha entrega: ${b.fecha_entrega}`,\n `Cantidad audios: ${b.cantidad_audios}`,\n `Cantidad imágenes: ${b.cantidad_imagenes}`,\n `Cantidad documentos: ${b.cantidad_documentos}`,\n `Cantidad links: ${b.cantidad_links}`,\n `Resumen chatbot: ${b.resumen_chatbot}`,\n `Texto brief: ${b.texto_brief}`,\n `Texto búsqueda: ${b.texto_busqueda}`,\n `Link carpeta Drive: ${b.link_carpeta_drive}`,\n `Link documento brief: ${b.link_documento_brief}`,\n ].join('\\n');\n}).join('\\n\\n---\\n\\n');\n\nreturn [\n {\n json: {\n source_type: 'briefs_cdc',\n pregunta,\n tokens,\n menciona_briefs: mencionaBriefs,\n menciona_propuestas: mencionaPropuestas,\n cantidad_solicitada: cantidadSolicitada,\n quiere_ultimas: quiereUltimas,\n total_briefs: briefs.length,\n total_briefs_candidatos: candidatos.length,\n briefs_candidatos: candidatos,\n contexto_briefs: contextoBriefs,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
256,
1056
],
"id": "d44ec6a2-e62a-4a0d-a976-2df5408d2621",
"name": "Code - Filtrar briefs CDC"
},
{
"parameters": {
"jsCode": "const items = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst propuestasItem =\n items.find(i => i.json.source_type === 'propuestas')?.json || {};\n\nconst briefsItem =\n items.find(i => i.json.source_type === 'briefs_cdc')?.json || {};\n\nconst ejecucionesItem =\n items.find(i => i.json.source_type === 'propuestas_ejecutadas')?.json || {};\n\nif (propuestasItem.intencion_especial) {\n return [\n {\n json: {\n ...propuestasItem,\n prompt_usuario: propuestasItem.prompt_usuario,\n total_fuentes: 1,\n fuentes_usadas: ['intencion_especial'],\n },\n },\n ];\n}\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction construirContextoPropuestas(candidatos = []) {\n if (!Array.isArray(candidatos) || candidatos.length === 0) return '';\n\n return candidatos.map((p, index) => {\n return [\n `PROPUESTA ${index + 1}`,\n `Fila: ${clean(p.fila)}`,\n `Nombre: ${clean(p.NOMBRE)}`,\n `Cliente: ${clean(p.CLIENTE)}`,\n `Marca: ${clean(p.MARCA)}`,\n `País: ${clean(p.PAIS)}`,\n `Tipo de acción: ${clean(p.TIPO_DE_ACCION)}`,\n `Canal: ${clean(p.CANAL)}`,\n `Ambiente de compra: ${clean(p.AMBIENTE_DE_COMPRA)}`,\n `Táctica promocional: ${clean(p.TACTICA_PROMOCIONAL)}`,\n `Estado aprobación: ${clean(p.APROBADA)}`,\n `Etiquetas: ${clean(p.ETIQUETAS)}`,\n `Año: ${clean(p.ANIO)}`,\n `Descripción: ${clean(p.DESCRIPCION)}`,\n `Enlace: ${clean(p.ENLACE)}`,\n `Enlaces propuestas ejecutadas: ${clean(p.ENLACES_EJECUTADAS)}`,\n ].join('\\n');\n }).join('\\n\\n---\\n\\n');\n}\n\nfunction construirContextoEjecuciones(candidatos = []) {\n if (!Array.isArray(candidatos) || candidatos.length === 0) return '';\n\n return candidatos.map((e, index) => {\n return [\n `EJECUCION ${index + 1}`,\n `Fila: ${clean(e.fila)}`,\n `Ejecución ID: ${clean(e.ejecucion_id)}`,\n `Tipo reporte: ${clean(e.tipo_reporte || e.tipo_normalizado)}`,\n `Fecha recepción: ${clean(e.fecha_recepcion)}`,\n `Fecha ejecución: ${clean(e.fecha_ejecucion)}`,\n `Manager: ${clean(e.manager_nombre)}`,\n `Propuesta referencia: ${clean(e.propuesta_referencia)}`,\n `Match estado: ${clean(e.propuesta_match_estado)}`,\n `Match confianza: ${clean(e.propuesta_match_confianza)}`,\n `Propuesta nombre banco: ${clean(e.propuesta_nombre_banco)}`,\n `Propuesta original link: ${clean(e.propuesta_link_banco)}`,\n `Marca: ${clean(e.marca)}`,\n `Cliente: ${clean(e.cliente)}`,\n `País: ${clean(e.pais)}`,\n `Ubicación: ${clean(e.ubicacion)}`,\n `Resumen IA: ${clean(e.resumen_ia)}`,\n `Descripción ejecución: ${clean(e.descripcion_ejecucion)}`,\n `Elementos detectados: ${clean(e.elementos_detectados)}`,\n `Tags: ${clean(e.tags)}`,\n `Fotos: ${clean(e.fotos_count)}`,\n `Videos: ${clean(e.videos_count)}`,\n `Audios: ${clean(e.audios_count)}`,\n `Carpeta evidencias: ${clean(e.media_folder_url)}`,\n `Presentación ejecución: ${clean(e.presentacion_ejecucion_url)}`,\n `Estado revisión: ${clean(e.estado_revision)}`,\n `Motivo revisión: ${clean(e.motivo_revision)}`,\n ].join('\\n');\n }).join('\\n\\n---\\n\\n');\n}\n\nconst contextoPropuestas =\n clean(propuestasItem.contexto_propuestas) ||\n construirContextoPropuestas(propuestasItem.candidatos || []);\n\nconst contextoBriefs = clean(briefsItem.contexto_briefs);\n\nconst contextoEjecuciones =\n clean(ejecucionesItem.contexto_ejecuciones) ||\n construirContextoEjecuciones(ejecucionesItem.ejecuciones_candidatas || []);\n\nconst totalPropuestas = Number(propuestasItem.total_candidatos || 0);\nconst totalBriefs = Number(briefsItem.total_briefs_candidatos || 0);\nconst totalEjecuciones = Number(ejecucionesItem.total_ejecuciones_candidatas || 0);\n\nconst sinResultados =\n totalPropuestas === 0 &&\n totalBriefs === 0 &&\n totalEjecuciones === 0;\n\nconst promptUsuario = `\nPregunta del usuario:\n${pregunta}\n\nFuentes disponibles para responder:\n\n1. Banco de propuestas:\n - Contiene propuestas originales, casos, presentaciones, activaciones y referencias internas de GomezLee Marketing.\n - Total de candidatos encontrados: ${totalPropuestas}\n\n2. CDC Briefs:\n - Contiene briefs y solicitudes creativas generadas desde la app CDC Brief.\n - Total de candidatos encontrados: ${totalBriefs}\n\n3. Propuestas Ejecutadas / Externas:\n - Contiene reportes reales de ejecución, evidencias, propuestas externas recibidas y presentaciones generadas desde WhatsApp.\n - Total de candidatos encontrados: ${totalEjecuciones}\n\nReglas para elegir fuente:\n- Si el usuario pregunta por propuestas originales, casos, activaciones o presentaciones base, prioriza el Banco de propuestas.\n- Si el usuario pregunta por briefs, solicitudes, requerimientos creativos, pedidos o formularios CDC, prioriza CDC Briefs.\n- Si el usuario pregunta por propuesta ejecutada, ejecución, evidencias, presentación de ejecución, fotos, videos, propuesta externa o link de ejecución, prioriza Propuestas Ejecutadas / Externas.\n- Si el usuario pide “la presentación de la propuesta ejecutada de X”, busca primero en Propuestas Ejecutadas / Externas.\n- Si una ejecución está relacionada con una propuesta original, puedes mostrar ambos enlaces: presentación ejecutada y propuesta original.\n- Si hay información útil en varias fuentes, separa la respuesta por secciones.\n- No mezcles un brief con una propuesta.\n- No mezcles una propuesta original con una propuesta ejecutada como si fueran lo mismo.\n- Usa únicamente la información entregada abajo.\n- No inventes enlaces ni datos.\n\nResultado de búsqueda:\n${sinResultados ? 'No se encontraron candidatos en ninguna fuente.' : 'Se encontraron candidatos en una o más fuentes.'}\n\nContexto del Banco de propuestas:\n${contextoPropuestas || 'No se encontraron propuestas candidatas.'}\n\nContexto de CDC Briefs:\n${contextoBriefs || 'No se encontraron briefs candidatos.'}\n\nContexto de Propuestas Ejecutadas / Externas:\n${contextoEjecuciones || 'No se encontraron propuestas ejecutadas o externas candidatas.'}\n\nInstrucción para responder:\n${sinResultados\n ? 'Indica que no encontraste coincidencias. Sugiere buscar por cliente, marca, país, año, tipo de acción, estado, brief ID, solicitante, tema del brief, nombre de propuesta ejecutada o propuesta externa.'\n : 'Responde usando únicamente el contexto anterior. Si usas varias fuentes, separa la respuesta por secciones.'}\n`;\n\nreturn [\n {\n json: {\n pregunta,\n\n source_type: 'contexto_fulgencio_combinado',\n\n total_propuestas_candidatas: totalPropuestas,\n total_briefs_candidatos: totalBriefs,\n total_ejecuciones_candidatas: totalEjecuciones,\n sin_resultados: sinResultados,\n\n propuestas: propuestasItem.candidatos || [],\n briefs: briefsItem.briefs_candidatos || [],\n ejecuciones: ejecucionesItem.ejecuciones_candidatas || [],\n\n contexto_propuestas: contextoPropuestas,\n contexto_briefs: contextoBriefs,\n contexto_ejecuciones: contextoEjecuciones,\n\n prompt_usuario: promptUsuario,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
768,
896
],
"id": "12528b2b-1d6e-43f6-9573-5b7dccd8cab6",
"name": "Code - Construir contexto Fulgencio"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1nbeuaW2lxvWJYVFD-E0uJRhTGzUa-nuH2WYItHdTsKk",
"mode": "list",
"cachedResultName": "Fulgencio - Propuestas Ejecutadas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nbeuaW2lxvWJYVFD-E0uJRhTGzUa-nuH2WYItHdTsKk/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 737153956,
"mode": "list",
"cachedResultName": "propuestas_ejecutadas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nbeuaW2lxvWJYVFD-E0uJRhTGzUa-nuH2WYItHdTsKk/edit#gid=737153956"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
64,
1280
],
"id": "96b7e960-b6b2-4a19-8707-31ee42c6cfcd",
"name": "Sheets - Leer propuestas ejecutadas",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all();\n\nconst pregunta = $('Code - Preparar mensaje').first().json.pregunta || '';\n\nconst MAX_EJECUCIONES_DEFAULT = 15;\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction norm(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nconst preguntaNorm = norm(pregunta);\n\nfunction extraerCantidad(texto) {\n const t = norm(texto);\n\n const numero = t.match(/\\b(\\d{1,2})\\b/);\n if (numero) return Number(numero[1]);\n\n const palabras = {\n UNA: 1,\n UNO: 1,\n DOS: 2,\n TRES: 3,\n CUATRO: 4,\n CINCO: 5,\n SEIS: 6,\n SIETE: 7,\n OCHO: 8,\n NUEVE: 9,\n DIEZ: 10,\n QUINCE: 15,\n VEINTE: 20,\n };\n\n for (const [palabra, valor] of Object.entries(palabras)) {\n if (t.includes(palabra)) return valor;\n }\n\n return null;\n}\n\nfunction detectarUltimas(texto) {\n const t = norm(texto);\n\n return [\n 'ULTIMA',\n 'ULTIMAS',\n 'ULTIMO',\n 'ULTIMOS',\n 'RECIENTE',\n 'RECIENTES',\n 'NUEVA',\n 'NUEVAS',\n 'NUEVO',\n 'NUEVOS',\n ].some(x => t.includes(x));\n}\n\nfunction detectarInteresEjecuciones(texto) {\n const t = norm(texto);\n\n return [\n 'EJECUTADA',\n 'EJECUTADAS',\n 'EJECUCION',\n 'EJECUCIONES',\n 'EVIDENCIA',\n 'EVIDENCIAS',\n 'PROPUESTA EJECUTADA',\n 'PROPUESTAS EJECUTADAS',\n 'PRESENTACION EJECUTADA',\n 'PRESENTACION DE EJECUCION',\n 'PRESENTACION DE LA EJECUCION',\n 'MUESTRAME LA EJECUCION',\n 'ENVIAME LA PRESENTACION',\n 'LINK DE LA EJECUCION',\n 'PROPUESTA EXTERNA',\n 'PROPUESTAS EXTERNAS',\n 'EXTERNA',\n 'EXTERNAS',\n 'FOTOS',\n 'VIDEOS',\n 'IMPLEMENTACION',\n 'IMPLEMENTADA',\n 'IMPLEMENTADAS',\n ].some(x => t.includes(norm(x)));\n}\n\nfunction detectarTipoSolicitado(texto) {\n const t = norm(texto);\n\n if (\n t.includes('PROPUESTA EXTERNA') ||\n t.includes('PROPUESTAS EXTERNAS') ||\n t.includes(' EXTERNA') ||\n t.includes(' EXTERNAS')\n ) {\n return 'PROPUESTA_EXTERNA';\n }\n\n if (\n t.includes('PROPUESTA EJECUTADA') ||\n t.includes('PROPUESTAS EJECUTADAS') ||\n t.includes('EJECUCION') ||\n t.includes('EJECUCIONES') ||\n t.includes('EJECUTADA') ||\n t.includes('EJECUTADAS')\n ) {\n return 'PROPUESTA_EJECUTADA';\n }\n\n return '';\n}\n\nfunction detectarPaises(texto) {\n const t = norm(texto);\n\n const paises = [\n 'EL SALVADOR',\n 'PANAMA',\n 'REPUBLICA DOMINICANA',\n 'COLOMBIA',\n 'PUERTO RICO',\n 'HONDURAS',\n 'MEXICO',\n 'VENEZUELA',\n 'JAMAICA',\n 'TRINIDAD Y TOBAGO',\n 'COSTA RICA',\n 'NICARAGUA',\n 'GUATEMALA',\n ];\n\n const encontrados = [];\n\n for (const pais of paises) {\n if (t.includes(norm(pais))) {\n encontrados.push(pais);\n }\n }\n\n if (/\\\\bRD\\\\b/.test(t) || t.includes('REP DOM') || t.includes('DOMINICANA')) {\n if (!encontrados.includes('REPUBLICA DOMINICANA')) {\n encontrados.push('REPUBLICA DOMINICANA');\n }\n }\n\n return encontrados;\n}\n\nfunction detectarAnios(texto) {\n const matches = norm(texto).match(/\\b(20\\d{2}|19\\d{2})\\b/g);\n return matches ? [...new Set(matches)] : [];\n}\n\nconst cantidadSolicitada = extraerCantidad(pregunta);\nconst quiereUltimas = detectarUltimas(pregunta);\nconst mencionaEjecuciones = detectarInteresEjecuciones(pregunta);\nconst tipoSolicitado = detectarTipoSolicitado(pregunta);\nconst paisesSolicitados = detectarPaises(pregunta);\nconst aniosSolicitados = detectarAnios(pregunta);\n\nconst stopwords = new Set([\n 'DAME', 'DARME', 'MUESTRA', 'MUESTRAME', 'BUSCA', 'BUSCAME',\n 'ENVIAME', 'ENVIAR', 'QUIERO', 'NECESITO',\n 'PROPUESTA', 'PROPUESTAS', 'EJECUTADA', 'EJECUTADAS',\n 'EJECUCION', 'EJECUCIONES', 'PRESENTACION', 'PRESENTACIONES',\n 'EXTERNA', 'EXTERNAS', 'EVIDENCIA', 'EVIDENCIAS',\n 'LINK', 'ENLACE', 'RESUMEN',\n 'ULTIMA', 'ULTIMAS', 'ULTIMO', 'ULTIMOS', 'RECIENTE', 'RECIENTES',\n 'NUEVA', 'NUEVAS', 'NUEVO', 'NUEVOS',\n 'DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'PARA', 'POR', 'CON',\n 'EN', 'UN', 'UNA', 'Y', 'O', 'QUE', 'ME', 'TIENES', 'TENGO',\n 'ALGUNA', 'ALGUNAS', 'ALGUN', 'SOBRE', 'RELACIONADA', 'RELACIONADAS',\n]);\n\nconst tokens = preguntaNorm\n .split(/[^A-Z0-9Ñ]+/)\n .map(t => t.trim())\n .filter(t => t.length >= 3 && !stopwords.has(t))\n .filter(t => !aniosSolicitados.includes(t));\n\nfunction getDateValue(...values) {\n for (const value of values) {\n const raw = clean(value);\n if (!raw) continue;\n\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.getTime();\n }\n\n return 0;\n}\n\nfunction fieldContains(value, expected) {\n return norm(value).includes(norm(expected));\n}\n\nfunction matchesAny(value, expectedList) {\n if (!expectedList.length) return true;\n return expectedList.some(expected => fieldContains(value, expected));\n}\n\nfunction normalizarTipo(row) {\n const campos = [\n row.tipo_reporte,\n row.clasificacion_reporte,\n row.propuesta_match_estado,\n row.estado_revision,\n row.motivo_revision,\n row.propuesta_match_revision,\n ].map(norm).join(' ');\n\n if (\n campos.includes('PROPUESTA_EXTERNA') ||\n campos.includes('EXTERNA') ||\n campos.includes('SIN MATCH') ||\n campos.includes('MATCH BAJO')\n ) {\n return 'PROPUESTA_EXTERNA';\n }\n\n if (\n campos.includes('PROPUESTA_EJECUTADA') ||\n campos.includes('MATCH ALTO') ||\n campos.includes('MATCH_ALTO') ||\n campos.includes('EJECUTADA')\n ) {\n return 'PROPUESTA_EJECUTADA';\n }\n\n return clean(row.tipo_reporte || row.clasificacion_reporte || row.propuesta_match_estado || '');\n}\n\nfunction hardFilterPass(e) {\n if (tipoSolicitado === 'PROPUESTA_EXTERNA' && e.tipo_normalizado !== 'PROPUESTA_EXTERNA') {\n return false;\n }\n\n if (tipoSolicitado === 'PROPUESTA_EJECUTADA' && e.tipo_normalizado === 'PROPUESTA_EXTERNA') {\n return false;\n }\n\n if (paisesSolicitados.length && !matchesAny(e.pais, paisesSolicitados)) {\n return false;\n }\n\n if (aniosSolicitados.length) {\n const textoFechas = norm(`${e.fecha_recepcion} ${e.fecha_ejecucion} ${e.fecha_sort_raw}`);\n const tieneAnio = aniosSolicitados.some(anio => textoFechas.includes(anio));\n if (!tieneAnio) return false;\n }\n\n return true;\n}\n\nfunction scoreRow(row) {\n const camposFuertes = [\n row.propuesta_nombre_banco,\n row.propuesta_referencia,\n row.propuesta_nombre,\n row.nombre_propuesta,\n row.marca,\n row.cliente,\n row.pais,\n row.ubicacion,\n ];\n\n const camposGenerales = [\n row.propuesta_nombre_banco,\n row.propuesta_referencia,\n row.propuesta_nombre,\n row.nombre_propuesta,\n row.propuesta_link_banco,\n row.marca,\n row.cliente,\n row.pais,\n row.ubicacion,\n row.comentario_original,\n row.resumen_ia,\n row.descripcion_ejecucion,\n row.elementos_detectados,\n row.tags,\n row.transcripcion_audio,\n row.tipo_reporte,\n row.propuesta_match_estado,\n row.propuesta_match_revision,\n row.estado_revision,\n row.motivo_revision,\n ];\n\n const textoFuerte = norm(camposFuertes.join(' '));\n const textoGeneral = norm(camposGenerales.join(' '));\n\n let score = 0;\n\n for (const token of tokens) {\n if (textoGeneral.includes(token)) score += 1;\n if (textoFuerte.includes(token)) score += 3;\n }\n\n if (mencionaEjecuciones) score += 4;\n\n if (tipoSolicitado) {\n score += 5;\n }\n\n if (paisesSolicitados.length && matchesAny(row.pais, paisesSolicitados)) {\n score += 8;\n }\n\n if (clean(row.presentacion_ejecucion_url)) {\n score += 4;\n }\n\n if (clean(row.propuesta_nombre_banco)) {\n score += 3;\n }\n\n return score;\n}\n\nconst ejecuciones = rows\n .map((item, index) => {\n const row = item.json || {};\n\n const fila =\n row.row_number ||\n row.__row_number ||\n row.__rowNumber ||\n row.rowNumber ||\n index + 2;\n\n const tipoNormalizado = normalizarTipo(row);\n\n const fechaSort = getDateValue(\n row.fecha_ejecucion,\n row.fecha_recepcion,\n row.ultima_actualizacion\n );\n\n return {\n fila,\n score: scoreRow(row),\n\n ejecucion_id: clean(row.ejecucion_id),\n session_id: clean(row.session_id),\n\n tipo_reporte: clean(row.tipo_reporte || row.clasificacion_reporte),\n tipo_normalizado: tipoNormalizado,\n\n fecha_recepcion: clean(row.fecha_recepcion),\n fecha_ejecucion: clean(row.fecha_ejecucion),\n fecha_sort,\n fecha_sort_raw: clean(row.fecha_ejecucion || row.fecha_recepcion || row.ultima_actualizacion),\n\n manager_nombre: clean(row.manager_nombre),\n manager_telefono: clean(row.manager_telefono),\n\n propuesta_referencia: clean(row.propuesta_referencia),\n propuesta_match_estado: clean(row.propuesta_match_estado),\n propuesta_match_confianza: clean(row.propuesta_match_confianza),\n propuesta_nombre_banco: clean(row.propuesta_nombre_banco),\n propuesta_link_banco: clean(row.propuesta_link_banco),\n propuesta_match_revision: clean(row.propuesta_match_revision),\n\n marca: clean(row.marca),\n cliente: clean(row.cliente),\n pais: clean(row.pais),\n ubicacion: clean(row.ubicacion),\n\n comentario_original: clean(row.comentario_original),\n resumen_ia: clean(row.resumen_ia),\n descripcion_ejecucion: clean(row.descripcion_ejecucion),\n elementos_detectados: clean(row.elementos_detectados),\n tags: clean(row.tags),\n transcripcion_audio: clean(row.transcripcion_audio),\n\n media_folder_url: clean(row.media_folder_url),\n presentacion_ejecucion_url: clean(row.presentacion_ejecucion_url),\n\n fotos_count: clean(row.fotos_count),\n videos_count: clean(row.videos_count),\n audios_count: clean(row.audios_count),\n\n estado_revision: clean(row.estado_revision),\n motivo_revision: clean(row.motivo_revision),\n canal_origen: clean(row.canal_origen),\n };\n })\n .filter(e =>\n e.ejecucion_id ||\n e.presentacion_ejecucion_url ||\n e.resumen_ia ||\n e.descripcion_ejecucion ||\n e.propuesta_nombre_banco\n );\n\nlet candidatos = ejecuciones\n .filter(hardFilterPass)\n .filter(e => {\n if (tokens.length > 0) return e.score > 0;\n if (mencionaEjecuciones) return true;\n if (tipoSolicitado) return true;\n return false;\n });\n\nif (quiereUltimas) {\n candidatos = candidatos.sort((a, b) => {\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n} else {\n candidatos = candidatos.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n if (b.fecha_sort !== a.fecha_sort) return b.fecha_sort - a.fecha_sort;\n return b.fila - a.fila;\n });\n}\n\nconst limite = cantidadSolicitada\n ? Math.min(Math.max(cantidadSolicitada, 1), 25)\n : MAX_EJECUCIONES_DEFAULT;\n\ncandidatos = candidatos.slice(0, limite);\n\nconst contextoEjecuciones = candidatos.map((e, index) => {\n return [\n `EJECUCION ${index + 1}`,\n `Fila: ${e.fila}`,\n `Ejecución ID: ${e.ejecucion_id}`,\n `Tipo reporte: ${e.tipo_reporte || e.tipo_normalizado}`,\n `Fecha recepción: ${e.fecha_recepcion}`,\n `Fecha ejecución: ${e.fecha_ejecucion}`,\n `Manager: ${e.manager_nombre}`,\n `Propuesta referencia: ${e.propuesta_referencia}`,\n `Match estado: ${e.propuesta_match_estado}`,\n `Match confianza: ${e.propuesta_match_confianza}`,\n `Propuesta nombre banco: ${e.propuesta_nombre_banco}`,\n `Propuesta original link: ${e.propuesta_link_banco}`,\n `Marca: ${e.marca}`,\n `Cliente: ${e.cliente}`,\n `País: ${e.pais}`,\n `Ubicación: ${e.ubicacion}`,\n `Resumen IA: ${e.resumen_ia}`,\n `Descripción ejecución: ${e.descripcion_ejecucion}`,\n `Elementos detectados: ${e.elementos_detectados}`,\n `Tags: ${e.tags}`,\n `Fotos: ${e.fotos_count}`,\n `Videos: ${e.videos_count}`,\n `Audios: ${e.audios_count}`,\n `Carpeta evidencias: ${e.media_folder_url}`,\n `Presentación ejecución: ${e.presentacion_ejecucion_url}`,\n `Estado revisión: ${e.estado_revision}`,\n `Motivo revisión: ${e.motivo_revision}`,\n ].join('\\n');\n}).join('\\n\\n---\\n\\n');\n\nreturn [\n {\n json: {\n source_type: 'propuestas_ejecutadas',\n pregunta,\n tokens,\n menciona_ejecuciones: mencionaEjecuciones,\n tipo_solicitado: tipoSolicitado,\n cantidad_solicitada: cantidadSolicitada,\n quiere_ultimas: quiereUltimas,\n paises_solicitados: paisesSolicitados,\n anios_solicitados: aniosSolicitados,\n total_ejecuciones: ejecuciones.length,\n total_ejecuciones_candidatas: candidatos.length,\n ejecuciones_candidatas: candidatos,\n contexto_ejecuciones: contextoEjecuciones,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
272,
1280
],
"id": "79aa045d-603c-44b3-abcc-f58471fac1a4",
"name": "Code - Filtrar propuestas ejecutadas relevantes"
},
{
"parameters": {
"numberInputs": 3
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
560,
896
],
"id": "722b0de9-c0ad-4ee9-981c-390df1c9b6c9",
"name": "Merge - Unir propuestas y briefs Fulgencio"
},
{
"parameters": {
"httpMethod": "POST",
"path": "googlechat",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-720,
864
],
"id": "511df9a0-6f14-4141-b277-bfbf04ac8967",
"name": "Webhook1",
"webhookId": "85f57925-7a74-4686-9b6b-1d022ec8bc37"
}
],
"connections": {
"Respond to Webhook": {
"main": [
[
{
"node": "Code - Preparar mensaje",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar mensaje": {
"main": [
[
{
"node": "Sheets - Leer propuestas",
"type": "main",
"index": 0
},
{
"node": "Sheets - Leer briefs CDC",
"type": "main",
"index": 0
},
{
"node": "Sheets - Leer propuestas ejecutadas",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Leer propuestas": {
"main": [
[
{
"node": "Code - Filtrar propuestas relevantes",
"type": "main",
"index": 0
}
]
]
},
"Code - Filtrar propuestas relevantes": {
"main": [
[
{
"node": "Merge - Unir propuestas y briefs Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"AI Agent - Gemini Fulgencio": {
"main": [
[
{
"node": "Code - Formatear respuesta Chat",
"type": "main",
"index": 0
}
]
]
},
"Google Gemini Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent - Gemini Fulgencio",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Code - Formatear respuesta Chat": {
"main": [
[
{
"node": "Chat - Enviar respuesta",
"type": "main",
"index": 0
}
]
]
},
"Chat - Enviar respuesta": {
"main": [
[]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent - Gemini Fulgencio",
"type": "ai_memory",
"index": 0
}
]
]
},
"Sheets - Leer briefs CDC": {
"main": [
[
{
"node": "Code - Filtrar briefs CDC",
"type": "main",
"index": 0
}
]
]
},
"Code - Filtrar briefs CDC": {
"main": [
[
{
"node": "Merge - Unir propuestas y briefs Fulgencio",
"type": "main",
"index": 1
}
]
]
},
"Code - Construir contexto Fulgencio": {
"main": [
[
{
"node": "AI Agent - Gemini Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Leer propuestas ejecutadas": {
"main": [
[
{
"node": "Code - Filtrar propuestas ejecutadas relevantes",
"type": "main",
"index": 0
}
]
]
},
"Merge - Unir propuestas y briefs Fulgencio": {
"main": [
[
{
"node": "Code - Construir contexto Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"Code - Filtrar propuestas ejecutadas relevantes": {
"main": [
[
{
"node": "Merge - Unir propuestas y briefs Fulgencio",
"type": "main",
"index": 2
}
]
]
},
"Webhook1": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version ab4d8488",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-06-27T17:39:04.946Z",
"id": 1957,
"workflowId": "KRF6cPYGgmYT68rF",
"versionId": "ab4d8488-2e85-44bd-bff1-13f1501c74ea",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}