4363 lines
262 KiB
JSON
4363 lines
262 KiB
JSON
{
|
||
"updatedAt": "2026-08-29T16:20:29.305Z",
|
||
"createdAt": "2026-08-22T14:59:56.567Z",
|
||
"id": "rtnO23gHlJfj45pA",
|
||
"name": "Cotizador WMC - Agente Gemini + Supabase",
|
||
"description": null,
|
||
"active": true,
|
||
"isArchived": false,
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"httpMethod": "POST",
|
||
"path": "cotizador-wmc-chat",
|
||
"responseMode": "responseNode",
|
||
"options": {
|
||
"allowedOrigins": "*"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.webhook",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
-9408,
|
||
24784
|
||
],
|
||
"id": "6b03c1cb-2ea2-4014-bc61-aac8913b04a3",
|
||
"name": "Webhook - Chat",
|
||
"webhookId": "23c9a253-ba3d-47f0-85ca-21b3fde221c7"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const body = $json.body ?? {};\nconst headers = $json.headers ?? {};\nconst clean = (v, max = 10000) => typeof v === 'string' ? v.trim().slice(0, max) : '';\nconst auth = clean(headers.authorization || headers.Authorization, 12000);\nconst text = clean(body.text, 12000);\nconst conversationId = clean(body.conversationId, 200);\nconst messageId = clean(body.messageId, 200);\nconst userEmail = clean(body.userEmail, 320).toLowerCase();\nconst userName = clean(body.userName, 200);\nconst appSessionId = clean(body.appSessionId, 250) || `backend-${Date.now()}`;\nconst sentAt = clean(body.sentAt, 100) || new Date().toISOString();\nconst attachments = Array.isArray(body.attachments) ? body.attachments.slice(0, 8).map((a) => ({\n filename: clean(a?.filename, 180), mediaType: clean(a?.mediaType, 120), url: clean(a?.url, 6000), storagePath: clean(a?.storagePath, 1000) || null,\n})) : [];\nif (!auth.startsWith('Bearer ')) throw new Error('La sesión no incluye una credencial válida. Vuelve a iniciar sesión.');\nif (!conversationId || !messageId) throw new Error('Faltan conversationId/messageId.');\nif (!text && attachments.length === 0) throw new Error('La solicitud no contiene texto ni archivos.');\nconst countryMap = [\n ['GT','guatemala'],['CR','costa rica'],['SV','el salvador'],['SV','salvador'],['HN','honduras'],['NI','nicaragua']\n];\nconst low = text.toLowerCase();\nlet countryCode = null;\nfor (const [code, term] of countryMap) if (low.includes(term)) { countryCode = code; break; }\nreturn [{json:{auth,text,conversationId,messageId,userEmail,userName,appSessionId,sentAt,attachments,countryCode,startedAt:Date.now()}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8960,
|
||
24784
|
||
],
|
||
"id": "607da6ea-20d1-4de1-a554-bf9a0bc49f1a",
|
||
"name": "Normalizar solicitud"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://dbit.digitalcompass.agency/auth/v1/user",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-8480,
|
||
24784
|
||
],
|
||
"id": "6e2f9429-9948-454e-8346-aeca1d379d38",
|
||
"name": "Supabase - validar JWT",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Normalizar solicitud\"].json;\nconst authUser = $json ?? {};\nconst email = String(authUser.email ?? '').trim().toLowerCase();\nconst id = String(authUser.id ?? '').trim();\nconst valid = Boolean(id && email && email.endsWith('@gomezleemarketing.com') && (!req.userEmail || req.userEmail === email));\nreturn [{json:{...req, userId:id, verifiedEmail:email, jwtValid:valid, jwtError: valid ? null : 'Sesión inválida o correo no coincide.'}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8096,
|
||
24784
|
||
],
|
||
"id": "7bacd862-184e-4fac-a48c-8f89d546b0fb",
|
||
"name": "Validar identidad"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "0bfb8fea-20b0-4cf3-8e89-9160c914cc8e",
|
||
"leftValue": "={{ $json.jwtValid }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-7872,
|
||
24784
|
||
],
|
||
"id": "5256df52-35b7-4eda-9c11-088d42a45912",
|
||
"name": "JWT válido?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ { ok:false, reply:'Tu sesión no es válida. Vuelve a iniciar sesión.' } }}",
|
||
"options": {
|
||
"responseCode": 401
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-7648,
|
||
25024
|
||
],
|
||
"id": "c1c0385b-d949-41a7-a765-2c98adead24b",
|
||
"name": "Responder 401"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cotizador_wmc_access?select=email,full_name,is_active,can_use_tarifario&email=eq.' + encodeURIComponent($json.verifiedEmail) + '&is_active=eq.true' + '&limit=1' }}",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-7648,
|
||
24704
|
||
],
|
||
"id": "4056c80b-7554-4743-a285-e764849aecd6",
|
||
"name": "Supabase - consultar acceso",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Validar identidad\"].json;\nconst raw = $json ?? {};\n\n// n8n puede entregar la respuesta de PostgREST de varias formas:\n// 1) una fila directamente como $json (caso habitual cuando la respuesta HTTP es un array de 1 fila),\n// 2) un array,\n// 3) { data: [...] }, o\n// 4) { body: [...] } según la configuración/versión del nodo HTTP Request.\nlet row = null;\nif (Array.isArray(raw)) {\n row = raw[0] ?? null;\n} else if (Array.isArray(raw?.data)) {\n row = raw.data[0] ?? null;\n} else if (Array.isArray(raw?.body)) {\n row = raw.body[0] ?? null;\n} else if (raw?.body && typeof raw.body === 'object' && !Array.isArray(raw.body)) {\n row = raw.body;\n} else if (raw && typeof raw === 'object' && ('email' in raw || 'is_active' in raw || 'full_name' in raw)) {\n row = raw;\n}\n\nconst normalizeEmail = (value) => String(value ?? '').trim().toLowerCase();\nconst rowEmail = normalizeEmail(row?.email);\nconst active = row?.is_active === true || String(row?.is_active ?? '').toLowerCase() === 'true';\nconst accessAllowed = Boolean(row && active && rowEmail === req.verifiedEmail);\n\nreturn [{\n json: {\n ...req,\n accessAllowed,\n fullName: row?.full_name || req.userName || req.verifiedEmail,\n canUseTarifario: accessAllowed && (row?.can_use_tarifario === true || String(row?.can_use_tarifario ?? '').toLowerCase() === 'true'),\n accessLookupOk: Boolean(row),\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7440,
|
||
24704
|
||
],
|
||
"id": "488b5673-80a2-4eab-a469-eeb633060e5e",
|
||
"name": "Validar acceso"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b01cd05b-234e-4d8d-a2f0-3f9b83cb857e",
|
||
"leftValue": "={{ $json.accessAllowed }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-7216,
|
||
24704
|
||
],
|
||
"id": "39108519-535b-4c70-a61d-460cbd312436",
|
||
"name": "Acceso permitido?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ { ok:false, reply:'No tienes acceso activo al Cotizador Walmart Connect.' } }}",
|
||
"options": {
|
||
"responseCode": 403
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-6992,
|
||
24992
|
||
],
|
||
"id": "0a9392e9-d799-412b-95b6-bb4ce39168cb",
|
||
"name": "Responder 403"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_get_my_agent_context",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $node[\"Validar acceso\"].json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_query:$node[\"Resolver contexto conversacional\"].json.searchQuery || $node[\"Validar acceso\"].json.text,p_country_code:$node[\"Resolver contexto conversacional\"].json.effectiveCountryCode,p_limit:20}) }}",
|
||
"options": {
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-6992,
|
||
24592
|
||
],
|
||
"id": "d73a4ee1-0ad9-4127-956c-35ce97ef168a",
|
||
"name": "Contexto Supabase"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cotizador_wmc_messages?select=role,content,attachments,created_at&conversation_id=eq.' + encodeURIComponent($node[\"Validar acceso\"].json.conversationId) + '&order=created_at.asc&limit=40' }}",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $node[\"Validar acceso\"].json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-6768,
|
||
24592
|
||
],
|
||
"id": "bc2919b2-03cb-4b92-84fe-7a54b541bf20",
|
||
"name": "Historial Supabase",
|
||
"alwaysOutputData": true,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_get_my_pending_actions",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $node[\"Validar acceso\"].json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_limit:5}) }}",
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-6544,
|
||
24592
|
||
],
|
||
"id": "e937115d-9351-4188-ab54-ac110bcf143b",
|
||
"name": "Acciones pendientes",
|
||
"alwaysOutputData": true,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $json;\nconst context = req.agentContext && typeof req.agentContext === 'object' ? req.agentContext : {};\nconst history = Array.isArray(req.conversationHistory) ? req.conversationHistory.slice(-40) : [];\nconst pending = Array.isArray(req.pendingActions) ? req.pendingActions : [];\nconst isEmail = String(req.channel || 'chat') === 'email';\n\nconst system = `Eres el agente interno Cotizador Walmart Connect de GomezLee Marketing (GLM).\nTrabajas EXCLUSIVAMENTE con cotizaciones, presupuestos, tarifario, propuestas, ejecuciones y archivos relacionados con Walmart Connect/GLM. Si te piden algo fuera de este alcance, explica brevemente que no corresponde al Cotizador WMC.\n\nFUENTES Y REGLAS OBLIGATORIAS:\n1) El TARIFARIO OFICIAL incluido en CONTEXTO es la fuente oficial vigente. Nunca inventes una tarifa oficial.\n2) Los HISTÓRICOS son referencia para elementos específicos que no existan en el tarifario. Si debes estimar, usa referencias comparables de la forma más realista posible y marca isEstimated=true, estimationReason y confidence 0..1. Nunca presentes una estimación como tarifa oficial.\n3) Las cotizaciones Walmart Connect salen en USD y también incluyen moneda local usando el tipo de cambio entregado por CONTEXTO.\n4) Conserva la estructura funcional oficial WMC: Personal, Mobiliario - Equipo, Insumos de degustación y/o preparación, Logística e Instalación, Fee de agencia, total, condiciones comerciales, vigencia, observaciones/exclusiones y referencias visuales.\n5) Personal: total = unidades × días × costo unitario. Verifica TODA la matemática, subtotales, total USD y total local antes de responder.\n6) Respeta el país. No traslades precios de un país a otro salvo que el CONTEXTO lo permita. NO uses precios hardcodeados de ejemplos anteriores: para cualquier tarifa oficial vigente, CONTEXTO.tariff es la única fuente de verdad.\n7) RTM/RTM+ suele implicar arcos, backpanels, mesas degustadoras, alfombras y montaje; cotizaciones de islas suelen concentrarse en impulso/personal, mobiliario y rebranding. No agregues partidas no solicitadas sin aclararlo.\n8) En CAM/multipaís, genera la combinación país × formato requerida; por ejemplo, 5 países × 3 formatos puede implicar 15 cotizaciones/hojas.\n9) Cuando Walmart haya dado el OK y pidan mensualización, usa action=generate_monthly_glm.\n10) Cambios de tarifario: JAMÁS los ejecutes silenciosamente. Primera solicitud -> tariff_change_proposal. Solo después de confirmación explícita del mismo usuario -> confirm_tariff_change con pendingActionId. Si el usuario RECHAZA, CANCELA o indica explícitamente que NO desea aplicar una propuesta pendiente, usa action=cancel_tariff_change con el pendingActionId exacto de ACCIONES_PENDIENTES. Una acción cancelada deja de ser confirmable y nunca debes responder que fue cancelada usando action=chat.\n11) Si permissions.canUseTarifario=false, NO propongas ni confirmes modificaciones del tarifario; explica que el usuario no tiene ese permiso.\n12) Si faltan datos esenciales para un archivo confiable, pregunta exactamente lo que falta y usa action=chat. ANTES de action=generate_quote debes tener confirmados por el usuario los datos que determinan el precio: país, formato, duración, cantidad y tipo/categoría del personal, y ubicación/cobertura cuando la tarifa dependa de Capital/Interior/mixta.\n13) PROHIBIDO asumir partidas no solicitadas: no agregues supervisor, uniformes, logística, mobiliario, insumos, rebranding ni ningún otro rubro solo porque aparezca en históricos o sea frecuente. Solo inclúyelos si el usuario los pidió explícitamente o los confirmó después de que tú preguntaras. Si el usuario dice simplemente \"impulsadoras\" y existen categorías A/AA/AAA o diferencias por ubicación, pregunta la categoría/ubicación antes de generar el archivo.\n14) El Fee de agencia sí forma parte estructural del machote oficial. Usa la tasa oficial que venga en CONTEXTO; si no existe una tasa explícita, usa 6% como valor del machote. totalUsd y totalLocal SIEMPRE deben incluir el fee. Fórmula obligatoria: subtotalUsd = suma de partidas que no sean Fee; feeUsd = subtotalUsd × feeRate; totalUsd = subtotalUsd + feeUsd; totalLocal = totalUsd × exchangeRate.\n15) Nunca completes silenciosamente Capital, Interior o mixta a partir del país. Si ese dato cambia la tarifa y no fue dicho, pregunta.\n16) ADJUNTOS MULTIMODALES: los metadatos/URLs por sí solos NO prueban el contenido de un archivo. Si AUDIO_TRANSCRIPTION tiene contenido, úsalo como la transcripción real de la nota de voz. Si IMAGE_ANALYSIS tiene contenido, úsalo como el análisis real de las imágenes adjuntas. Si DOCUMENT_ANALYSIS tiene contenido, úsalo como el análisis real de los PDF adjuntos. Si SPREADSHEET_ANALYSIS tiene contenido, úsalo como contenido REAL extraído del Excel XLSX adjunto y NO respondas que no puedes leer archivos .xlsx. SPREADSHEET_ANALYSIS_SCOPE indica los límites aplicados y SPREADSHEET_SHEET_NAMES enumera las hojas detectadas. La extracción intenta leer TODAS las hojas con contenido legible del libro, no únicamente A1 ni una pestaña específica. Nunca inventes información que no aparezca en esos análisis. Si el indicador *_ERROR correspondiente es true y no hay análisis útil, pide únicamente reintentar ese archivo o compartir el dato faltante. Los formatos de oficina que sigan apareciendo en UNPARSED_DOCUMENT_ATTACHMENTS no han sido leídos y no debes afirmar lo contrario.\n17) Para referencias visuales o artes, genera prompts específicos, realistas y coherentes con el mobiliario/activación solicitada.\n18) Para propuestas en Google Slides, mantén estilo Walmart Connect: limpio, corporativo, azul marino profundo, títulos simples y visuales grandes. PROHIBIDO inventar nombres de concepto, slogans, claims, frases de campaña, nombres creativos, promociones, atributos de producto, promesas de resultados o especificaciones no entregadas por el usuario o por el contexto confirmado. Si el usuario no proporcionó un concepto o slogan, usa redacción descriptiva y factual (por ejemplo, \"Activación de impulso en punto de venta\") sin crear uno. En los visuales respeta exactamente la cantidad y el tipo/categoría de personal indicados en quote.items; no sustituyas impulsadoras/supervisores por personal genérico ni agregues personal de marca no cotizado.\n19) Cuando el usuario pida una imagen directamente, usa action=generate_image.\n20) MEMORIA CONVERSACIONAL OBLIGATORIA: HISTORIAL contiene los mensajes previos de ESTE MISMO chat y CONVERSATION_STATE resume hechos recuperados. Trata como confirmados los datos que el usuario ya indicó anteriormente (país, proyecto, anunciante, formato, cantidades, duración, ubicación, categoría, partidas solicitadas), salvo que el usuario los corrija. NO vuelvas a preguntar un dato ya presente en el historial. El mensaje actual puede ser una continuación breve del anterior. Si countryHint/effectiveCountryCode ya existe, no preguntes nuevamente el país.\n21) Si hay conflicto entre un dato anterior y uno nuevo, prevalece la corrección más reciente del usuario.\n22) MENSUALIZACIÓN GLM: cuando Walmart ya dio el OK, NO recotices ni sustituyas la cotización aprobada con tarifas nuevas. Usa action=generate_monthly_glm y conserva como base los conceptos, cantidades, días, moneda, país, tipo de cambio y total WMC aprobado de la cotización generada previamente en ESTA conversación. El flujo de Artefactos recuperará internamente la cotización fuente y la enlazará por sourceQuoteId. El machote mensual GLM aplica IVA 12% sobre la base aprobada WMC; distingue claramente en la respuesta el Total aprobado WMC del Total mensual GLM con IVA.\n23) En generate_monthly_glm, artifact.metadata debe incluir cuando sea posible countryCode, country, currencyCode, exchangeRate, approvedWmcTotalUsd, approvedWmcTotalLocal y glmIvaRate:0.12; no inventes estos valores si no están en historial/contexto.\n24) LENGUAJE PARA USUARIO FINAL: jamás menciones Supabase, base de datos, PostgreSQL, RPC, PostgREST, tablas, funciones internas, pendingActionId, UUID ni detalles de infraestructura en reply. El usuario solo debe leer lenguaje funcional como \"tarifario oficial\", \"propuesta de cambio\", \"cambio registrado\", \"cambio cancelado\" o \"no hay una propuesta pendiente válida\".\n25) En propuestas de cambio de tarifario, termina con una pregunta natural como \"¿Confirmas esta actualización?\", \"¿Confirmas que agregue esta tarifa?\" o \"¿Confirmas la desactivación de esta tarifa?\", sin mencionar dónde se almacena.\n26) En una confirmación exitosa, indica que el cambio \"quedó registrado en el tarifario oficial\"; no describas la tecnología utilizada.\n27) Si una confirmación no puede ejecutarse porque la propuesta ya fue aplicada, cancelada, venció o pertenece a otro chat, NO afirmes primero que el cambio fue realizado. Explica únicamente que esa propuesta ya no está pendiente en este chat y que debe generarse una nueva propuesta si desea continuar.\n28) CLASIFICACIÓN DE COBERTURA: una ciudad, municipio, tienda o dirección NO equivale automáticamente a Capital, Interior o mixta. Solo usa una clasificación tarifaria Capital/Interior/mixta cuando aparezca explícitamente en la solicitud/adjunto o el usuario la confirme. Si la tarifa cambia por esa clasificación y no está explícita, usa action=chat y pregunta únicamente esa clasificación antes de generar.\n29) FIDELIDAD DE PARTIDAS DESDE ADJUNTOS: cuando PDF/XLSX/imagen/audio sea la fuente operativa, conserva en quote.items[].description el concepto solicitado por esa fuente. No renombres, amplíes ni sustituyas silenciosamente la partida para hacerla coincidir con una tarifa. Cambios como agregar \"Panel\", \"Envío\", \"Camión\", \"Montaje\", \"Capital\", \"Interior\" u otro alcance no presente requieren confirmación explícita.\n30) MATCH DE TARIFARIO CON NOMBRE DIFERENTE: si la tarifa oficial candidata tiene una descripción o alcance más específico/diferente al concepto del adjunto, NO generes todavía. Explica brevemente la diferencia en lenguaje funcional y pregunta si el usuario confirma usar esa partida exacta del tarifario oficial.\n31) CONFIRMACIONES PARCIALES: que el usuario resuelva una ambigüedad (por ejemplo 5 vs 6 días) NO autoriza a resolver silenciosamente otras variables que todavía afecten el precio. Antes de generate_quote/generate_proposal deben estar confirmadas TODAS las variables de precio aplicables.\n32) TRAZABILIDAD DE PARTIDAS: cuando una partida provenga de un adjunto y action=generate_quote o generate_proposal, incluye cuando sea posible en metadata sourceDescription (texto/concepto original de la fuente) y tariffDescription (nombre exacto de la tarifa aplicada). La descripción visible debe conservar sourceDescription; tariffDescription solo documenta el match.\n33) 28) PROCEDENCIA DE PRECIOS OBLIGATORIA: para CADA item con monto, incluye metadata.priceSourceCode, metadata.priceSourceType y metadata.priceSourceRef. Usa T=tarifario oficial SOLO si la tarifa se encuentra realmente en CONTEXTO.tariff; usa H=histórico SOLO si tomas como referencia una cotización/item previo e incluye sourceQuoteId/referenceQuoteId; usa W SOLO si existe una URL web verificable realmente proporcionada en el contexto/solicitud e incluye sourceUrl; usa E cuando debas estimar con Gemini por no existir tarifa ni histórico suficiente. JAMÁS inventes una URL o marques T sin evidencia del tarifario. Prioridad obligatoria: T -> H -> E. W únicamente cuando haya una fuente web real disponible.\n29) CANAL EMAIL: si channel=email, NO sustituyes el chat: eres la misma lógica del Cotizador aplicada al correo. Si faltan datos que afectan el precio, action=chat y pregunta únicamente lo necesario para continuar. Conserva la memoria del hilo recibida en HISTORIAL.\n30) FILTRO TEMÁTICO EMAIL: si channel=email y el contenido NO corresponde a Walmart Connect/cotizaciones/presupuestos/propuestas/activaciones/ajustes o seguimiento de una solicitud del Cotizador, devuelve emailDisposition=\"ignore\", action=\"chat\", reply=\"\" y no generes ningún artefacto. Si sí corresponde, emailDisposition=\"process\".\n31) SEGURIDAD EMAIL: por email nunca propongas, confirmes ni canceles cambios del tarifario aunque el remitente lo pida; solo consulta el tarifario y referencias. Cualquier item fuera de tarifario debe mantener su H/W/E para que la capa de correo solicite aprobación interna antes de enviar.\n32) REVISION INTERNA: si approvalRevision existe, las instrucciones del aprobador son una revisión interna autorizada del candidato actual. Aplica únicamente los cambios solicitados, conserva el resto y vuelve a validar toda la matemática y las fuentes. Si falta un dato para ejecutar el cambio, action=chat y pregunta al aprobador; no inventes.\n33) Devuelve SOLO JSON válido, sin markdown ni texto fuera del JSON.\n\nESQUEMA DE RESPUESTA:\n{\n \"reply\":\"respuesta para el usuario\",\n \"emailDisposition\":\"process|ignore\",\n \"action\":\"chat|generate_quote|generate_proposal|generate_image|generate_monthly_glm|tariff_change_proposal|confirm_tariff_change|cancel_tariff_change\",\n \"requiresConfirmation\":false,\n \"pendingAction\":null,\n \"pendingActionId\":null,\n \"quote\":null,\n \"artifact\":null,\n \"warnings\":[],\n \"confidence\":0.0\n}\n\nCuando action=generate_quote, quote debe incluir:\ncountryCode,country,brandSolution,project,advertiser,format,exchangeRate,currencyCode,totalUsd,totalLocal,status,items[],conditions,validity,observations,visualReferences.\nCada item: section,description,units,days,unitCostUsd,totalUsd,unitCostLocal,totalLocal,isEstimated,confidence,estimationReason,metadata.\nNo incluyas el Fee como una partida inventada para cuadrar números: reporta agencyFeeRate (decimal, por ejemplo 0.06), subtotalUsd, agencyFeeUsd, agencyFeeLocal, totalUsd y totalLocal; los totales deben cuadrar matemáticamente.\nvisualReferences puede contener URLs existentes o objetos {title,imagePrompt,imageUrl}. Si el usuario pide una referencia visual nueva para la cotización, incluye un objeto con imagePrompt; el workflow generará esa imagen con el nodo nativo de Gemini y la insertará en Referencias visuales del Google Sheet.\n\nCuando action=generate_proposal:\nquote NO puede ser null. Debe incluir la MISMA estructura operativa y financiera completa indicada para generate_quote, aunque la acción siga siendo generate_proposal y NO se cree un Google Sheet de cotización. Incluye countryCode,country,brandSolution,project,advertiser,format,exchangeRate,currencyCode,status,items[],conditions,validity,observations,visualReferences,agencyFeeRate,subtotalUsd,subtotalLocal,agencyFeeUsd,agencyFeeLocal,totalUsd,totalLocal. Cada item debe conservar section,description,units,days,unitCostUsd,totalUsd,unitCostLocal,totalLocal,isEstimated,confidence,estimationReason,metadata. Usa exclusivamente el TARIFARIO OFICIAL de CONTEXTO, respeta las partidas solicitadas y verifica unidades × días × costo unitario, subtotal, Fee, total USD y total local antes de responder. Este quote estructurado alimenta el alcance y la inversión de la presentación; no lo sustituyas por cifras escritas únicamente dentro del texto de las diapositivas.\nartifact={title,subtitle,slides:[{type:\"cover|section|visual|content|thankyou\",title,subtitle,body,imagePrompt,imageUrl}]}\nEn title/subtitle/body de las diapositivas NO inventes slogans, nombres de concepto, claims ni frases de campaña: solo usa redacción descriptiva sustentada en la solicitud, HISTORIAL, CONTEXTO y quote. Cada imagePrompt debe reflejar literalmente el personal cotizado en quote.items (cantidad y descripción/categoría) y no debe pedir personal de marca adicional ni reemplazarlo por perfiles genéricos.\n\nCuando action=generate_image:\nartifact={title,imagePrompt,aspectRatio}\n\nCuando action=generate_monthly_glm:\nartifact={title,sourceQuoteId,monthlyLines,metadata:{countryCode,country,currencyCode,exchangeRate,approvedWmcTotalUsd,approvedWmcTotalLocal,glmIvaRate}}\n\nCuando action=tariff_change_proposal:\npendingAction={operation:\"insert|update|deactivate\",tariffItemId,item,reason}.\n\nCuando action=confirm_tariff_change:\npendingActionId debe ser el id exacto de la acción pendiente que el usuario está confirmando.\n\nCuando action=cancel_tariff_change:\npendingActionId debe ser el id exacto de ACCIONES_PENDIENTES correspondiente a la propuesta que el usuario está rechazando/cancelando; requiresConfirmation=false y pendingAction=null. Si no existe una acción pendiente válida en ESTA conversación, usa action=chat e indica que no hay nada pendiente que cancelar.`;\n\nconst attachments = (req.attachments || []).map(a => ({\n filename: a.filename || '',\n mediaType: a.mediaType || '',\n url: a.url || '',\n storagePath: a.storagePath || null\n}));\n\nconst payload = {\n channel: req.channel || 'chat',\n currentRequest: req.text,\n countryHint: req.effectiveCountryCode || req.countryCode || null,\n CONVERSATION_STATE: {\n effectiveCountryCode: req.effectiveCountryCode || null,\n effectiveCountrySource: req.effectiveCountrySource || null\n },\n user: {name:req.fullName,email:req.verifiedEmail},\n permissions: {canUseTarifario:!isEmail && Boolean(req.canUseTarifario)},\n EMAIL_META: req.emailMeta || null,\n approvalRevision: req.approvalRevision || null,\n attachments,\n AUDIO_TRANSCRIPTION: req.audioTranscription || null,\n AUDIO_TRANSCRIPTION_ERROR: Boolean(req.audioTranscriptionError),\n IMAGE_ANALYSIS: req.imageAnalysis || null,\n IMAGE_ANALYSIS_ERROR: Boolean(req.imageAnalysisError),\n DOCUMENT_ANALYSIS: req.documentAnalysis || null,\n DOCUMENT_ANALYSIS_ERROR: Boolean(req.documentAnalysisError),\n SPREADSHEET_ANALYSIS: req.spreadsheetAnalysis || null,\n SPREADSHEET_ANALYSIS_ERROR: Boolean(req.spreadsheetAnalysisError),\n SPREADSHEET_ANALYSIS_SCOPE: req.spreadsheetAnalysisScope || null,\n SPREADSHEET_SHEET_NAMES: Array.isArray(req.spreadsheetSheetNames) ? req.spreadsheetSheetNames : [],\n SPREADSHEET_EXTRACTION_WARNINGS: Array.isArray(req.spreadsheetExtractionWarnings) ? req.spreadsheetExtractionWarnings : [],\n UNPARSED_DOCUMENT_ATTACHMENTS: Array.isArray(req.unparsedDocumentAttachments) ? req.unparsedDocumentAttachments : [],\n CONTEXTO: context,\n ACCIONES_PENDIENTES: pending,\n HISTORIAL: history.map(h => ({\n role:h.role,\n content:h.content,\n attachments:h.attachments || [],\n createdAt:h.created_at\n }))\n};\n\nconst attachmentLinks = attachments\n .filter(a => a.url)\n .map((a,i) => `Adjunto ${i+1}: ${a.filename || 'archivo'} (${a.mediaType || 'tipo no indicado'})\\nURL: ${a.url}`)\n .join('\\n\\n');\n\nconst geminiPrompt = [\n 'SOLICITUD Y CONTEXTO EN JSON:',\n JSON.stringify(payload),\n attachmentLinks ? '\\nENLACES DE ADJUNTOS PARA URL CONTEXT:\\n' + attachmentLinks : ''\n].join('\\n\\n');\n\nreturn [{json:{...req,system,geminiPrompt,model:\"gemini-3.7-flash\",pendingActions:pending}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6320,
|
||
24592
|
||
],
|
||
"id": "d832c941-cdfa-46a9-8b9a-80aea6ebc0e7",
|
||
"name": "Construir Gemini"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Construir Gemini\"].json;\nconst raw = $json ?? {};\n\nlet parsed = null;\nlet text = '';\n\n// El nodo nativo de Gemini puede devolver JSON ya simplificado o contenido en parts.\nif (raw && typeof raw === 'object' && typeof raw.action === 'string') {\n parsed = raw;\n try { text = JSON.stringify(raw); } catch {}\n}\n\nif (!parsed) {\n if (typeof raw.mergedResponse === 'string') {\n text = raw.mergedResponse;\n } else if (raw.content?.parts && Array.isArray(raw.content.parts)) {\n text = raw.content.parts.map(p => p?.text || '').join('');\n } else if (raw.candidates?.[0]?.content?.parts && Array.isArray(raw.candidates[0].content.parts)) {\n text = raw.candidates[0].content.parts.map(p => p?.text || '').join('');\n } else if (typeof raw.text === 'string') {\n text = raw.text;\n } else if (typeof raw.output === 'string') {\n text = raw.output;\n } else if (typeof raw.response === 'string') {\n text = raw.response;\n } else if (raw.output && typeof raw.output.text === 'string') {\n text = raw.output.text;\n }\n\n try {\n const cleaned = String(text || '').trim()\n .replace(/^```(?:json)?\\s*/i,'')\n .replace(/\\s*```$/,'');\n parsed = JSON.parse(cleaned);\n } catch {\n parsed = {\n reply:'No pude interpretar de forma segura la respuesta del modelo. Intenta nuevamente.',\n action:'chat',\n warnings:['La respuesta de Gemini no fue JSON válido.'],\n confidence:0\n };\n }\n}\n\n// Cancelación determinista de cambios de tarifario.\n// No dependemos únicamente del modelo para invalidar una acción sensible.\nfunction normalizeIntent_(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst intentText_ = normalizeIntent_(req.text);\nconst saysDoNotCancel_ = /\\bno\\s+(?:quiero|deseo)\\s+cancel(?:ar|arlo|arla|en|acion)?\\b/.test(intentText_);\nconst explicitCancel_ = !saysDoNotCancel_ && (\n /\\bcancel(?:a|ar|alo|ala|en|acion)\\b/.test(intentText_) ||\n /\\brechaz(?:a|ar|o|amos|en)\\b/.test(intentText_) ||\n /\\bno\\s+(?:quiero|deseo)\\s+(?:aplicar|confirmar|hacer|modificar)\\b/.test(intentText_) ||\n /\\bno\\s+(?:apliques|confirmes|modifiques)\\b/.test(intentText_) ||\n /\\bdejal[oa]\\s+(?:igual|sin cambios)\\b/.test(intentText_) ||\n /\\bmanten(?:lo|la)?\\s+sin cambios\\b/.test(intentText_)\n);\n\nconst pendingActions_ = Array.isArray(req.pendingActions) ? req.pendingActions : [];\nconst sameConversationPending_ = pendingActions_\n .filter(p =>\n p &&\n String(p.actionType || p.action_type || '').toLowerCase() === 'tariff_change' &&\n String(p.status || 'pending').toLowerCase() === 'pending' &&\n String(p.conversationId || p.conversation_id || '') === String(req.conversationId || '')\n )\n .sort((a,b) => Date.parse(b.createdAt || b.created_at || 0) - Date.parse(a.createdAt || a.created_at || 0));\n\nconst requestedPendingId_ = String(parsed?.pendingActionId || '').trim();\nlet targetPending_ = requestedPendingId_\n ? sameConversationPending_.find(p => String(p.id || '') === requestedPendingId_)\n : null;\nif (!targetPending_) targetPending_ = sameConversationPending_[0] || null;\n\nif (explicitCancel_) {\n if (targetPending_) {\n parsed.action = 'cancel_tariff_change';\n parsed.pendingActionId = String(targetPending_.id);\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n if (!String(parsed.reply || '').trim() || !/\\bcancel/i.test(String(parsed.reply))) {\n parsed.reply = 'Entendido. Cancelaré la propuesta pendiente y no se aplicará el cambio.';\n }\n } else {\n parsed.action = 'chat';\n parsed.pendingActionId = null;\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n parsed.reply = 'No hay una modificación de tarifario pendiente válida en esta conversación para cancelar.';\n }\n} else if (String(parsed?.action || '') === 'cancel_tariff_change') {\n if (targetPending_) {\n parsed.pendingActionId = String(targetPending_.id);\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n } else {\n parsed.action = 'chat';\n parsed.pendingActionId = null;\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n parsed.reply = 'No hay una modificación de tarifario pendiente válida en esta conversación para cancelar.';\n }\n}\n\nconst allowed = new Set([\n 'chat','generate_quote','generate_proposal','generate_image',\n 'generate_monthly_glm','tariff_change_proposal','confirm_tariff_change','cancel_tariff_change'\n]);\nlet action = allowed.has(parsed?.action) ? parsed.action : 'chat';\n\n// Seguridad adicional: usuarios sin permiso de Tarifario no pueden pedir mutaciones.\nif (!req.canUseTarifario && ['tariff_change_proposal','confirm_tariff_change'].includes(action)) {\n action = 'chat';\n parsed.reply = 'No tienes habilitado el permiso para modificar el Tarifario.';\n parsed.pendingAction = null;\n parsed.pendingActionId = null;\n parsed.requiresConfirmation = false;\n}\n\n\n// Barrera determinista para cotizaciones desde adjuntos:\n// 1) no permite clasificar Capital/Interior/mixta sin confirmación explícita;\n// 2) no permite que una partida de logística gane alcance nuevo (Panel, Envío, Camión, etc.)\n// que no exista en la fuente operativa o en una confirmación del usuario.\n// Esto se ejecuta DESPUÉS de Gemini y ANTES de crear cualquier artefacto.\nfunction normSource_(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nfunction relevantUserText_() {\n const history = Array.isArray(req.conversationHistory) ? req.conversationHistory : [];\n let lastAttachmentIndex = -1;\n for (let i = history.length - 1; i >= 0; i--) {\n const h = history[i] || {};\n if (\n String(h.role || '').toLowerCase() === 'user' &&\n Array.isArray(h.attachments) &&\n h.attachments.length > 0\n ) {\n lastAttachmentIndex = i;\n break;\n }\n }\n const start = lastAttachmentIndex >= 0 ? lastAttachmentIndex : Math.max(0, history.length - 8);\n const userTurns = history\n .slice(start)\n .filter(h => String(h?.role || '').toLowerCase() === 'user')\n .map(h => String(h?.content || ''))\n .filter(Boolean);\n\n userTurns.push(String(req.text || ''));\n return userTurns.join('\\n');\n}\nfunction coverageFromAttachment_() {\n const attachmentText = normSource_([\n req.spreadsheetAnalysis,\n req.documentAnalysis,\n req.imageAnalysis,\n req.audioTranscription\n ].filter(Boolean).join('\\n'));\n const set = new Set();\n for (const term of ['capital','interior','mixta']) {\n if (new RegExp(`\\\\b${term}\\\\b`).test(attachmentText)) set.add(term);\n }\n return set;\n}\nfunction coverageFromUser_() {\n const raw = normSource_(relevantUserText_());\n const set = new Set();\n for (const term of ['capital','interior','mixta']) {\n const explicit = [\n new RegExp(`^(?:usa |usar |cotiza |cotizar |como |es |cobertura |ubicacion )*${term}$`),\n new RegExp(`\\\\b(?:usa|usar|cotiza|cotizar|cotizalo|cotizala|aplica|aplicar|como|cobertura|ubicacion|clasificacion)\\\\s+(?:de\\\\s+|como\\\\s+)?${term}\\\\b`),\n new RegExp(`\\\\b${term}\\\\s+(?:confirmado|confirmada|correcto|correcta)\\\\b`),\n new RegExp(`\\\\b(?:es|sera|seria)\\\\s+${term}\\\\b`)\n ].some(re => re.test(raw));\n if (explicit) set.add(term);\n }\n return set;\n}\nfunction pricingCoverageTerms_(quote) {\n const q = quote && typeof quote === 'object' ? quote : {};\n const items = Array.isArray(q.items) ? q.items : [];\n const parts = [\n q.coverage, q.coverageType, q.locationCategory, q.tariffCoverage,\n ...items.flatMap(it => [\n it?.description,\n it?.metadata?.tariffDescription,\n it?.metadata?.coverage,\n it?.metadata?.locationCategory\n ])\n ];\n const raw = normSource_(parts.filter(Boolean).join('\\n'));\n const set = new Set();\n for (const term of ['capital','interior','mixta']) {\n if (new RegExp(`\\\\b${term}\\\\b`).test(raw)) set.add(term);\n }\n return set;\n}\nfunction sourceEvidence_() {\n return normSource_([\n req.spreadsheetAnalysis,\n req.documentAnalysis,\n req.imageAnalysis,\n req.audioTranscription,\n relevantUserText_()\n ].filter(Boolean).join('\\n'));\n}\nfunction logisticsNovelQualifiers_(quote, sourceText) {\n const q = quote && typeof quote === 'object' ? quote : {};\n const items = Array.isArray(q.items) ? q.items : [];\n const risky = [\n 'envio','panel','backpanel','camion','vehiculo','pickup','van',\n 'montaje','desmontaje','rebranding','branding','impresion',\n 'capital','interior','mixta','nocturno','nocturna','refrigerado','refrigerada'\n ];\n const out = [];\n for (const it of items) {\n const section = normSource_(it?.section);\n const visibleDesc = String(it?.description || '').trim();\n const tariffDesc = String(it?.metadata?.tariffDescription || '').trim();\n const candidate = normSource_([visibleDesc, tariffDesc].filter(Boolean).join(' '));\n if (!/(logistica|instalacion|transporte|envio|traslado)/.test(`${section} ${candidate}`)) continue;\n const added = risky.filter(term =>\n new RegExp(`\\\\b${term}\\\\b`).test(candidate) &&\n !new RegExp(`\\\\b${term}\\\\b`).test(sourceText)\n );\n if (added.length) {\n out.push({\n description: tariffDesc || visibleDesc,\n added:[...new Set(added)]\n });\n }\n }\n return out;\n}\n\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote) {\n const attachmentCoverage_ = coverageFromAttachment_();\n const userCoverage_ = coverageFromUser_();\n const confirmedCoverage_ = new Set([...attachmentCoverage_, ...userCoverage_]);\n const quoteCoverage_ = pricingCoverageTerms_(parsed.quote);\n const unconfirmedCoverage_ = [...quoteCoverage_].filter(term => !confirmedCoverage_.has(term));\n\n if (unconfirmedCoverage_.length) {\n action = 'chat';\n parsed.action = 'chat';\n parsed.quote = null;\n parsed.artifact = null;\n parsed.requiresConfirmation = false;\n parsed.pendingAction = null;\n parsed.pendingActionId = null;\n parsed.warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];\n parsed.warnings.push('Se bloqueó la generación porque la clasificación Capital/Interior/mixta utilizada para precio no estaba confirmada por la fuente o el usuario.');\n parsed.reply = 'Antes de generar la cotización necesito confirmar únicamente la cobertura tarifaria. La ciudad, municipio o tienda indicada en el archivo no define por sí sola esa clasificación. ¿Debo cotizar esta activación como Capital, Interior o mixta?';\n } else {\n const sourceText_ = sourceEvidence_();\n const novelLogistics_ = logisticsNovelQualifiers_(parsed.quote, sourceText_);\n if (novelLogistics_.length) {\n const first_ = novelLogistics_[0];\n const addedHuman_ = first_.added.map(x => {\n const labels = {envio:'Envío',panel:'Panel',backpanel:'Backpanel',camion:'Camión',vehiculo:'Vehículo',pickup:'Pickup',van:'Van',montaje:'Montaje',desmontaje:'Desmontaje',rebranding:'Rebranding',branding:'Branding',impresion:'Impresión',capital:'Capital',interior:'Interior',mixta:'Mixta',nocturno:'Nocturno',nocturna:'Nocturna',refrigerado:'Refrigerado',refrigerada:'Refrigerada'};\n return labels[x] || x;\n });\n action = 'chat';\n parsed.action = 'chat';\n parsed.quote = null;\n parsed.artifact = null;\n parsed.requiresConfirmation = false;\n parsed.pendingAction = null;\n parsed.pendingActionId = null;\n parsed.warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];\n parsed.warnings.push('Se bloqueó una sustitución/ampliación de alcance en una partida de logística que no estaba confirmada en la fuente.');\n parsed.reply = `Antes de generar necesito confirmar la partida de logística. La partida seleccionada agrega detalles que no aparecen confirmados en el archivo (${addedHuman_.join(', ')}). Indícame la partida exacta que debo usar o confirma expresamente que use: \"${first_.description}\".`;\n }\n }\n}\n\n\n// Fidelidad determinista de etiquetas visibles.\n// Si Gemini entregó la trazabilidad sourceDescription, esa descripción original\n// prevalece en el archivo visible; el nombre de la tarifa queda solo en metadata.\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote && Array.isArray(parsed.quote.items)) {\n parsed.quote.items = parsed.quote.items.map(raw => {\n const it = raw && typeof raw === 'object' ? {...raw} : {};\n const sourceDescription = String(it?.metadata?.sourceDescription || '').trim();\n if (sourceDescription) it.description = sourceDescription;\n return it;\n });\n}\n\n// Procedencia de precios determinista.\n// No confiamos solo en la letra sugerida por Gemini: T exige evidencia real en el tarifario,\n// H exige referencia histórica, W exige URL verificable y todo lo demás cae en E.\nfunction sourceNorm_(v) {\n return String(v || '').toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/\\s+/g,' ').trim();\n}\nfunction validUrl_(v) { return /^https?:\\/\\/[^\\s]+$/i.test(String(v || '').trim()); }\nfunction tariffRows_() {\n const c = req.agentContext && typeof req.agentContext === 'object' ? req.agentContext : {};\n if (Array.isArray(c.tariff)) return c.tariff;\n if (Array.isArray(c?.context?.tariff)) return c.context.tariff;\n return [];\n}\nfunction tariffEvidence_(item, tariffs) {\n const it = item && typeof item === 'object' ? item : {};\n const md = it.metadata && typeof it.metadata === 'object' ? it.metadata : {};\n const id = String(it.tariffItemId || md.tariffItemId || '').trim();\n const code = sourceNorm_(md.itemCode || it.itemCode);\n const desc = sourceNorm_([it.description, md.tariffDescription].filter(Boolean).join(' '));\n const cost = Number(it.unitCostUsd ?? it.unit_cost_usd ?? 0);\n for (const t of tariffs) {\n if (!t || typeof t !== 'object') continue;\n if (id && String(t.id || '') === id) return t;\n if (code && sourceNorm_(t.itemCode) === code) return t;\n const tc = Number(t.baseCostUsd ?? t.base_cost_usd ?? NaN);\n const tn = sourceNorm_([t.name,t.detail].filter(Boolean).join(' '));\n if (Number.isFinite(cost) && Number.isFinite(tc) && Math.abs(cost-tc)<0.01 && tn && desc && (desc.includes(sourceNorm_(t.name)) || tn.includes(desc))) return t;\n }\n return null;\n}\nfunction normalizePriceSources_(quote) {\n if (!quote || typeof quote !== 'object' || !Array.isArray(quote.items)) return quote;\n const tariffs = tariffRows_();\n quote.items = quote.items.map(raw => {\n const it = raw && typeof raw === 'object' ? {...raw} : {};\n const md = it.metadata && typeof it.metadata === 'object' ? {...it.metadata} : {};\n const amount = Number(it.unitCostUsd ?? it.unit_cost_usd ?? 0) > 0 || Number(it.totalUsd ?? it.total_usd ?? 0) > 0;\n if (!amount) return {...it,metadata:md};\n const t = tariffEvidence_(it, tariffs);\n const histRef = String(md.sourceQuoteId || md.referenceQuoteId || md.historicalQuoteId || it.sourceQuoteId || '').trim();\n const sourceUrl = String(md.sourceUrl || '').trim();\n let code, type, ref;\n if (t) {\n code='T'; type='tariff'; ref=String(t.id || t.itemCode || 'TARIFARIO');\n if (!md.tariffItemId && t.id) md.tariffItemId=t.id;\n if (!md.itemCode && t.itemCode) md.itemCode=t.itemCode;\n if (!md.tariffDescription && (t.name || t.detail)) md.tariffDescription=[t.name,t.detail].filter(Boolean).join(' - ');\n } else if (histRef) {\n code='H'; type='historical'; ref=histRef;\n } else if (validUrl_(sourceUrl)) {\n code='W'; type='web'; ref=sourceUrl;\n } else {\n code='E'; type='estimate'; ref=String(md.estimationReference || 'Gemini estimate');\n it.isEstimated=true;\n if (!it.estimationReason) it.estimationReason='No se encontró una tarifa oficial ni una referencia histórica suficientemente identificada para esta partida.';\n }\n md.priceSourceCode=code; md.priceSourceType=type; md.priceSourceRef=ref;\n return {...it,priceSourceCode:code,priceSourceType:type,priceSourceRef:ref,metadata:md};\n });\n return quote;\n}\n\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote) {\n parsed.quote = normalizePriceSources_(parsed.quote);\n}\n\n// Recalculo financiero determinista: Gemini propone la estructura, pero n8n valida toda la matemática.\nfunction n_(value, fallback = 0) {\n const n = Number(value);\n return Number.isFinite(n) ? n : fallback;\n}\nfunction money_(value) {\n return Math.round((n_(value, 0) + Number.EPSILON) * 100) / 100;\n}\nfunction isFeeSection_(section) {\n return String(section || '').toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').includes('fee');\n}\nfunction normalizeQuoteFinancials_(input) {\n if (!input || typeof input !== 'object') return input ?? null;\n const q = JSON.parse(JSON.stringify(input));\n const rate = n_(q.exchangeRate ?? q.exchange_rate, 1) > 0 ? n_(q.exchangeRate ?? q.exchange_rate, 1) : 1;\n const originalItems = Array.isArray(q.items) ? q.items : [];\n\n let baseUsd = 0;\n let explicitFeeUsd = 0;\n q.items = originalItems.map((raw) => {\n const it = raw && typeof raw === 'object' ? { ...raw } : {};\n const units = n_(it.units ?? it.quantity, 0);\n const daysRaw = it.days;\n const days = (daysRaw === '' || daysRaw === null || daysRaw === undefined) ? 1 : n_(daysRaw, 1);\n const unitUsd = n_(it.unitCostUsd ?? it.unit_cost_usd, 0);\n const computedUsd = money_(units * days * unitUsd);\n const suppliedUsd = n_(it.totalUsd ?? it.total_usd, computedUsd);\n const totalUsd = unitUsd > 0 && units > 0 ? computedUsd : money_(suppliedUsd);\n const unitLocal = money_(unitUsd * rate);\n const totalLocal = money_(totalUsd * rate);\n\n if (isFeeSection_(it.section)) explicitFeeUsd += totalUsd;\n else baseUsd += totalUsd;\n\n return {\n ...it,\n units,\n days,\n unitCostUsd: money_(unitUsd),\n totalUsd,\n unitCostLocal: unitLocal,\n totalLocal,\n };\n });\n\n baseUsd = money_(baseUsd);\n let feeRate = n_(q.agencyFeeRate ?? q.feeRate, NaN);\n if (!Number.isFinite(feeRate) && explicitFeeUsd > 0 && baseUsd > 0) feeRate = explicitFeeUsd / baseUsd;\n if (!Number.isFinite(feeRate) || feeRate < 0) feeRate = 0.06;\n if (feeRate > 1) feeRate = feeRate / 100;\n\n const feeUsd = money_(baseUsd * feeRate);\n const totalUsd = money_(baseUsd + feeUsd);\n const feeLocal = money_(feeUsd * rate);\n const totalLocal = money_(totalUsd * rate);\n\n q.exchangeRate = rate;\n q.agencyFeeRate = feeRate;\n q.subtotalUsd = baseUsd;\n q.subtotalLocal = money_(baseUsd * rate);\n q.agencyFeeUsd = feeUsd;\n q.agencyFeeLocal = feeLocal;\n q.totalUsd = totalUsd;\n q.totalLocal = totalLocal;\n return q;\n}\n\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote) {\n const previousTotal = Number(parsed.quote.totalUsd);\n parsed.quote = normalizeQuoteFinancials_(parsed.quote);\n if (Number.isFinite(previousTotal) && Math.abs(previousTotal - parsed.quote.totalUsd) > 0.01) {\n parsed.warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];\n parsed.warnings.push(`Total recalculado automáticamente para incluir el Fee de agencia: USD ${parsed.quote.totalUsd.toFixed(2)}.`);\n }\n}\n\nconst normalized = {\n emailDisposition: String(parsed?.emailDisposition || (String(req.channel||'chat')==='email' ? 'process' : 'process')).toLowerCase()==='ignore' ? 'ignore' : 'process',\n reply: String(parsed?.reply || '').trim() || 'Listo.',\n action,\n requiresConfirmation: Boolean(parsed?.requiresConfirmation || action === 'tariff_change_proposal'),\n pendingAction: parsed?.pendingAction ?? null,\n pendingActionId: parsed?.pendingActionId ?? null,\n quote: parsed?.quote ?? null,\n artifact: parsed?.artifact ?? null,\n warnings: Array.isArray(parsed?.warnings) ? parsed.warnings.map(String).slice(0,10) : [],\n confidence: Number.isFinite(Number(parsed?.confidence)) ? Math.max(0,Math.min(1,Number(parsed.confidence))) : null,\n model: 'gemini-3.7-flash'\n};\n\nreturn [{json:{...req,agent:normalized,rawModelText:text || JSON.stringify(parsed),durationMs:Date.now()-req.startedAt}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5456,
|
||
24560
|
||
],
|
||
"id": "ec6d4762-c0b0-46f8-820c-deeeaa5cdc93",
|
||
"name": "Interpretar Gemini"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_process_my_agent_action",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_action:$json.agent,p_conversation_id:$json.conversationId,p_display_name:$json.fullName,p_session_id:$json.appSessionId}) }}",
|
||
"options": {
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-5232,
|
||
24560
|
||
],
|
||
"id": "87c90208-7fa7-4d29-8542-42aed83b13e8",
|
||
"name": "Procesar acción sensible",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x = $node[\"Interpretar Gemini\"].json;\nreturn [{json:{\n action:x.agent.action,\n quote:x.agent.quote,\n artifact:x.agent.artifact,\n user:{id:x.userId,email:x.verifiedEmail,name:x.fullName},\n conversationId:x.conversationId,\n appSessionId:x.appSessionId,\n model:x.agent.model,\n auth:x.auth\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5008,
|
||
24560
|
||
],
|
||
"id": "e2208ad4-d173-411c-9af3-30ba23cae6b2",
|
||
"name": "Preparar artefacto"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://agenteit.digitalcompass.agency/webhook/cotizador-wmc-artifacts",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({action:$json.action,quote:$json.quote,artifact:$json.artifact,user:$json.user,conversationId:$json.conversationId,appSessionId:$json.appSessionId,model:$json.model}) }}",
|
||
"options": {
|
||
"timeout": 180000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-4800,
|
||
24560
|
||
],
|
||
"id": "6c71a752-b2bd-4794-8bfa-6e0d3e2c4f87",
|
||
"name": "Generar artefacto",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x = $node[\"Interpretar Gemini\"].json;\n\nlet actionResult = {};\nlet artifactResult = {};\ntry { actionResult = $node[\"Procesar acción sensible\"].json ?? {}; } catch {}\ntry { artifactResult = $node[\"Generar artefacto\"].json ?? {}; } catch {}\n\nconst out = {...x.agent};\n\nfunction cleanUserReply_(value) {\n let s = String(value || '').trim();\n\n // La infraestructura es interna: nunca debe aparecer en el chat del usuario.\n s = s\n .replace(/tarifario\\s+oficial\\s+de\\s+supabase/gi, 'tarifario oficial')\n .replace(/tarifario\\s+de\\s+supabase/gi, 'tarifario oficial')\n .replace(/en\\s+supabase/gi, 'en el sistema')\n .replace(/\\bsupabase\\b/gi, 'el sistema')\n .replace(/\\bpostgres(?:ql)?\\b/gi, 'el sistema')\n .replace(/\\bpostgrest\\b/gi, 'el sistema')\n .replace(/\\brpc\\b/gi, 'proceso interno')\n .replace(/\\bpendingActionId\\b/gi, 'identificador de la propuesta');\n\n // Correcciones cosméticas de reemplazos.\n s = s\n .replace(/\\bde\\s+el\\s+sistema\\b/gi, 'del sistema')\n .replace(/\\ben\\s+el\\s+sistema\\s+oficial\\b/gi, 'en el sistema')\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n{3,}/g, '\\n\\n');\n\n return s.trim();\n}\n\nfunction friendlyActionError_(error, action) {\n const raw = String(error || '').trim();\n const low = raw.toLowerCase();\n\n if (\n low.includes('pertenece a otra conversacion') ||\n low.includes('pertenece a otra conversación') ||\n low.includes('ya fue aplicada/cancelada/expirada') ||\n low.includes('ya fue aplicada o pertenece a otro usuario') ||\n low.includes('accion pendiente no existe') ||\n low.includes('acción pendiente no existe')\n ) {\n return 'No pude confirmar ese cambio porque esa propuesta ya no está pendiente en este chat. Si deseas continuar, solicita una nueva propuesta de cambio.';\n }\n\n if (low.includes('falta pendingactionid')) {\n return action === 'cancel_tariff_change'\n ? 'No encontré una propuesta pendiente válida para cancelar en este chat.'\n : 'No encontré una propuesta pendiente válida para confirmar en este chat. Solicita una nueva propuesta de cambio.';\n }\n\n if (low.includes('expir') || low.includes('vencio') || low.includes('venció')) {\n return 'Esa propuesta ya venció y no puede aplicarse. Solicita una nueva propuesta de cambio.';\n }\n\n if (low.includes('permiso') || low.includes('habilitado')) {\n return 'No tienes permiso para modificar el tarifario oficial.';\n }\n\n if (low.includes('no se encontró el item') || low.includes('no se encontro el item')) {\n return 'No pude completar el cambio porque la tarifa seleccionada ya no está disponible. Consulta el tarifario vigente e inténtalo nuevamente.';\n }\n\n if (low.includes('operación pendiente no soportada') || low.includes('operacion pendiente no soportada')) {\n return 'No pude procesar ese cambio de tarifario. Genera una nueva propuesta e inténtalo nuevamente.';\n }\n\n return 'No pude completar el cambio solicitado. No se realizó ninguna modificación. Inténtalo nuevamente o genera una nueva propuesta.';\n}\n\nif (actionResult.pendingActionId) {\n out.pendingActionId = actionResult.pendingActionId;\n}\n\nif (typeof actionResult.requiresConfirmation === 'boolean') {\n out.requiresConfirmation = actionResult.requiresConfirmation;\n}\n\n// La respuesta del backend es AUTORITATIVA para acciones sensibles.\n// Nunca anexamos un error a una frase optimista de Gemini.\nif (actionResult.ok === false) {\n const friendly = friendlyActionError_(actionResult.error, out.action);\n out.reply = friendly;\n out.requiresConfirmation = false;\n out.pendingAction = null;\n out.warnings = [friendly];\n}\n\n// Para cancelación confirmada, usar un mensaje inequívoco aunque Gemini haya\n// redactado otra cosa.\nif (actionResult.ok === true && out.action === 'cancel_tariff_change') {\n out.reply = 'Entendido. La propuesta de cambio fue cancelada y no se aplicará.';\n out.requiresConfirmation = false;\n}\n\n// En éxito de confirmación, mantener la respuesta del agente, pero limpiar\n// cualquier referencia a tecnología interna.\nif (actionResult.ok === true && out.action === 'confirm_tariff_change') {\n out.requiresConfirmation = false;\n}\n\n// Resultado de artefactos.\nif (artifactResult?.artifactUrl) {\n out.artifactUrl = artifactResult.artifactUrl;\n out.artifactType = artifactResult.artifactType || null;\n out.quoteId = artifactResult.quoteId || null;\n} else if (['generate_quote','generate_proposal','generate_image','generate_monthly_glm'].includes(out.action)) {\n const err = artifactResult?.error || artifactResult?.message || 'No se pudo generar el archivo solicitado.';\n const friendly = 'No pude generar el archivo solicitado en este momento. Inténtalo nuevamente.';\n out.warnings = [...(out.warnings || []), friendly];\n out.reply = `${cleanUserReply_(out.reply)}\\n\\n⚠️ ${friendly}`.trim();\n}\n\n// Última barrera de presentación: ninguna respuesta visible debe filtrar\n// detalles de infraestructura.\nout.reply = cleanUserReply_(out.reply);\n\nif (Array.isArray(out.warnings)) {\n out.warnings = out.warnings\n .map(cleanUserReply_)\n .filter(Boolean)\n .slice(0, 10);\n}\n\nreturn [{json:{...x, final:{ok:true,...out}}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4576,
|
||
24560
|
||
],
|
||
"id": "6e53f791-7153-4db2-85fa-155083fdfb2b",
|
||
"name": "Respuesta final"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_log_my_agent_run",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_conversation_id:$json.conversationId,p_prompt:$json.text,p_response:$json.final.reply,p_action:$json.final.action,p_model:$json.final.model,p_duration_ms:$json.durationMs,p_status:'ok',p_metadata:{warnings:$json.final.warnings||[],confidence:$json.final.confidence,hasArtifact:!!$json.final.artifactUrl}}) }}",
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-4352,
|
||
24560
|
||
],
|
||
"id": "2f62e2ee-cb7d-4f43-b91e-ccbe7b3b78c7",
|
||
"name": "Registrar ejecución",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ $node[\"Respuesta final\"].json.final }}",
|
||
"options": {
|
||
"responseCode": 200
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-4112,
|
||
24560
|
||
],
|
||
"id": "75acb076-3bf4-4c6a-8da0-a73c91339419",
|
||
"name": "Responder al chat"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// Configuración local del workflow: NO usa variables de entorno.\nreturn [{json:{\n ...$json,\n config:{\n supabaseUrl:\"https://dbit.digitalcompass.agency\",\n supabaseAnonKey:\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE\",\n artifactsWebhookUrl:\"https://agenteit.digitalcompass.agency/webhook/cotizador-wmc-artifacts\",\n internalEmailSecret:\"fLf-Fl4AcRL912NsWfC4wGwlfEZ9kvOXJ33ylUjXJ9prTuML4b2sSJt3tDrG5D08\"\n }\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-9184,
|
||
24784
|
||
],
|
||
"id": "8337225f-58c9-4613-9b26-fe31ddca04bd",
|
||
"name": "Configuración - sin ENV"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"promptType": "define",
|
||
"text": "={{ $json.system + '\\n\\n' + $json.geminiPrompt }}",
|
||
"needsFallback": true,
|
||
"batching": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||
"typeVersion": 1.7,
|
||
"position": [
|
||
-6096,
|
||
24592
|
||
],
|
||
"id": "5ddf147d-5303-4856-94c5-53c0754fb521",
|
||
"name": "Gemini - Basic LLM Chain",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 4000
|
||
},
|
||
{
|
||
"parameters": {
|
||
"modelName": "models/gemini-3.7-flash",
|
||
"options": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-6096,
|
||
24800
|
||
],
|
||
"id": "b8af0928-afac-46aa-a907-daceff0e4356",
|
||
"name": "Gemini 3.7 Flash - Chat Model",
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Preparar análisis XLSX\"].json;\n\n// El HTTP Request de n8n puede convertir una respuesta JSON de varias filas\n// en varios items. Por eso NO debemos leer el historial con $node[...].json\n// como si fuera un array: eso solo toma un item.\nconst inputItems = $input.all();\nconst history = [];\n\nfunction pushRow(row) {\n if (!row || typeof row !== 'object') return;\n const role = String(row.role || '').trim();\n const content = String(row.content || '').trim();\n if (!role && !content) return;\n history.push({\n role,\n content,\n attachments: Array.isArray(row.attachments) ? row.attachments : [],\n created_at: row.created_at || row.createdAt || null,\n });\n}\n\nfor (const item of inputItems) {\n const raw = item?.json ?? {};\n if (Array.isArray(raw)) {\n raw.forEach(pushRow);\n } else if (Array.isArray(raw?.data)) {\n raw.data.forEach(pushRow);\n } else if (Array.isArray(raw?.body)) {\n raw.body.forEach(pushRow);\n } else if (raw?.body && typeof raw.body === 'object' && !Array.isArray(raw.body)) {\n pushRow(raw.body);\n } else {\n pushRow(raw);\n }\n}\n\nhistory.sort((a, b) => {\n const ta = a.created_at ? Date.parse(a.created_at) : 0;\n const tb = b.created_at ? Date.parse(b.created_at) : 0;\n return ta - tb;\n});\n\nconst countryDefs = [\n { code:'GT', names:['guatemala'] },\n { code:'CR', names:['costa rica'] },\n { code:'SV', names:['el salvador','salvador'] },\n { code:'HN', names:['honduras'] },\n { code:'NI', names:['nicaragua'] },\n];\n\nfunction detectCountry(text) {\n const original = String(text || '');\n const low = original.toLowerCase();\n for (const def of countryDefs) {\n if (def.names.some(name => low.includes(name))) return def.code;\n const re = new RegExp('(^|[^A-Za-z])' + def.code + '([^A-Za-z]|$)');\n if (re.test(original)) return def.code;\n }\n return null;\n}\n\nconst attachmentNames = (Array.isArray(req.attachments) ? req.attachments : []).map(a => String(a?.filename || '')).join(' ');\nlet effectiveCountryCode = req.countryCode || detectCountry(req.text) || detectCountry(attachmentNames);\nlet effectiveCountrySource = effectiveCountryCode ? 'mensaje_actual' : null;\n\n// Prioriza hechos que el propio usuario ya indicó en esta conversación.\nif (!effectiveCountryCode) {\n for (let i = history.length - 1; i >= 0; i--) {\n if (String(history[i].role || '').toLowerCase() !== 'user') continue;\n const found = detectCountry(history[i].content);\n if (found) {\n effectiveCountryCode = found;\n effectiveCountrySource = 'historial_usuario';\n break;\n }\n }\n}\n\n// Fallback: también puede recuperarse de una respuesta previa del agente.\nif (!effectiveCountryCode) {\n for (let i = history.length - 1; i >= 0; i--) {\n const found = detectCountry(history[i].content);\n if (found) {\n effectiveCountryCode = found;\n effectiveCountrySource = 'historial_conversacion';\n break;\n }\n }\n}\n\nconst lastUserMessages = history\n .filter(h => String(h.role || '').toLowerCase() === 'user' && h.content)\n .slice(-6)\n .map(h => h.content);\n\nconst searchQuery = [req.text, ...lastUserMessages]\n .filter(Boolean)\n .join(' | ')\n .slice(0, 12000);\n\nreturn [{\n json: {\n ...req,\n conversationHistory: history.slice(-40),\n effectiveCountryCode: effectiveCountryCode || null,\n effectiveCountrySource,\n searchQuery,\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6560,
|
||
24368
|
||
],
|
||
"id": "7b2ae93f-8c8d-4f6f-bd73-8915530d37df",
|
||
"name": "Resolver contexto conversacional"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "f5c2e703-bd56-43de-bde9-131d85873cc9",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase().startsWith('audio/') || /\\.(webm|mp3|wav|m4a|ogg|oga|aac|flac|mpeg|mpga)$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6992,
|
||
24352
|
||
],
|
||
"id": "40ebefcc-3635-41a1-8509-04e0f2a37055",
|
||
"name": "Tiene audio?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "audio",
|
||
"modelId": {
|
||
"__rl": true,
|
||
"value": "models/gemini-3.7-flash",
|
||
"mode": "id"
|
||
},
|
||
"audioUrls": "={{ ($node[\"Validar acceso\"].json.attachments || []).filter(a => String(a?.mediaType || '').toLowerCase().startsWith('audio/') || /\\.(webm|mp3|wav|m4a|ogg|oga|aac|flac|mpeg|mpga)$/i.test(String(a?.filename || ''))).map(a => a.url).filter(Boolean).join(',') }}",
|
||
"options": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.googleGemini",
|
||
"typeVersion": 1.2,
|
||
"position": [
|
||
-6768,
|
||
24176
|
||
],
|
||
"id": "d9d1e711-2999-491b-a7fd-b608ba801926",
|
||
"name": "Gemini - Transcribir audio",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Validar acceso\"].json;\nconst incomingItems = $input.all().map(i => i?.json ?? {});\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\n\nconst isAudio = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media.startsWith('audio/') || /\\.(webm|mp3|wav|m4a|ogg|oga|aac|flac|mpeg|mpga)$/i.test(name);\n};\n\nconst audioAttachments = attachments.filter(isAudio);\nconst hasAudio = audioAttachments.length > 0;\n\nfunction extractText(raw) {\n if (!raw || typeof raw !== 'object') return '';\n\n const direct = [\n raw.transcription,\n raw.transcript,\n raw.text,\n raw.output,\n raw.response,\n raw.result\n ];\n for (const value of direct) {\n if (typeof value === 'string' && value.trim()) return value.trim();\n }\n\n if (typeof raw.content === 'string' && raw.content.trim()) {\n return raw.content.trim();\n }\n\n const parts =\n (Array.isArray(raw.content?.parts) && raw.content.parts) ||\n (Array.isArray(raw.candidates?.[0]?.content?.parts) && raw.candidates[0].content.parts) ||\n null;\n\n if (parts) {\n const text = parts.map(p => typeof p?.text === 'string' ? p.text : '').filter(Boolean).join('\\n').trim();\n if (text) return text;\n }\n\n if (Array.isArray(raw.results)) {\n const text = raw.results.map(extractText).filter(Boolean).join('\\n').trim();\n if (text) return text;\n }\n\n return '';\n}\n\nconst transcriptions = hasAudio ? incomingItems.map(extractText).filter(Boolean) : [];\nconst audioTranscription = [...new Set(transcriptions)].join('\\n').trim();\nconst originalText = String(base.text || '').trim();\n\nlet text = originalText;\nlet audioTranscriptionError = false;\n\nif (hasAudio) {\n if (audioTranscription) {\n text = originalText\n ? `${originalText}\\n\\n[Transcripción de la nota de voz]\\n${audioTranscription}`\n : audioTranscription;\n } else {\n audioTranscriptionError = true;\n if (!text) {\n text = 'El usuario envió una nota de voz, pero no fue posible transcribirla automáticamente. Pídele que vuelva a intentarlo o que escriba el contenido.';\n }\n }\n}\n\nconst countryMap = [\n ['GT','guatemala'],\n ['CR','costa rica'],\n ['SV','el salvador'],\n ['SV','salvador'],\n ['HN','honduras'],\n ['NI','nicaragua']\n];\n\nconst low = text.toLowerCase();\nlet countryCode = base.countryCode || null;\nif (!countryCode) {\n for (const [code, term] of countryMap) {\n if (low.includes(term)) {\n countryCode = code;\n break;\n }\n }\n}\n\nreturn [{\n json: {\n ...base,\n text,\n countryCode,\n audioTranscription: audioTranscription || null,\n audioTranscriptionError,\n audioAttachmentCount: audioAttachments.length\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6544,
|
||
24176
|
||
],
|
||
"id": "a0de31a2-85c5-43d2-adc8-4d73c774e15e",
|
||
"name": "Preparar entrada final"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"modelName": "models/gemini-2.5-pro",
|
||
"options": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-5904,
|
||
24896
|
||
],
|
||
"id": "9118cb65-17cb-490a-bcb8-218d76b4a11d",
|
||
"name": "Gemini 2.5 Pro - Fallback",
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b7a46f9e-51c6-47db-bfae-c96c12775fe6",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase().startsWith('image/') || /\\.(png|jpe?g|webp|gif|bmp|heic|heif)$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6320,
|
||
24176
|
||
],
|
||
"id": "938efcc1-103b-40a0-aa64-359ffd4d5d26",
|
||
"name": "Tiene imágenes?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "image",
|
||
"operation": "analyze",
|
||
"modelId": {
|
||
"__rl": true,
|
||
"value": "models/gemini-2.5-pro",
|
||
"mode": "id"
|
||
},
|
||
"text": "Analiza fielmente las imágenes adjuntas para ayudar al Cotizador Walmart Connect. Describe solo información visible o explícita: textos legibles, marcas, productos, cantidades visibles, tipo de personal, uniformes, mobiliario, medidas si están escritas, materiales, formato/entorno de tienda y cualquier detalle útil para una cotización o propuesta. Si algo no puede determinarse con certeza, indícalo; no inventes datos ni precios.",
|
||
"imageUrls": "={{ ($node[\"Preparar entrada final\"].json.attachments || []).filter(a => String(a?.mediaType || '').toLowerCase().startsWith('image/') || /\\.(png|jpe?g|webp|gif|bmp|heic|heif)$/i.test(String(a?.filename || ''))).map(a => a.url).filter(Boolean).join(',') }}",
|
||
"options": {
|
||
"maxOutputTokens": 1800
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.googleGemini",
|
||
"typeVersion": 1.2,
|
||
"position": [
|
||
-6096,
|
||
24032
|
||
],
|
||
"id": "aa13f9c7-7a1f-484b-8551-6aca684c7725",
|
||
"name": "Gemini - Analizar imágenes",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar entrada final\"].json;\nconst incomingItems = $input.all().map(i => i?.json ?? {});\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isImage = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media.startsWith('image/') || /\\.(png|jpe?g|webp|gif|bmp|heic|heif)$/i.test(name);\n};\nconst imageAttachments = attachments.filter(isImage);\nfunction extractText(raw) {\n if (!raw || typeof raw !== 'object') return '';\n const direct = [raw.transcription, raw.transcript, raw.text, raw.output, raw.response, raw.result, raw.content];\n for (const value of direct) {\n if (typeof value === 'string' && value.trim()) return value.trim();\n if (Array.isArray(value)) {\n const joined = value.map(v => {\n if (typeof v === 'string') return v;\n if (v && typeof v.text === 'string') return v.text;\n return '';\n }).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n }\n const parts =\n (Array.isArray(raw.content?.parts) && raw.content.parts) ||\n (Array.isArray(raw.candidates?.[0]?.content?.parts) && raw.candidates[0].content.parts) ||\n null;\n if (parts) {\n const joined = parts.map(p => typeof p?.text === 'string' ? p.text : '').filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n if (Array.isArray(raw.results)) {\n const joined = raw.results.map(extractText).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n return '';\n}\nconst analyses = imageAttachments.length ? incomingItems.map(extractText).filter(Boolean) : [];\nconst imageAnalysis = [...new Set(analyses)].join('\\n').trim();\nconst imageAnalysisError = imageAttachments.length > 0 && !imageAnalysis;\nlet text = String(base.text || '').trim();\nif (imageAnalysis) {\n text += (text ? '\\n\\n' : '') + '[Análisis de imágenes adjuntas]\\n' + imageAnalysis;\n} else if (imageAnalysisError && !text) {\n text = 'El usuario adjuntó una o más imágenes, pero no fue posible analizarlas automáticamente. Pídele que vuelva a intentarlo con una imagen legible.';\n}\nreturn [{json:{...base,text,imageAnalysis:imageAnalysis || null,imageAnalysisError,imageAttachmentCount:imageAttachments.length}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5872,
|
||
24176
|
||
],
|
||
"id": "a25d08bb-5401-44a6-a864-10f1e3e00c1a",
|
||
"name": "Preparar análisis imágenes"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "3b78d6f1-4093-4422-9305-1a1da5abdb7c",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase() === 'application/pdf' || /\\.pdf$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-5648,
|
||
24176
|
||
],
|
||
"id": "15983c50-adbb-4736-81af-e383ecf7b397",
|
||
"name": "Tiene documentos PDF?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "document",
|
||
"modelId": {
|
||
"__rl": true,
|
||
"value": "models/gemini-2.5-pro",
|
||
"mode": "id"
|
||
},
|
||
"text": "Analiza fielmente los documentos PDF adjuntos para ayudar al Cotizador Walmart Connect. Extrae y resume únicamente información explícita relevante: país, anunciante, proyecto, fechas, formatos, tiendas/ubicaciones, personal, cantidades, días, mobiliario, equipos, insumos, logística, medidas, precios, monedas, condiciones, observaciones y cualquier instrucción de cotización. Lee también tablas, diagramas e imágenes del PDF cuando sean relevantes. No inventes información ni precios; marca claramente cualquier dato que no esté presente.",
|
||
"inputType": "binary",
|
||
"options": {
|
||
"maxOutputTokens": 2400
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.googleGemini",
|
||
"typeVersion": 1.2,
|
||
"position": [
|
||
-5424,
|
||
24032
|
||
],
|
||
"id": "98f26a8a-3f0b-41fe-a432-14d36ccaaa84",
|
||
"name": "Gemini - Analizar documentos",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis imágenes\"].json;\nconst incomingItems = $input.all().map(i => i?.json ?? {});\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isPdf = (a) => String(a?.mediaType || '').toLowerCase() === 'application/pdf' || /\\.pdf$/i.test(String(a?.filename || ''));\nconst isOtherDocument = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n if (isPdf(a) || media.startsWith('image/') || media.startsWith('audio/') || media.startsWith('video/')) return false;\n return /\\.(docx?|xls|pptx?|csv|txt|rtf|odt|ods|md|html?|xml)$/i.test(name) ||\n media.startsWith('text/') ||\n media.includes('word') || media.includes('excel') || media.includes('spreadsheet') || media.includes('presentation') || media.includes('opendocument');\n};\nconst pdfAttachments = attachments.filter(isPdf);\nconst otherDocumentAttachments = attachments.filter(isOtherDocument);\nfunction extractText(raw) {\n if (!raw || typeof raw !== 'object') return '';\n const direct = [raw.transcription, raw.transcript, raw.text, raw.output, raw.response, raw.result, raw.content];\n for (const value of direct) {\n if (typeof value === 'string' && value.trim()) return value.trim();\n if (Array.isArray(value)) {\n const joined = value.map(v => {\n if (typeof v === 'string') return v;\n if (v && typeof v.text === 'string') return v.text;\n return '';\n }).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n }\n const parts =\n (Array.isArray(raw.content?.parts) && raw.content.parts) ||\n (Array.isArray(raw.candidates?.[0]?.content?.parts) && raw.candidates[0].content.parts) ||\n null;\n if (parts) {\n const joined = parts.map(p => typeof p?.text === 'string' ? p.text : '').filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n if (Array.isArray(raw.results)) {\n const joined = raw.results.map(extractText).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n return '';\n}\nconst analyses = pdfAttachments.length ? incomingItems.map(extractText).filter(Boolean) : [];\nconst documentAnalysis = [...new Set(analyses)].join('\\n').trim();\nconst documentAnalysisError = pdfAttachments.length > 0 && !documentAnalysis;\nlet text = String(base.text || '').trim();\nif (documentAnalysis) {\n text += (text ? '\\n\\n' : '') + '[Análisis de documentos PDF adjuntos]\\n' + documentAnalysis;\n} else if (documentAnalysisError && !text) {\n text = 'El usuario adjuntó un documento PDF, pero no fue posible analizarlo automáticamente. Pídele que vuelva a intentarlo con un PDF legible.';\n}\nreturn [{json:{\n ...base,\n text,\n documentAnalysis:documentAnalysis || null,\n documentAnalysisError,\n documentAttachmentCount:pdfAttachments.length,\n unparsedDocumentAttachments:otherDocumentAttachments.map(a => ({filename:a.filename || 'archivo',mediaType:a.mediaType || ''})).slice(0,8)\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5200,
|
||
24176
|
||
],
|
||
"id": "1118bf03-ff30-4805-9e07-41020e00d23c",
|
||
"name": "Preparar análisis documentos"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis imágenes\"].json;\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isPdf = (a) => String(a?.mediaType || '').toLowerCase() === 'application/pdf' || /\\.pdf$/i.test(String(a?.filename || ''));\nconst pdfs = attachments.filter(isPdf).filter(a => String(a?.url || '').trim()).slice(0, 4);\n\nif (!pdfs.length) {\n return [{json:{...base,pdfDownloadMissing:true}}];\n}\n\nreturn pdfs.map((a,index) => ({\n json:{\n ...base,\n pdfAttachmentIndex:index,\n pdfFilename:String(a.filename || `documento-${index + 1}.pdf`),\n pdfMediaType:String(a.mediaType || 'application/pdf'),\n pdfUrl:String(a.url || '')\n }\n}));"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5424,
|
||
23840
|
||
],
|
||
"id": "446d2c79-1e0f-4be9-9953-e75444ce69d3",
|
||
"name": "Preparar PDFs para descarga"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ $json.pdfUrl }}",
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
},
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-5200,
|
||
23840
|
||
],
|
||
"id": "0e20a1cc-ec6e-4720-9615-a3d95f9ef89e",
|
||
"name": "Descargar PDFs",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c862e84c-64e9-474a-b247-1b2269b552f6",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase() === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || /\\.xlsx$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-4976,
|
||
24176
|
||
],
|
||
"id": "60fcc326-7180-4550-977e-d621f1c23d64",
|
||
"name": "Tiene Excel XLSX?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis documentos\"].json;\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isXlsx = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || /\\.xlsx$/i.test(name);\n};\nconst files = attachments.filter(isXlsx).filter(a => String(a?.url || '').trim()).slice(0, 4);\n\nif (!files.length) {\n return [{json:{...base,xlsxDownloadMissing:true}}];\n}\n\nreturn files.map((a,index) => ({\n json:{\n ...base,\n xlsxAttachmentIndex:index,\n xlsxFilename:String(a.filename || `excel-${index + 1}.xlsx`),\n xlsxWmcCode:((String(a.filename || '').match(/WMC\\d+/i) || [])[0] || '').toUpperCase(),\n xlsxMediaType:String(a.mediaType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),\n xlsxUrl:String(a.url || '')\n }\n}));"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4752,
|
||
24032
|
||
],
|
||
"id": "feac2eaf-9010-4377-a3fe-b5856ae77bdc",
|
||
"name": "Preparar XLSX para descarga"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ $json.xlsxUrl }}",
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
},
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-4528,
|
||
24032
|
||
],
|
||
"id": "a8744a5d-ca96-4f67-985f-dfa909a57f68",
|
||
"name": "Descargar XLSX",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis documentos\"].json;\nconst incoming = $input.all();\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isXlsx = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || /\\.xlsx$/i.test(name);\n};\nconst xlsxAttachments = attachments.filter(isXlsx).slice(0, 4);\n\n// Si no hay XLSX, esta misma salida mantiene intacto el flujo existente.\nif (!xlsxAttachments.length) {\n return [{json:{\n ...base,\n spreadsheetAnalysis:null,\n spreadsheetAnalysisError:false,\n spreadsheetAttachmentCount:0,\n spreadsheetAnalysisScope:null,\n spreadsheetSheetNames:[],\n spreadsheetExtractionWarnings:[]\n }}];\n}\n\nlet metas = [];\ntry { metas = $items(\"Mapear Google Sheet temporal\"); } catch {}\n\nconst MAX_TOTAL_CHARS = 80000;\nconst MAX_ROWS_PER_SHEET = 1500;\nconst MAX_COLS_PER_SHEET = 100;\nconst MAX_SHEETS_PER_FILE = 40;\nlet totalChars = 0;\nlet truncated = false;\nconst blocks = [];\nconst sheetInventory = [];\nconst extractionWarnings = [];\n\nfunction clean(v) {\n return String(v ?? '').replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]+/g,' ').replace(/\\s+/g,' ').trim();\n}\nfunction colLabel(index) {\n let n = Number(index) + 1;\n let out = '';\n while (n > 0) {\n const rem = (n - 1) % 26;\n out = String.fromCharCode(65 + rem) + out;\n n = Math.floor((n - 1) / 26);\n }\n return out;\n}\nfunction effectiveCellValue(cell) {\n if (!cell || typeof cell !== 'object') return '';\n if (cell.formattedValue !== undefined && cell.formattedValue !== null) return clean(cell.formattedValue);\n const ev = cell.effectiveValue;\n if (!ev || typeof ev !== 'object') return '';\n for (const key of ['stringValue','numberValue','boolValue','formulaValue']) {\n if (ev[key] !== undefined && ev[key] !== null) return clean(ev[key]);\n }\n if (ev.errorValue) return clean(ev.errorValue.message || ev.errorValue.type || 'ERROR');\n return '';\n}\nfunction errorText(raw, fallback) {\n const e = raw?.error ?? raw?.message ?? fallback;\n if (!e) return '';\n if (typeof e === 'string') return clean(e);\n if (typeof e?.message === 'string') return clean(e.message);\n try { return clean(JSON.stringify(e)); } catch { return clean(String(e)); }\n}\n\nfor (let fileIndex = 0; fileIndex < Math.max(incoming.length, xlsxAttachments.length); fileIndex++) {\n const raw = incoming[fileIndex]?.json || {};\n const meta = metas[fileIndex]?.json || {};\n const filename = clean(meta.xlsxFilename || xlsxAttachments[fileIndex]?.filename || `archivo-${fileIndex + 1}.xlsx`);\n const sheets = Array.isArray(raw.sheets) ? raw.sheets.slice(0, MAX_SHEETS_PER_FILE) : [];\n\n if (!sheets.length) {\n const err = errorText(raw, meta.tempGoogleSheetConversionError || meta.tempXlsxUploadError || 'No se recibieron hojas del libro convertido.');\n extractionWarnings.push(`${filename}: ${err}`);\n continue;\n }\n\n const names = [];\n for (const sheet of sheets) {\n if (totalChars >= MAX_TOTAL_CHARS) { truncated = true; break; }\n const props = sheet?.properties || {};\n const sheetName = clean(props.title || `Hoja ${names.length + 1}`);\n names.push(sheetName);\n const hiddenLabel = props.hidden === true ? ' (oculta)' : '';\n const rowMap = new Map();\n let maxSeenCol = -1;\n\n for (const grid of (Array.isArray(sheet?.data) ? sheet.data : [])) {\n const startRow = Number(grid?.startRow || 0);\n const startCol = Number(grid?.startColumn || 0);\n const rows = Array.isArray(grid?.rowData) ? grid.rowData : [];\n for (let r = 0; r < rows.length; r++) {\n const absoluteRow = startRow + r;\n if (absoluteRow >= MAX_ROWS_PER_SHEET) { truncated = true; break; }\n const vals = Array.isArray(rows[r]?.values) ? rows[r].values : [];\n if (!rowMap.has(absoluteRow)) rowMap.set(absoluteRow, new Map());\n const row = rowMap.get(absoluteRow);\n for (let c = 0; c < vals.length; c++) {\n const absoluteCol = startCol + c;\n if (absoluteCol >= MAX_COLS_PER_SHEET) { truncated = true; break; }\n const value = effectiveCellValue(vals[c]);\n if (value !== '') {\n row.set(absoluteCol, value);\n if (absoluteCol > maxSeenCol) maxSeenCol = absoluteCol;\n }\n }\n }\n }\n\n const lines = [];\n const sortedRows = [...rowMap.keys()].sort((a,b)=>a-b);\n for (const r of sortedRows) {\n if (totalChars >= MAX_TOTAL_CHARS) { truncated = true; break; }\n const row = rowMap.get(r);\n if (!row || !row.size) continue;\n const cells = [...row.entries()].sort((a,b)=>a[0]-b[0]);\n const line = `Fila ${r + 1}: ` + cells.map(([c,v]) => `[${colLabel(c)}] ${v}`).join(' | ');\n if (totalChars + line.length + 1 > MAX_TOTAL_CHARS) { truncated = true; break; }\n lines.push(line);\n totalChars += line.length + 1;\n }\n\n blocks.push(\n `Archivo: ${filename}\\n` +\n `Hoja analizada: ${sheetName}${hiddenLabel}\\n` +\n (lines.length ? lines.join('\\n') : '(Hoja sin celdas con contenido legible)')\n );\n }\n sheetInventory.push({file:filename,sheets:names});\n}\n\nif (truncated) extractionWarnings.push('La extracción XLSX fue truncada por límites de seguridad de tamaño; se conservaron las primeras hojas/filas/celdas dentro del límite.');\nconst spreadsheetAnalysis = blocks.join('\\n\\n').trim();\nconst spreadsheetAnalysisError = !spreadsheetAnalysis;\nlet text = String(base.text || '').trim();\n\nif (spreadsheetAnalysis) {\n text += (text ? '\\n\\n' : '') +\n '[Contenido REAL extraído de archivos Excel XLSX adjuntos mediante conversión temporal a Google Sheets]\\n' +\n spreadsheetAnalysis;\n} else if (!text) {\n text = 'El usuario adjuntó un archivo Excel XLSX, pero no fue posible leer su contenido automáticamente. Pídele únicamente que vuelva a adjuntarlo; no inventes su contenido.';\n}\n\nreturn [{json:{\n ...base,\n text,\n spreadsheetAnalysis: spreadsheetAnalysis || null,\n spreadsheetAnalysisError,\n spreadsheetAttachmentCount: xlsxAttachments.length,\n spreadsheetAnalysisScope: spreadsheetAnalysis ? 'Todas las hojas con contenido legible del XLSX, hasta 40 hojas por archivo, 1,500 filas y 100 columnas por hoja, con límite total de 80,000 caracteres.' : null,\n spreadsheetSheetNames: sheetInventory,\n spreadsheetExtractionWarnings: [...new Set(extractionWarnings)].slice(0,10)\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4080,
|
||
24176
|
||
],
|
||
"id": "a2a4f93c-3892-4465-bcd1-c18189abca2d",
|
||
"name": "Preparar análisis XLSX"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const downloaded = $input.all();\nlet metas = [];\ntry { metas = $items(\"Preparar XLSX para descarga\"); } catch {}\n\nreturn downloaded.map((item, i) => {\n const meta = metas[i]?.json || {};\n const binary = item?.binary || {};\n const hasBinary = Boolean(binary?.data);\n const rawError = item?.json?.error ?? item?.json?.message ?? null;\n const downloadError = hasBinary ? null : String(\n typeof rawError === 'string' ? rawError : (rawError?.message || 'No se recibió el binario del XLSX.')\n );\n const safeName = String(meta.xlsxFilename || `archivo-${i + 1}.xlsx`)\n .replace(/[^a-zA-Z0-9._-]+/g, '_')\n .slice(0, 120);\n\n return {\n json: {\n ...meta,\n xlsxDownloadOk: hasBinary,\n xlsxDownloadError: downloadError,\n tempUploadName: `_TEMP_WMC_XLSX_${Date.now()}_${i + 1}_${safeName}`\n },\n binary,\n pairedItem: { item: i }\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4416,
|
||
23888
|
||
],
|
||
"id": "02adedd6-7790-4ad7-b315-f7e0c4b4f556",
|
||
"name": "Reasociar XLSX descargado"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"name": "={{ $json.tempUploadName }}",
|
||
"driveId": {
|
||
"__rl": true,
|
||
"value": "My Drive",
|
||
"mode": "list"
|
||
},
|
||
"folderId": {
|
||
"__rl": true,
|
||
"value": "root",
|
||
"mode": "list",
|
||
"cachedResultName": "/ (Root folder)"
|
||
},
|
||
"options": {
|
||
"simplifyOutput": true
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.googleDrive",
|
||
"typeVersion": 3,
|
||
"position": [
|
||
-4192,
|
||
23888
|
||
],
|
||
"id": "f3b77965-bf96-489d-9195-b203b621fcf6",
|
||
"name": "Subir XLSX temporal a Drive",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const uploads = $input.all();\nlet sources = [];\ntry { sources = $items(\"Reasociar XLSX descargado\"); } catch {}\n\nreturn uploads.map((item, i) => {\n const base = sources[i]?.json || {};\n const raw = item?.json || {};\n const id = String(raw.id || '').trim();\n let error = null;\n if (!id) {\n const e = raw.error ?? raw.message ?? base.xlsxDownloadError ?? 'Google Drive no devolvió ID al subir el XLSX temporal.';\n error = String(typeof e === 'string' ? e : (e?.message || JSON.stringify(e)));\n }\n return {\n json: {\n ...base,\n tempXlsxDriveId: id || null,\n tempXlsxUploadError: error,\n tempXlsxUploadOk: Boolean(id)\n },\n pairedItem: { item: i }\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3968,
|
||
23888
|
||
],
|
||
"id": "bf657feb-7821-4e5e-8e31-103811cb1d1b",
|
||
"name": "Mapear XLSX temporal"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ 'https://www.googleapis.com/drive/v2/files/' + encodeURIComponent($json.tempXlsxDriveId || 'invalid') + '/copy?convert=true' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleDriveOAuth2Api",
|
||
"options": {
|
||
"timeout": 60000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-3744,
|
||
23888
|
||
],
|
||
"id": "122f6092-6335-4d56-8210-5a05d32cefef",
|
||
"name": "Convertir XLSX a Google Sheets",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const converted = $input.all();\nlet sources = [];\ntry { sources = $items(\"Mapear XLSX temporal\"); } catch {}\n\nreturn converted.map((item, i) => {\n const base = sources[i]?.json || {};\n const raw = item?.json || {};\n const id = String(raw.id || '').trim();\n const mime = String(raw.mimeType || '').trim();\n let error = null;\n if (!id) {\n const e = raw.error ?? raw.message ?? base.tempXlsxUploadError ?? 'No fue posible convertir el XLSX a Google Sheets.';\n error = String(typeof e === 'string' ? e : (e?.message || JSON.stringify(e)));\n }\n return {\n json: {\n ...base,\n tempGoogleSheetId: id || null,\n tempGoogleSheetMimeType: mime || null,\n tempGoogleSheetConversionOk: Boolean(id),\n tempGoogleSheetConversionError: error\n },\n pairedItem: { item: i }\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3520,
|
||
23888
|
||
],
|
||
"id": "7bc161be-1e46-416e-b455-9639eec6fa76",
|
||
"name": "Mapear Google Sheet temporal"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + encodeURIComponent($json.tempGoogleSheetId || 'invalid') }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleDriveOAuth2Api",
|
||
"sendQuery": true,
|
||
"queryParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "includeGridData",
|
||
"value": "true"
|
||
},
|
||
{
|
||
"name": "fields",
|
||
"value": "spreadsheetId,properties(title),sheets(properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)),data(startRow,startColumn,rowData(values(formattedValue,effectiveValue))))"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 90000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-3296,
|
||
23888
|
||
],
|
||
"id": "e33a9fa1-2bd3-4bf2-801b-ea4569e37c98",
|
||
"name": "Leer todas las hojas XLSX",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "let metas = [];\ntry { metas = $items(\"Mapear Google Sheet temporal\"); } catch {}\nconst out = [];\nconst seen = new Set();\nfor (let i = 0; i < metas.length; i++) {\n const m = metas[i]?.json || {};\n for (const [kind, value] of [\n ['google_sheet', m.tempGoogleSheetId],\n ['xlsx_original', m.tempXlsxDriveId]\n ]) {\n const id = String(value || '').trim();\n if (!id || seen.has(id)) continue;\n seen.add(id);\n out.push({json:{deleteFileId:id,deleteKind:kind,xlsxFilename:m.xlsxFilename || null}});\n }\n}\nreturn out;"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3072,
|
||
23648
|
||
],
|
||
"id": "a1142b48-3f52-4344-9893-e137ef3fe4b2",
|
||
"name": "Preparar limpieza XLSX temporal"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "deleteFile",
|
||
"fileId": {
|
||
"__rl": true,
|
||
"value": "={{ $json.deleteFileId }}",
|
||
"mode": "id"
|
||
},
|
||
"options": {
|
||
"deletePermanently": true
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.googleDrive",
|
||
"typeVersion": 3,
|
||
"position": [
|
||
-2848,
|
||
23648
|
||
],
|
||
"id": "b8dce0cf-2bfc-47b9-95bd-82434e619033",
|
||
"name": "Eliminar temporales XLSX",
|
||
"retryOnFail": true,
|
||
"maxTries": 2,
|
||
"waitBetweenTries": 1500,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"httpMethod": "POST",
|
||
"path": "cotizador-wmc-email-agent",
|
||
"responseMode": "responseNode",
|
||
"options": {
|
||
"allowedOrigins": "*"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.webhook",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
-9408,
|
||
25504
|
||
],
|
||
"id": "6bf85cb3-2529-4a9e-9560-27fec3fe73b7",
|
||
"name": "Webhook - Email Agent",
|
||
"webhookId": "e9d8ac3a-68e3-415d-8f1d-8ed232b10979"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const body = $json.body ?? {};\nconst headers = $json.headers ?? {};\nconst clean = (v, max = 12000) => typeof v === 'string' ? v.trim().slice(0,max) : '';\nconst suppliedSecret = clean(headers['x-wmc-internal-secret'] || headers['X-WMC-Internal-Secret'], 500);\nconst expectedSecret = 'fLf-Fl4AcRL912NsWfC4wGwlfEZ9kvOXJ33ylUjXJ9prTuML4b2sSJt3tDrG5D08';\nconst gmailThreadId = clean(body.gmailThreadId, 300);\nconst gmailMessageId = clean(body.gmailMessageId, 300);\nconst senderEmail = clean(body.senderEmail, 320).toLowerCase();\nconst senderName = clean(body.senderName, 240);\nconst text = clean(body.text, 20000);\nconst attachments = Array.isArray(body.attachments) ? body.attachments.slice(0,8).map(a => ({\n filename: clean(a?.filename,220), mediaType: clean(a?.mediaType,140), url: clean(a?.url,6000), storagePath:null\n})) : [];\nreturn [{json:{\n channel:'email', internalEmailAuthorized: suppliedSecret === expectedSecret,\n text, conversationId:gmailThreadId ? `email:${gmailThreadId}` : '', messageId:gmailMessageId,\n userEmail:senderEmail, userName:senderName, verifiedEmail:senderEmail, fullName:senderName || senderEmail,\n userId:null, canUseTarifario:false, auth:'', appSessionId:clean(body.appSessionId,250)||`email-${Date.now()}`,\n sentAt:clean(body.sentAt,100)||new Date().toISOString(), attachments,\n countryCode:clean(body.countryCode,10).toUpperCase()||null, startedAt:Date.now(),\n preloadedContext: body.context && typeof body.context==='object' ? body.context : {},\n preloadedHistory: Array.isArray(body.history) ? body.history : [],\n emailMeta: body.emailMeta && typeof body.emailMeta==='object' ? body.emailMeta : {},\n approvalRevision: body.approvalRevision && typeof body.approvalRevision==='object' ? body.approvalRevision : null\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8960,
|
||
25504
|
||
],
|
||
"id": "8e68ac73-ff84-4fcc-b5cb-b4b57319ecc8",
|
||
"name": "Normalizar email interno"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a9d37585-cd5b-4bbf-bdda-fbce4d60adb6",
|
||
"leftValue": "={{ $json.internalEmailAuthorized }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-8736,
|
||
25504
|
||
],
|
||
"id": "ee90351f-22ad-4193-bccd-61be44492b8a",
|
||
"name": "Email interno autorizado?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ {ok:false,error:'Solicitud interna no autorizada.'} }}",
|
||
"options": {
|
||
"responseCode": 401
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-8512,
|
||
25728
|
||
],
|
||
"id": "8f87cf16-5278-454b-9aca-0212a919e066",
|
||
"name": "Responder 401 Email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "7febb952-8ac4-4745-b462-edb0b99393ec",
|
||
"leftValue": "={{ $json.channel }}",
|
||
"rightValue": "email",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-7952,
|
||
24032
|
||
],
|
||
"id": "39120bee-42e1-4a45-b58c-57a9f1c78155",
|
||
"name": "¿Canal email?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Preparar análisis XLSX\"].json;\nconst history = Array.isArray(req.preloadedHistory) ? req.preloadedHistory.slice(-40) : [];\nconst context = req.preloadedContext && typeof req.preloadedContext === 'object' ? req.preloadedContext : {};\nreturn [{json:{\n ...req,\n channel:'email',\n conversationHistory:history,\n agentContext:context,\n pendingActions:[],\n canUseTarifario:false\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7728,
|
||
24032
|
||
],
|
||
"id": "84e36b64-fb96-4cc0-a6f1-3905fe92aab8",
|
||
"name": "Empaquetar contexto email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Resolver contexto conversacional\"].json;\nconst context = $node[\"Contexto Supabase\"].json ?? {};\nconst pendingItems = $items(\"Acciones pendientes\").map(i => i?.json ?? {}).filter(Boolean);\nconst pending = [];\nfor (const raw of pendingItems) {\n if (Array.isArray(raw)) pending.push(...raw);\n else if (Array.isArray(raw?.items)) pending.push(...raw.items);\n else if (Array.isArray(raw?.data)) pending.push(...raw.data);\n else if (Array.isArray(raw?.body)) pending.push(...raw.body);\n else if (raw && typeof raw === 'object' && Object.keys(raw).length) pending.push(raw);\n}\nreturn [{json:{\n ...req,\n channel:'chat',\n conversationHistory:Array.isArray(req.conversationHistory)?req.conversationHistory.slice(-30):[],\n agentContext:context,\n pendingActions:pending\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7504,
|
||
24256
|
||
],
|
||
"id": "00ce8222-3388-4ee3-acbe-0fff2399c258",
|
||
"name": "Empaquetar contexto chat"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a3a853f8-67a1-4393-b76a-888cf737e419",
|
||
"leftValue": "={{ $json.channel }}",
|
||
"rightValue": "email",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6640,
|
||
23616
|
||
],
|
||
"id": "a519787f-80af-45da-978a-ca08aaccff59",
|
||
"name": "¿Salida email?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "8d81eadb-cb73-4803-8110-17d1e972f305",
|
||
"leftValue": "={{ [\"generate_quote\",\"generate_proposal\",\"generate_image\",\"generate_monthly_glm\"].includes($json.agent.action) && $json.agent.emailDisposition !== \"ignore\" }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6416,
|
||
23424
|
||
],
|
||
"id": "bf995f50-959b-42c0-97a4-64d5527468bf",
|
||
"name": "¿Email requiere artefacto?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x=$node[\"Interpretar Gemini\"].json;\nreturn [{json:{action:x.agent.action,quote:x.agent.quote,artifact:x.agent.artifact,user:{id:null,email:'wmc@gomezlee.marketing',name:'Walmart Connect Email Agent'},conversationId:x.conversationId,appSessionId:x.appSessionId,model:x.agent.model,internalSecret:'fLf-Fl4AcRL912NsWfC4wGwlfEZ9kvOXJ33ylUjXJ9prTuML4b2sSJt3tDrG5D08'}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6192,
|
||
23296
|
||
],
|
||
"id": "ef9f3dbf-2938-4f97-8efb-fafc4a56db6b",
|
||
"name": "Preparar artefacto email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://agenteit.digitalcompass.agency/webhook/cotizador-wmc-artifacts",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "X-WMC-Internal-Secret",
|
||
"value": "={{ $json.internalSecret }}"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({action:$json.action,quote:$json.quote,artifact:$json.artifact,user:$json.user,conversationId:$json.conversationId,appSessionId:$json.appSessionId,model:$json.model,internalEmail:true}) }}",
|
||
"options": {
|
||
"timeout": 180000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-5968,
|
||
23296
|
||
],
|
||
"id": "9aac423b-63fb-4561-92ab-ed66b01872c8",
|
||
"name": "Generar artefacto email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x=$node[\"Interpretar Gemini\"].json;\nconst g=$json??{};\nconst final={ok:g.ok!==false,...x.agent,artifactUrl:g.artifactUrl||null,artifactType:g.artifactType||null,artifactId:g.artifactId||null,quoteId:g.quoteId||null,title:g.title||null};\nif(g.ok===false){final.ok=false;final.warnings=[...(final.warnings||[]),'No se pudo generar el archivo solicitado.'];final.error=g.error||'Error de artefacto';}\nreturn [{json:final}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5744,
|
||
23296
|
||
],
|
||
"id": "e4f13906-99e7-4f1b-bad1-a5a885a9fb3f",
|
||
"name": "Preparar respuesta email con artefacto"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x=$node[\"Interpretar Gemini\"].json;\nreturn [{json:{ok:true,...x.agent,artifactUrl:null,artifactType:null,artifactId:null,quoteId:null,title:null}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5968,
|
||
23552
|
||
],
|
||
"id": "779035df-c7db-4f6f-ab15-f555ca429a9c",
|
||
"name": "Preparar respuesta email sin artefacto"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ $json }}",
|
||
"options": {
|
||
"responseCode": 200
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-5520,
|
||
23424
|
||
],
|
||
"id": "bb243240-7d68-440d-a162-689e33320b98",
|
||
"name": "Responder email agente"
|
||
}
|
||
],
|
||
"connections": {
|
||
"Webhook - Chat": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Configuración - sin ENV",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar solicitud": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - validar JWT",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - validar JWT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Validar identidad",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Validar identidad": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "JWT válido?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"JWT válido?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - consultar acceso",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Responder 401",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - consultar acceso": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Validar acceso",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Validar acceso": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Acceso permitido?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Acceso permitido?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene audio?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Responder 403",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Contexto Supabase": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Acciones pendientes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Historial Supabase": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Resolver contexto conversacional",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Acciones pendientes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Empaquetar contexto chat",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Construir Gemini": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Basic LLM Chain",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Interpretar Gemini": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "¿Salida email?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Procesar acción sensible": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Generar artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Generar artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Respuesta final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Respuesta final": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Registrar ejecución",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Registrar ejecución": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Responder al chat",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Configuración - sin ENV": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar solicitud",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Basic LLM Chain": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Interpretar Gemini",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini 3.7 Flash - Chat Model": {
|
||
"ai_languageModel": [
|
||
[
|
||
{
|
||
"node": "Gemini - Basic LLM Chain",
|
||
"type": "ai_languageModel",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Resolver contexto conversacional": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Contexto Supabase",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene audio?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Transcribir audio",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar entrada final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Transcribir audio": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar entrada final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar entrada final": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene imágenes?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene imágenes?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Analizar imágenes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar análisis imágenes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Analizar imágenes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar análisis imágenes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar análisis imágenes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene documentos PDF?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene documentos PDF?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar PDFs para descarga",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar análisis documentos",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Analizar documentos": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar análisis documentos",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar análisis documentos": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene Excel XLSX?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini 2.5 Pro - Fallback": {
|
||
"ai_languageModel": [
|
||
[
|
||
{
|
||
"node": "Gemini - Basic LLM Chain",
|
||
"type": "ai_languageModel",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar PDFs para descarga": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Descargar PDFs",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Descargar PDFs": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Analizar documentos",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene Excel XLSX?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar XLSX para descarga",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar análisis XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar XLSX para descarga": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Descargar XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Descargar XLSX": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Reasociar XLSX descargado",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar análisis XLSX": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "¿Canal email?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Reasociar XLSX descargado": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Subir XLSX temporal a Drive",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Subir XLSX temporal a Drive": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Mapear XLSX temporal",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Mapear XLSX temporal": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Convertir XLSX a Google Sheets",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Convertir XLSX a Google Sheets": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Mapear Google Sheet temporal",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Mapear Google Sheet temporal": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Leer todas las hojas XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Leer todas las hojas XLSX": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar análisis XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Preparar limpieza XLSX temporal",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar limpieza XLSX temporal": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Eliminar temporales XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Webhook - Email Agent": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar email interno",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar email interno": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Email interno autorizado?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Email interno autorizado?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene audio?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Responder 401 Email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"¿Canal email?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Empaquetar contexto email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Historial Supabase",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Empaquetar contexto chat": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Construir Gemini",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Empaquetar contexto email": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Construir Gemini",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"¿Salida email?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "¿Email requiere artefacto?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Procesar acción sensible",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"¿Email requiere artefacto?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar artefacto email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar respuesta email sin artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar artefacto email": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Generar artefacto email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Generar artefacto email": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar respuesta email con artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar respuesta email con artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Responder email agente",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar respuesta email sin artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Responder email agente",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"settings": {
|
||
"executionOrder": "v1",
|
||
"binaryMode": "separate"
|
||
},
|
||
"staticData": null,
|
||
"meta": null,
|
||
"versionId": "f870cadc-50ae-4e5e-83dd-2df02594dc9b",
|
||
"activeVersionId": "f870cadc-50ae-4e5e-83dd-2df02594dc9b",
|
||
"versionCounter": 87,
|
||
"triggerCount": 2,
|
||
"shared": [
|
||
{
|
||
"updatedAt": "2026-08-22T14:59:56.571Z",
|
||
"createdAt": "2026-08-22T14:59:56.571Z",
|
||
"role": "workflow:owner",
|
||
"workflowId": "rtnO23gHlJfj45pA",
|
||
"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-08-29T16:12:57.000Z",
|
||
"createdAt": "2026-08-29T16:12:56.286Z",
|
||
"versionId": "f870cadc-50ae-4e5e-83dd-2df02594dc9b",
|
||
"workflowId": "rtnO23gHlJfj45pA",
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"httpMethod": "POST",
|
||
"path": "cotizador-wmc-chat",
|
||
"responseMode": "responseNode",
|
||
"options": {
|
||
"allowedOrigins": "*"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.webhook",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
-9408,
|
||
24784
|
||
],
|
||
"id": "6b03c1cb-2ea2-4014-bc61-aac8913b04a3",
|
||
"name": "Webhook - Chat",
|
||
"webhookId": "23c9a253-ba3d-47f0-85ca-21b3fde221c7"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const body = $json.body ?? {};\nconst headers = $json.headers ?? {};\nconst clean = (v, max = 10000) => typeof v === 'string' ? v.trim().slice(0, max) : '';\nconst auth = clean(headers.authorization || headers.Authorization, 12000);\nconst text = clean(body.text, 12000);\nconst conversationId = clean(body.conversationId, 200);\nconst messageId = clean(body.messageId, 200);\nconst userEmail = clean(body.userEmail, 320).toLowerCase();\nconst userName = clean(body.userName, 200);\nconst appSessionId = clean(body.appSessionId, 250) || `backend-${Date.now()}`;\nconst sentAt = clean(body.sentAt, 100) || new Date().toISOString();\nconst attachments = Array.isArray(body.attachments) ? body.attachments.slice(0, 8).map((a) => ({\n filename: clean(a?.filename, 180), mediaType: clean(a?.mediaType, 120), url: clean(a?.url, 6000), storagePath: clean(a?.storagePath, 1000) || null,\n})) : [];\nif (!auth.startsWith('Bearer ')) throw new Error('La sesión no incluye una credencial válida. Vuelve a iniciar sesión.');\nif (!conversationId || !messageId) throw new Error('Faltan conversationId/messageId.');\nif (!text && attachments.length === 0) throw new Error('La solicitud no contiene texto ni archivos.');\nconst countryMap = [\n ['GT','guatemala'],['CR','costa rica'],['SV','el salvador'],['SV','salvador'],['HN','honduras'],['NI','nicaragua']\n];\nconst low = text.toLowerCase();\nlet countryCode = null;\nfor (const [code, term] of countryMap) if (low.includes(term)) { countryCode = code; break; }\nreturn [{json:{auth,text,conversationId,messageId,userEmail,userName,appSessionId,sentAt,attachments,countryCode,startedAt:Date.now()}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8960,
|
||
24784
|
||
],
|
||
"id": "607da6ea-20d1-4de1-a554-bf9a0bc49f1a",
|
||
"name": "Normalizar solicitud"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://dbit.digitalcompass.agency/auth/v1/user",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-8480,
|
||
24784
|
||
],
|
||
"id": "6e2f9429-9948-454e-8346-aeca1d379d38",
|
||
"name": "Supabase - validar JWT",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Normalizar solicitud\"].json;\nconst authUser = $json ?? {};\nconst email = String(authUser.email ?? '').trim().toLowerCase();\nconst id = String(authUser.id ?? '').trim();\nconst valid = Boolean(id && email && email.endsWith('@gomezleemarketing.com') && (!req.userEmail || req.userEmail === email));\nreturn [{json:{...req, userId:id, verifiedEmail:email, jwtValid:valid, jwtError: valid ? null : 'Sesión inválida o correo no coincide.'}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8096,
|
||
24784
|
||
],
|
||
"id": "7bacd862-184e-4fac-a48c-8f89d546b0fb",
|
||
"name": "Validar identidad"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "0bfb8fea-20b0-4cf3-8e89-9160c914cc8e",
|
||
"leftValue": "={{ $json.jwtValid }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-7872,
|
||
24784
|
||
],
|
||
"id": "5256df52-35b7-4eda-9c11-088d42a45912",
|
||
"name": "JWT válido?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ { ok:false, reply:'Tu sesión no es válida. Vuelve a iniciar sesión.' } }}",
|
||
"options": {
|
||
"responseCode": 401
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-7648,
|
||
25024
|
||
],
|
||
"id": "c1c0385b-d949-41a7-a765-2c98adead24b",
|
||
"name": "Responder 401"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cotizador_wmc_access?select=email,full_name,is_active,can_use_tarifario&email=eq.' + encodeURIComponent($json.verifiedEmail) + '&is_active=eq.true' + '&limit=1' }}",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-7648,
|
||
24704
|
||
],
|
||
"id": "4056c80b-7554-4743-a285-e764849aecd6",
|
||
"name": "Supabase - consultar acceso",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Validar identidad\"].json;\nconst raw = $json ?? {};\n\n// n8n puede entregar la respuesta de PostgREST de varias formas:\n// 1) una fila directamente como $json (caso habitual cuando la respuesta HTTP es un array de 1 fila),\n// 2) un array,\n// 3) { data: [...] }, o\n// 4) { body: [...] } según la configuración/versión del nodo HTTP Request.\nlet row = null;\nif (Array.isArray(raw)) {\n row = raw[0] ?? null;\n} else if (Array.isArray(raw?.data)) {\n row = raw.data[0] ?? null;\n} else if (Array.isArray(raw?.body)) {\n row = raw.body[0] ?? null;\n} else if (raw?.body && typeof raw.body === 'object' && !Array.isArray(raw.body)) {\n row = raw.body;\n} else if (raw && typeof raw === 'object' && ('email' in raw || 'is_active' in raw || 'full_name' in raw)) {\n row = raw;\n}\n\nconst normalizeEmail = (value) => String(value ?? '').trim().toLowerCase();\nconst rowEmail = normalizeEmail(row?.email);\nconst active = row?.is_active === true || String(row?.is_active ?? '').toLowerCase() === 'true';\nconst accessAllowed = Boolean(row && active && rowEmail === req.verifiedEmail);\n\nreturn [{\n json: {\n ...req,\n accessAllowed,\n fullName: row?.full_name || req.userName || req.verifiedEmail,\n canUseTarifario: accessAllowed && (row?.can_use_tarifario === true || String(row?.can_use_tarifario ?? '').toLowerCase() === 'true'),\n accessLookupOk: Boolean(row),\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7440,
|
||
24704
|
||
],
|
||
"id": "488b5673-80a2-4eab-a469-eeb633060e5e",
|
||
"name": "Validar acceso"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b01cd05b-234e-4d8d-a2f0-3f9b83cb857e",
|
||
"leftValue": "={{ $json.accessAllowed }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-7216,
|
||
24704
|
||
],
|
||
"id": "39108519-535b-4c70-a61d-460cbd312436",
|
||
"name": "Acceso permitido?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ { ok:false, reply:'No tienes acceso activo al Cotizador Walmart Connect.' } }}",
|
||
"options": {
|
||
"responseCode": 403
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-6992,
|
||
24992
|
||
],
|
||
"id": "0a9392e9-d799-412b-95b6-bb4ce39168cb",
|
||
"name": "Responder 403"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_get_my_agent_context",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $node[\"Validar acceso\"].json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_query:$node[\"Resolver contexto conversacional\"].json.searchQuery || $node[\"Validar acceso\"].json.text,p_country_code:$node[\"Resolver contexto conversacional\"].json.effectiveCountryCode,p_limit:20}) }}",
|
||
"options": {
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-6992,
|
||
24592
|
||
],
|
||
"id": "d73a4ee1-0ad9-4127-956c-35ce97ef168a",
|
||
"name": "Contexto Supabase"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cotizador_wmc_messages?select=role,content,attachments,created_at&conversation_id=eq.' + encodeURIComponent($node[\"Validar acceso\"].json.conversationId) + '&order=created_at.asc&limit=40' }}",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $node[\"Validar acceso\"].json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-6768,
|
||
24592
|
||
],
|
||
"id": "bc2919b2-03cb-4b92-84fe-7a54b541bf20",
|
||
"name": "Historial Supabase",
|
||
"alwaysOutputData": true,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_get_my_pending_actions",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $node[\"Validar acceso\"].json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_limit:5}) }}",
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-6544,
|
||
24592
|
||
],
|
||
"id": "e937115d-9351-4188-ab54-ac110bcf143b",
|
||
"name": "Acciones pendientes",
|
||
"alwaysOutputData": true,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $json;\nconst context = req.agentContext && typeof req.agentContext === 'object' ? req.agentContext : {};\nconst history = Array.isArray(req.conversationHistory) ? req.conversationHistory.slice(-40) : [];\nconst pending = Array.isArray(req.pendingActions) ? req.pendingActions : [];\nconst isEmail = String(req.channel || 'chat') === 'email';\n\nconst system = `Eres el agente interno Cotizador Walmart Connect de GomezLee Marketing (GLM).\nTrabajas EXCLUSIVAMENTE con cotizaciones, presupuestos, tarifario, propuestas, ejecuciones y archivos relacionados con Walmart Connect/GLM. Si te piden algo fuera de este alcance, explica brevemente que no corresponde al Cotizador WMC.\n\nFUENTES Y REGLAS OBLIGATORIAS:\n1) El TARIFARIO OFICIAL incluido en CONTEXTO es la fuente oficial vigente. Nunca inventes una tarifa oficial.\n2) Los HISTÓRICOS son referencia para elementos específicos que no existan en el tarifario. Si debes estimar, usa referencias comparables de la forma más realista posible y marca isEstimated=true, estimationReason y confidence 0..1. Nunca presentes una estimación como tarifa oficial.\n3) Las cotizaciones Walmart Connect salen en USD y también incluyen moneda local usando el tipo de cambio entregado por CONTEXTO.\n4) Conserva la estructura funcional oficial WMC: Personal, Mobiliario - Equipo, Insumos de degustación y/o preparación, Logística e Instalación, Fee de agencia, total, condiciones comerciales, vigencia, observaciones/exclusiones y referencias visuales.\n5) Personal: total = unidades × días × costo unitario. Verifica TODA la matemática, subtotales, total USD y total local antes de responder.\n6) Respeta el país. No traslades precios de un país a otro salvo que el CONTEXTO lo permita. NO uses precios hardcodeados de ejemplos anteriores: para cualquier tarifa oficial vigente, CONTEXTO.tariff es la única fuente de verdad.\n7) RTM/RTM+ suele implicar arcos, backpanels, mesas degustadoras, alfombras y montaje; cotizaciones de islas suelen concentrarse en impulso/personal, mobiliario y rebranding. No agregues partidas no solicitadas sin aclararlo.\n8) En CAM/multipaís, genera la combinación país × formato requerida; por ejemplo, 5 países × 3 formatos puede implicar 15 cotizaciones/hojas.\n9) Cuando Walmart haya dado el OK y pidan mensualización, usa action=generate_monthly_glm.\n10) Cambios de tarifario: JAMÁS los ejecutes silenciosamente. Primera solicitud -> tariff_change_proposal. Solo después de confirmación explícita del mismo usuario -> confirm_tariff_change con pendingActionId. Si el usuario RECHAZA, CANCELA o indica explícitamente que NO desea aplicar una propuesta pendiente, usa action=cancel_tariff_change con el pendingActionId exacto de ACCIONES_PENDIENTES. Una acción cancelada deja de ser confirmable y nunca debes responder que fue cancelada usando action=chat.\n11) Si permissions.canUseTarifario=false, NO propongas ni confirmes modificaciones del tarifario; explica que el usuario no tiene ese permiso.\n12) Si faltan datos esenciales para un archivo confiable, pregunta exactamente lo que falta y usa action=chat. ANTES de action=generate_quote debes tener confirmados por el usuario los datos que determinan el precio: país, formato, duración, cantidad y tipo/categoría del personal, y ubicación/cobertura cuando la tarifa dependa de Capital/Interior/mixta.\n13) PROHIBIDO asumir partidas no solicitadas: no agregues supervisor, uniformes, logística, mobiliario, insumos, rebranding ni ningún otro rubro solo porque aparezca en históricos o sea frecuente. Solo inclúyelos si el usuario los pidió explícitamente o los confirmó después de que tú preguntaras. Si el usuario dice simplemente \"impulsadoras\" y existen categorías A/AA/AAA o diferencias por ubicación, pregunta la categoría/ubicación antes de generar el archivo.\n14) El Fee de agencia sí forma parte estructural del machote oficial. Usa la tasa oficial que venga en CONTEXTO; si no existe una tasa explícita, usa 6% como valor del machote. totalUsd y totalLocal SIEMPRE deben incluir el fee. Fórmula obligatoria: subtotalUsd = suma de partidas que no sean Fee; feeUsd = subtotalUsd × feeRate; totalUsd = subtotalUsd + feeUsd; totalLocal = totalUsd × exchangeRate.\n15) Nunca completes silenciosamente Capital, Interior o mixta a partir del país. Si ese dato cambia la tarifa y no fue dicho, pregunta.\n16) ADJUNTOS MULTIMODALES: los metadatos/URLs por sí solos NO prueban el contenido de un archivo. Si AUDIO_TRANSCRIPTION tiene contenido, úsalo como la transcripción real de la nota de voz. Si IMAGE_ANALYSIS tiene contenido, úsalo como el análisis real de las imágenes adjuntas. Si DOCUMENT_ANALYSIS tiene contenido, úsalo como el análisis real de los PDF adjuntos. Si SPREADSHEET_ANALYSIS tiene contenido, úsalo como contenido REAL extraído del Excel XLSX adjunto y NO respondas que no puedes leer archivos .xlsx. SPREADSHEET_ANALYSIS_SCOPE indica los límites aplicados y SPREADSHEET_SHEET_NAMES enumera las hojas detectadas. La extracción intenta leer TODAS las hojas con contenido legible del libro, no únicamente A1 ni una pestaña específica. Nunca inventes información que no aparezca en esos análisis. Si el indicador *_ERROR correspondiente es true y no hay análisis útil, pide únicamente reintentar ese archivo o compartir el dato faltante. Los formatos de oficina que sigan apareciendo en UNPARSED_DOCUMENT_ATTACHMENTS no han sido leídos y no debes afirmar lo contrario.\n17) Para referencias visuales o artes, genera prompts específicos, realistas y coherentes con el mobiliario/activación solicitada.\n18) Para propuestas en Google Slides, mantén estilo Walmart Connect: limpio, corporativo, azul marino profundo, títulos simples y visuales grandes. PROHIBIDO inventar nombres de concepto, slogans, claims, frases de campaña, nombres creativos, promociones, atributos de producto, promesas de resultados o especificaciones no entregadas por el usuario o por el contexto confirmado. Si el usuario no proporcionó un concepto o slogan, usa redacción descriptiva y factual (por ejemplo, \"Activación de impulso en punto de venta\") sin crear uno. En los visuales respeta exactamente la cantidad y el tipo/categoría de personal indicados en quote.items; no sustituyas impulsadoras/supervisores por personal genérico ni agregues personal de marca no cotizado.\n19) Cuando el usuario pida una imagen directamente, usa action=generate_image.\n20) MEMORIA CONVERSACIONAL OBLIGATORIA: HISTORIAL contiene los mensajes previos de ESTE MISMO chat y CONVERSATION_STATE resume hechos recuperados. Trata como confirmados los datos que el usuario ya indicó anteriormente (país, proyecto, anunciante, formato, cantidades, duración, ubicación, categoría, partidas solicitadas), salvo que el usuario los corrija. NO vuelvas a preguntar un dato ya presente en el historial. El mensaje actual puede ser una continuación breve del anterior. Si countryHint/effectiveCountryCode ya existe, no preguntes nuevamente el país.\n21) Si hay conflicto entre un dato anterior y uno nuevo, prevalece la corrección más reciente del usuario.\n22) MENSUALIZACIÓN GLM: cuando Walmart ya dio el OK, NO recotices ni sustituyas la cotización aprobada con tarifas nuevas. Usa action=generate_monthly_glm y conserva como base los conceptos, cantidades, días, moneda, país, tipo de cambio y total WMC aprobado de la cotización generada previamente en ESTA conversación. El flujo de Artefactos recuperará internamente la cotización fuente y la enlazará por sourceQuoteId. El machote mensual GLM aplica IVA 12% sobre la base aprobada WMC; distingue claramente en la respuesta el Total aprobado WMC del Total mensual GLM con IVA.\n23) En generate_monthly_glm, artifact.metadata debe incluir cuando sea posible countryCode, country, currencyCode, exchangeRate, approvedWmcTotalUsd, approvedWmcTotalLocal y glmIvaRate:0.12; no inventes estos valores si no están en historial/contexto.\n24) LENGUAJE PARA USUARIO FINAL: jamás menciones Supabase, base de datos, PostgreSQL, RPC, PostgREST, tablas, funciones internas, pendingActionId, UUID ni detalles de infraestructura en reply. El usuario solo debe leer lenguaje funcional como \"tarifario oficial\", \"propuesta de cambio\", \"cambio registrado\", \"cambio cancelado\" o \"no hay una propuesta pendiente válida\".\n25) En propuestas de cambio de tarifario, termina con una pregunta natural como \"¿Confirmas esta actualización?\", \"¿Confirmas que agregue esta tarifa?\" o \"¿Confirmas la desactivación de esta tarifa?\", sin mencionar dónde se almacena.\n26) En una confirmación exitosa, indica que el cambio \"quedó registrado en el tarifario oficial\"; no describas la tecnología utilizada.\n27) Si una confirmación no puede ejecutarse porque la propuesta ya fue aplicada, cancelada, venció o pertenece a otro chat, NO afirmes primero que el cambio fue realizado. Explica únicamente que esa propuesta ya no está pendiente en este chat y que debe generarse una nueva propuesta si desea continuar.\n28) CLASIFICACIÓN DE COBERTURA: una ciudad, municipio, tienda o dirección NO equivale automáticamente a Capital, Interior o mixta. Solo usa una clasificación tarifaria Capital/Interior/mixta cuando aparezca explícitamente en la solicitud/adjunto o el usuario la confirme. Si la tarifa cambia por esa clasificación y no está explícita, usa action=chat y pregunta únicamente esa clasificación antes de generar.\n29) FIDELIDAD DE PARTIDAS DESDE ADJUNTOS: cuando PDF/XLSX/imagen/audio sea la fuente operativa, conserva en quote.items[].description el concepto solicitado por esa fuente. No renombres, amplíes ni sustituyas silenciosamente la partida para hacerla coincidir con una tarifa. Cambios como agregar \"Panel\", \"Envío\", \"Camión\", \"Montaje\", \"Capital\", \"Interior\" u otro alcance no presente requieren confirmación explícita.\n30) MATCH DE TARIFARIO CON NOMBRE DIFERENTE: si la tarifa oficial candidata tiene una descripción o alcance más específico/diferente al concepto del adjunto, NO generes todavía. Explica brevemente la diferencia en lenguaje funcional y pregunta si el usuario confirma usar esa partida exacta del tarifario oficial.\n31) CONFIRMACIONES PARCIALES: que el usuario resuelva una ambigüedad (por ejemplo 5 vs 6 días) NO autoriza a resolver silenciosamente otras variables que todavía afecten el precio. Antes de generate_quote/generate_proposal deben estar confirmadas TODAS las variables de precio aplicables.\n32) TRAZABILIDAD DE PARTIDAS: cuando una partida provenga de un adjunto y action=generate_quote o generate_proposal, incluye cuando sea posible en metadata sourceDescription (texto/concepto original de la fuente) y tariffDescription (nombre exacto de la tarifa aplicada). La descripción visible debe conservar sourceDescription; tariffDescription solo documenta el match.\n33) 28) PROCEDENCIA DE PRECIOS OBLIGATORIA: para CADA item con monto, incluye metadata.priceSourceCode, metadata.priceSourceType y metadata.priceSourceRef. Usa T=tarifario oficial SOLO si la tarifa se encuentra realmente en CONTEXTO.tariff; usa H=histórico SOLO si tomas como referencia una cotización/item previo e incluye sourceQuoteId/referenceQuoteId; usa W SOLO si existe una URL web verificable realmente proporcionada en el contexto/solicitud e incluye sourceUrl; usa E cuando debas estimar con Gemini por no existir tarifa ni histórico suficiente. JAMÁS inventes una URL o marques T sin evidencia del tarifario. Prioridad obligatoria: T -> H -> E. W únicamente cuando haya una fuente web real disponible.\n29) CANAL EMAIL: si channel=email, NO sustituyes el chat: eres la misma lógica del Cotizador aplicada al correo. Si faltan datos que afectan el precio, action=chat y pregunta únicamente lo necesario para continuar. Conserva la memoria del hilo recibida en HISTORIAL.\n30) FILTRO TEMÁTICO EMAIL: si channel=email y el contenido NO corresponde a Walmart Connect/cotizaciones/presupuestos/propuestas/activaciones/ajustes o seguimiento de una solicitud del Cotizador, devuelve emailDisposition=\"ignore\", action=\"chat\", reply=\"\" y no generes ningún artefacto. Si sí corresponde, emailDisposition=\"process\".\n31) SEGURIDAD EMAIL: por email nunca propongas, confirmes ni canceles cambios del tarifario aunque el remitente lo pida; solo consulta el tarifario y referencias. Cualquier item fuera de tarifario debe mantener su H/W/E para que la capa de correo solicite aprobación interna antes de enviar.\n32) REVISION INTERNA: si approvalRevision existe, las instrucciones del aprobador son una revisión interna autorizada del candidato actual. Aplica únicamente los cambios solicitados, conserva el resto y vuelve a validar toda la matemática y las fuentes. Si falta un dato para ejecutar el cambio, action=chat y pregunta al aprobador; no inventes.\n33) Devuelve SOLO JSON válido, sin markdown ni texto fuera del JSON.\n\nESQUEMA DE RESPUESTA:\n{\n \"reply\":\"respuesta para el usuario\",\n \"emailDisposition\":\"process|ignore\",\n \"action\":\"chat|generate_quote|generate_proposal|generate_image|generate_monthly_glm|tariff_change_proposal|confirm_tariff_change|cancel_tariff_change\",\n \"requiresConfirmation\":false,\n \"pendingAction\":null,\n \"pendingActionId\":null,\n \"quote\":null,\n \"artifact\":null,\n \"warnings\":[],\n \"confidence\":0.0\n}\n\nCuando action=generate_quote, quote debe incluir:\ncountryCode,country,brandSolution,project,advertiser,format,exchangeRate,currencyCode,totalUsd,totalLocal,status,items[],conditions,validity,observations,visualReferences.\nCada item: section,description,units,days,unitCostUsd,totalUsd,unitCostLocal,totalLocal,isEstimated,confidence,estimationReason,metadata.\nNo incluyas el Fee como una partida inventada para cuadrar números: reporta agencyFeeRate (decimal, por ejemplo 0.06), subtotalUsd, agencyFeeUsd, agencyFeeLocal, totalUsd y totalLocal; los totales deben cuadrar matemáticamente.\nvisualReferences puede contener URLs existentes o objetos {title,imagePrompt,imageUrl}. Si el usuario pide una referencia visual nueva para la cotización, incluye un objeto con imagePrompt; el workflow generará esa imagen con el nodo nativo de Gemini y la insertará en Referencias visuales del Google Sheet.\n\nCuando action=generate_proposal:\nquote NO puede ser null. Debe incluir la MISMA estructura operativa y financiera completa indicada para generate_quote, aunque la acción siga siendo generate_proposal y NO se cree un Google Sheet de cotización. Incluye countryCode,country,brandSolution,project,advertiser,format,exchangeRate,currencyCode,status,items[],conditions,validity,observations,visualReferences,agencyFeeRate,subtotalUsd,subtotalLocal,agencyFeeUsd,agencyFeeLocal,totalUsd,totalLocal. Cada item debe conservar section,description,units,days,unitCostUsd,totalUsd,unitCostLocal,totalLocal,isEstimated,confidence,estimationReason,metadata. Usa exclusivamente el TARIFARIO OFICIAL de CONTEXTO, respeta las partidas solicitadas y verifica unidades × días × costo unitario, subtotal, Fee, total USD y total local antes de responder. Este quote estructurado alimenta el alcance y la inversión de la presentación; no lo sustituyas por cifras escritas únicamente dentro del texto de las diapositivas.\nartifact={title,subtitle,slides:[{type:\"cover|section|visual|content|thankyou\",title,subtitle,body,imagePrompt,imageUrl}]}\nEn title/subtitle/body de las diapositivas NO inventes slogans, nombres de concepto, claims ni frases de campaña: solo usa redacción descriptiva sustentada en la solicitud, HISTORIAL, CONTEXTO y quote. Cada imagePrompt debe reflejar literalmente el personal cotizado en quote.items (cantidad y descripción/categoría) y no debe pedir personal de marca adicional ni reemplazarlo por perfiles genéricos.\n\nCuando action=generate_image:\nartifact={title,imagePrompt,aspectRatio}\n\nCuando action=generate_monthly_glm:\nartifact={title,sourceQuoteId,monthlyLines,metadata:{countryCode,country,currencyCode,exchangeRate,approvedWmcTotalUsd,approvedWmcTotalLocal,glmIvaRate}}\n\nCuando action=tariff_change_proposal:\npendingAction={operation:\"insert|update|deactivate\",tariffItemId,item,reason}.\n\nCuando action=confirm_tariff_change:\npendingActionId debe ser el id exacto de la acción pendiente que el usuario está confirmando.\n\nCuando action=cancel_tariff_change:\npendingActionId debe ser el id exacto de ACCIONES_PENDIENTES correspondiente a la propuesta que el usuario está rechazando/cancelando; requiresConfirmation=false y pendingAction=null. Si no existe una acción pendiente válida en ESTA conversación, usa action=chat e indica que no hay nada pendiente que cancelar.`;\n\nconst attachments = (req.attachments || []).map(a => ({\n filename: a.filename || '',\n mediaType: a.mediaType || '',\n url: a.url || '',\n storagePath: a.storagePath || null\n}));\n\nconst payload = {\n channel: req.channel || 'chat',\n currentRequest: req.text,\n countryHint: req.effectiveCountryCode || req.countryCode || null,\n CONVERSATION_STATE: {\n effectiveCountryCode: req.effectiveCountryCode || null,\n effectiveCountrySource: req.effectiveCountrySource || null\n },\n user: {name:req.fullName,email:req.verifiedEmail},\n permissions: {canUseTarifario:!isEmail && Boolean(req.canUseTarifario)},\n EMAIL_META: req.emailMeta || null,\n approvalRevision: req.approvalRevision || null,\n attachments,\n AUDIO_TRANSCRIPTION: req.audioTranscription || null,\n AUDIO_TRANSCRIPTION_ERROR: Boolean(req.audioTranscriptionError),\n IMAGE_ANALYSIS: req.imageAnalysis || null,\n IMAGE_ANALYSIS_ERROR: Boolean(req.imageAnalysisError),\n DOCUMENT_ANALYSIS: req.documentAnalysis || null,\n DOCUMENT_ANALYSIS_ERROR: Boolean(req.documentAnalysisError),\n SPREADSHEET_ANALYSIS: req.spreadsheetAnalysis || null,\n SPREADSHEET_ANALYSIS_ERROR: Boolean(req.spreadsheetAnalysisError),\n SPREADSHEET_ANALYSIS_SCOPE: req.spreadsheetAnalysisScope || null,\n SPREADSHEET_SHEET_NAMES: Array.isArray(req.spreadsheetSheetNames) ? req.spreadsheetSheetNames : [],\n SPREADSHEET_EXTRACTION_WARNINGS: Array.isArray(req.spreadsheetExtractionWarnings) ? req.spreadsheetExtractionWarnings : [],\n UNPARSED_DOCUMENT_ATTACHMENTS: Array.isArray(req.unparsedDocumentAttachments) ? req.unparsedDocumentAttachments : [],\n CONTEXTO: context,\n ACCIONES_PENDIENTES: pending,\n HISTORIAL: history.map(h => ({\n role:h.role,\n content:h.content,\n attachments:h.attachments || [],\n createdAt:h.created_at\n }))\n};\n\nconst attachmentLinks = attachments\n .filter(a => a.url)\n .map((a,i) => `Adjunto ${i+1}: ${a.filename || 'archivo'} (${a.mediaType || 'tipo no indicado'})\\nURL: ${a.url}`)\n .join('\\n\\n');\n\nconst geminiPrompt = [\n 'SOLICITUD Y CONTEXTO EN JSON:',\n JSON.stringify(payload),\n attachmentLinks ? '\\nENLACES DE ADJUNTOS PARA URL CONTEXT:\\n' + attachmentLinks : ''\n].join('\\n\\n');\n\nreturn [{json:{...req,system,geminiPrompt,model:\"gemini-3.7-flash\",pendingActions:pending}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6320,
|
||
24592
|
||
],
|
||
"id": "d832c941-cdfa-46a9-8b9a-80aea6ebc0e7",
|
||
"name": "Construir Gemini"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Construir Gemini\"].json;\nconst raw = $json ?? {};\n\nlet parsed = null;\nlet text = '';\n\n// El nodo nativo de Gemini puede devolver JSON ya simplificado o contenido en parts.\nif (raw && typeof raw === 'object' && typeof raw.action === 'string') {\n parsed = raw;\n try { text = JSON.stringify(raw); } catch {}\n}\n\nif (!parsed) {\n if (typeof raw.mergedResponse === 'string') {\n text = raw.mergedResponse;\n } else if (raw.content?.parts && Array.isArray(raw.content.parts)) {\n text = raw.content.parts.map(p => p?.text || '').join('');\n } else if (raw.candidates?.[0]?.content?.parts && Array.isArray(raw.candidates[0].content.parts)) {\n text = raw.candidates[0].content.parts.map(p => p?.text || '').join('');\n } else if (typeof raw.text === 'string') {\n text = raw.text;\n } else if (typeof raw.output === 'string') {\n text = raw.output;\n } else if (typeof raw.response === 'string') {\n text = raw.response;\n } else if (raw.output && typeof raw.output.text === 'string') {\n text = raw.output.text;\n }\n\n try {\n const cleaned = String(text || '').trim()\n .replace(/^```(?:json)?\\s*/i,'')\n .replace(/\\s*```$/,'');\n parsed = JSON.parse(cleaned);\n } catch {\n parsed = {\n reply:'No pude interpretar de forma segura la respuesta del modelo. Intenta nuevamente.',\n action:'chat',\n warnings:['La respuesta de Gemini no fue JSON válido.'],\n confidence:0\n };\n }\n}\n\n// Cancelación determinista de cambios de tarifario.\n// No dependemos únicamente del modelo para invalidar una acción sensible.\nfunction normalizeIntent_(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst intentText_ = normalizeIntent_(req.text);\nconst saysDoNotCancel_ = /\\bno\\s+(?:quiero|deseo)\\s+cancel(?:ar|arlo|arla|en|acion)?\\b/.test(intentText_);\nconst explicitCancel_ = !saysDoNotCancel_ && (\n /\\bcancel(?:a|ar|alo|ala|en|acion)\\b/.test(intentText_) ||\n /\\brechaz(?:a|ar|o|amos|en)\\b/.test(intentText_) ||\n /\\bno\\s+(?:quiero|deseo)\\s+(?:aplicar|confirmar|hacer|modificar)\\b/.test(intentText_) ||\n /\\bno\\s+(?:apliques|confirmes|modifiques)\\b/.test(intentText_) ||\n /\\bdejal[oa]\\s+(?:igual|sin cambios)\\b/.test(intentText_) ||\n /\\bmanten(?:lo|la)?\\s+sin cambios\\b/.test(intentText_)\n);\n\nconst pendingActions_ = Array.isArray(req.pendingActions) ? req.pendingActions : [];\nconst sameConversationPending_ = pendingActions_\n .filter(p =>\n p &&\n String(p.actionType || p.action_type || '').toLowerCase() === 'tariff_change' &&\n String(p.status || 'pending').toLowerCase() === 'pending' &&\n String(p.conversationId || p.conversation_id || '') === String(req.conversationId || '')\n )\n .sort((a,b) => Date.parse(b.createdAt || b.created_at || 0) - Date.parse(a.createdAt || a.created_at || 0));\n\nconst requestedPendingId_ = String(parsed?.pendingActionId || '').trim();\nlet targetPending_ = requestedPendingId_\n ? sameConversationPending_.find(p => String(p.id || '') === requestedPendingId_)\n : null;\nif (!targetPending_) targetPending_ = sameConversationPending_[0] || null;\n\nif (explicitCancel_) {\n if (targetPending_) {\n parsed.action = 'cancel_tariff_change';\n parsed.pendingActionId = String(targetPending_.id);\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n if (!String(parsed.reply || '').trim() || !/\\bcancel/i.test(String(parsed.reply))) {\n parsed.reply = 'Entendido. Cancelaré la propuesta pendiente y no se aplicará el cambio.';\n }\n } else {\n parsed.action = 'chat';\n parsed.pendingActionId = null;\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n parsed.reply = 'No hay una modificación de tarifario pendiente válida en esta conversación para cancelar.';\n }\n} else if (String(parsed?.action || '') === 'cancel_tariff_change') {\n if (targetPending_) {\n parsed.pendingActionId = String(targetPending_.id);\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n } else {\n parsed.action = 'chat';\n parsed.pendingActionId = null;\n parsed.pendingAction = null;\n parsed.requiresConfirmation = false;\n parsed.reply = 'No hay una modificación de tarifario pendiente válida en esta conversación para cancelar.';\n }\n}\n\nconst allowed = new Set([\n 'chat','generate_quote','generate_proposal','generate_image',\n 'generate_monthly_glm','tariff_change_proposal','confirm_tariff_change','cancel_tariff_change'\n]);\nlet action = allowed.has(parsed?.action) ? parsed.action : 'chat';\n\n// Seguridad adicional: usuarios sin permiso de Tarifario no pueden pedir mutaciones.\nif (!req.canUseTarifario && ['tariff_change_proposal','confirm_tariff_change'].includes(action)) {\n action = 'chat';\n parsed.reply = 'No tienes habilitado el permiso para modificar el Tarifario.';\n parsed.pendingAction = null;\n parsed.pendingActionId = null;\n parsed.requiresConfirmation = false;\n}\n\n\n// Barrera determinista para cotizaciones desde adjuntos:\n// 1) no permite clasificar Capital/Interior/mixta sin confirmación explícita;\n// 2) no permite que una partida de logística gane alcance nuevo (Panel, Envío, Camión, etc.)\n// que no exista en la fuente operativa o en una confirmación del usuario.\n// Esto se ejecuta DESPUÉS de Gemini y ANTES de crear cualquier artefacto.\nfunction normSource_(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nfunction relevantUserText_() {\n const history = Array.isArray(req.conversationHistory) ? req.conversationHistory : [];\n let lastAttachmentIndex = -1;\n for (let i = history.length - 1; i >= 0; i--) {\n const h = history[i] || {};\n if (\n String(h.role || '').toLowerCase() === 'user' &&\n Array.isArray(h.attachments) &&\n h.attachments.length > 0\n ) {\n lastAttachmentIndex = i;\n break;\n }\n }\n const start = lastAttachmentIndex >= 0 ? lastAttachmentIndex : Math.max(0, history.length - 8);\n const userTurns = history\n .slice(start)\n .filter(h => String(h?.role || '').toLowerCase() === 'user')\n .map(h => String(h?.content || ''))\n .filter(Boolean);\n\n userTurns.push(String(req.text || ''));\n return userTurns.join('\\n');\n}\nfunction coverageFromAttachment_() {\n const attachmentText = normSource_([\n req.spreadsheetAnalysis,\n req.documentAnalysis,\n req.imageAnalysis,\n req.audioTranscription\n ].filter(Boolean).join('\\n'));\n const set = new Set();\n for (const term of ['capital','interior','mixta']) {\n if (new RegExp(`\\\\b${term}\\\\b`).test(attachmentText)) set.add(term);\n }\n return set;\n}\nfunction coverageFromUser_() {\n const raw = normSource_(relevantUserText_());\n const set = new Set();\n for (const term of ['capital','interior','mixta']) {\n const explicit = [\n new RegExp(`^(?:usa |usar |cotiza |cotizar |como |es |cobertura |ubicacion )*${term}$`),\n new RegExp(`\\\\b(?:usa|usar|cotiza|cotizar|cotizalo|cotizala|aplica|aplicar|como|cobertura|ubicacion|clasificacion)\\\\s+(?:de\\\\s+|como\\\\s+)?${term}\\\\b`),\n new RegExp(`\\\\b${term}\\\\s+(?:confirmado|confirmada|correcto|correcta)\\\\b`),\n new RegExp(`\\\\b(?:es|sera|seria)\\\\s+${term}\\\\b`)\n ].some(re => re.test(raw));\n if (explicit) set.add(term);\n }\n return set;\n}\nfunction pricingCoverageTerms_(quote) {\n const q = quote && typeof quote === 'object' ? quote : {};\n const items = Array.isArray(q.items) ? q.items : [];\n const parts = [\n q.coverage, q.coverageType, q.locationCategory, q.tariffCoverage,\n ...items.flatMap(it => [\n it?.description,\n it?.metadata?.tariffDescription,\n it?.metadata?.coverage,\n it?.metadata?.locationCategory\n ])\n ];\n const raw = normSource_(parts.filter(Boolean).join('\\n'));\n const set = new Set();\n for (const term of ['capital','interior','mixta']) {\n if (new RegExp(`\\\\b${term}\\\\b`).test(raw)) set.add(term);\n }\n return set;\n}\nfunction sourceEvidence_() {\n return normSource_([\n req.spreadsheetAnalysis,\n req.documentAnalysis,\n req.imageAnalysis,\n req.audioTranscription,\n relevantUserText_()\n ].filter(Boolean).join('\\n'));\n}\nfunction logisticsNovelQualifiers_(quote, sourceText) {\n const q = quote && typeof quote === 'object' ? quote : {};\n const items = Array.isArray(q.items) ? q.items : [];\n const risky = [\n 'envio','panel','backpanel','camion','vehiculo','pickup','van',\n 'montaje','desmontaje','rebranding','branding','impresion',\n 'capital','interior','mixta','nocturno','nocturna','refrigerado','refrigerada'\n ];\n const out = [];\n for (const it of items) {\n const section = normSource_(it?.section);\n const visibleDesc = String(it?.description || '').trim();\n const tariffDesc = String(it?.metadata?.tariffDescription || '').trim();\n const candidate = normSource_([visibleDesc, tariffDesc].filter(Boolean).join(' '));\n if (!/(logistica|instalacion|transporte|envio|traslado)/.test(`${section} ${candidate}`)) continue;\n const added = risky.filter(term =>\n new RegExp(`\\\\b${term}\\\\b`).test(candidate) &&\n !new RegExp(`\\\\b${term}\\\\b`).test(sourceText)\n );\n if (added.length) {\n out.push({\n description: tariffDesc || visibleDesc,\n added:[...new Set(added)]\n });\n }\n }\n return out;\n}\n\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote) {\n const attachmentCoverage_ = coverageFromAttachment_();\n const userCoverage_ = coverageFromUser_();\n const confirmedCoverage_ = new Set([...attachmentCoverage_, ...userCoverage_]);\n const quoteCoverage_ = pricingCoverageTerms_(parsed.quote);\n const unconfirmedCoverage_ = [...quoteCoverage_].filter(term => !confirmedCoverage_.has(term));\n\n if (unconfirmedCoverage_.length) {\n action = 'chat';\n parsed.action = 'chat';\n parsed.quote = null;\n parsed.artifact = null;\n parsed.requiresConfirmation = false;\n parsed.pendingAction = null;\n parsed.pendingActionId = null;\n parsed.warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];\n parsed.warnings.push('Se bloqueó la generación porque la clasificación Capital/Interior/mixta utilizada para precio no estaba confirmada por la fuente o el usuario.');\n parsed.reply = 'Antes de generar la cotización necesito confirmar únicamente la cobertura tarifaria. La ciudad, municipio o tienda indicada en el archivo no define por sí sola esa clasificación. ¿Debo cotizar esta activación como Capital, Interior o mixta?';\n } else {\n const sourceText_ = sourceEvidence_();\n const novelLogistics_ = logisticsNovelQualifiers_(parsed.quote, sourceText_);\n if (novelLogistics_.length) {\n const first_ = novelLogistics_[0];\n const addedHuman_ = first_.added.map(x => {\n const labels = {envio:'Envío',panel:'Panel',backpanel:'Backpanel',camion:'Camión',vehiculo:'Vehículo',pickup:'Pickup',van:'Van',montaje:'Montaje',desmontaje:'Desmontaje',rebranding:'Rebranding',branding:'Branding',impresion:'Impresión',capital:'Capital',interior:'Interior',mixta:'Mixta',nocturno:'Nocturno',nocturna:'Nocturna',refrigerado:'Refrigerado',refrigerada:'Refrigerada'};\n return labels[x] || x;\n });\n action = 'chat';\n parsed.action = 'chat';\n parsed.quote = null;\n parsed.artifact = null;\n parsed.requiresConfirmation = false;\n parsed.pendingAction = null;\n parsed.pendingActionId = null;\n parsed.warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];\n parsed.warnings.push('Se bloqueó una sustitución/ampliación de alcance en una partida de logística que no estaba confirmada en la fuente.');\n parsed.reply = `Antes de generar necesito confirmar la partida de logística. La partida seleccionada agrega detalles que no aparecen confirmados en el archivo (${addedHuman_.join(', ')}). Indícame la partida exacta que debo usar o confirma expresamente que use: \"${first_.description}\".`;\n }\n }\n}\n\n\n// Fidelidad determinista de etiquetas visibles.\n// Si Gemini entregó la trazabilidad sourceDescription, esa descripción original\n// prevalece en el archivo visible; el nombre de la tarifa queda solo en metadata.\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote && Array.isArray(parsed.quote.items)) {\n parsed.quote.items = parsed.quote.items.map(raw => {\n const it = raw && typeof raw === 'object' ? {...raw} : {};\n const sourceDescription = String(it?.metadata?.sourceDescription || '').trim();\n if (sourceDescription) it.description = sourceDescription;\n return it;\n });\n}\n\n// Procedencia de precios determinista.\n// No confiamos solo en la letra sugerida por Gemini: T exige evidencia real en el tarifario,\n// H exige referencia histórica, W exige URL verificable y todo lo demás cae en E.\nfunction sourceNorm_(v) {\n return String(v || '').toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/\\s+/g,' ').trim();\n}\nfunction validUrl_(v) { return /^https?:\\/\\/[^\\s]+$/i.test(String(v || '').trim()); }\nfunction tariffRows_() {\n const c = req.agentContext && typeof req.agentContext === 'object' ? req.agentContext : {};\n if (Array.isArray(c.tariff)) return c.tariff;\n if (Array.isArray(c?.context?.tariff)) return c.context.tariff;\n return [];\n}\nfunction tariffEvidence_(item, tariffs) {\n const it = item && typeof item === 'object' ? item : {};\n const md = it.metadata && typeof it.metadata === 'object' ? it.metadata : {};\n const id = String(it.tariffItemId || md.tariffItemId || '').trim();\n const code = sourceNorm_(md.itemCode || it.itemCode);\n const desc = sourceNorm_([it.description, md.tariffDescription].filter(Boolean).join(' '));\n const cost = Number(it.unitCostUsd ?? it.unit_cost_usd ?? 0);\n for (const t of tariffs) {\n if (!t || typeof t !== 'object') continue;\n if (id && String(t.id || '') === id) return t;\n if (code && sourceNorm_(t.itemCode) === code) return t;\n const tc = Number(t.baseCostUsd ?? t.base_cost_usd ?? NaN);\n const tn = sourceNorm_([t.name,t.detail].filter(Boolean).join(' '));\n if (Number.isFinite(cost) && Number.isFinite(tc) && Math.abs(cost-tc)<0.01 && tn && desc && (desc.includes(sourceNorm_(t.name)) || tn.includes(desc))) return t;\n }\n return null;\n}\nfunction normalizePriceSources_(quote) {\n if (!quote || typeof quote !== 'object' || !Array.isArray(quote.items)) return quote;\n const tariffs = tariffRows_();\n quote.items = quote.items.map(raw => {\n const it = raw && typeof raw === 'object' ? {...raw} : {};\n const md = it.metadata && typeof it.metadata === 'object' ? {...it.metadata} : {};\n const amount = Number(it.unitCostUsd ?? it.unit_cost_usd ?? 0) > 0 || Number(it.totalUsd ?? it.total_usd ?? 0) > 0;\n if (!amount) return {...it,metadata:md};\n const t = tariffEvidence_(it, tariffs);\n const histRef = String(md.sourceQuoteId || md.referenceQuoteId || md.historicalQuoteId || it.sourceQuoteId || '').trim();\n const sourceUrl = String(md.sourceUrl || '').trim();\n let code, type, ref;\n if (t) {\n code='T'; type='tariff'; ref=String(t.id || t.itemCode || 'TARIFARIO');\n if (!md.tariffItemId && t.id) md.tariffItemId=t.id;\n if (!md.itemCode && t.itemCode) md.itemCode=t.itemCode;\n if (!md.tariffDescription && (t.name || t.detail)) md.tariffDescription=[t.name,t.detail].filter(Boolean).join(' - ');\n } else if (histRef) {\n code='H'; type='historical'; ref=histRef;\n } else if (validUrl_(sourceUrl)) {\n code='W'; type='web'; ref=sourceUrl;\n } else {\n code='E'; type='estimate'; ref=String(md.estimationReference || 'Gemini estimate');\n it.isEstimated=true;\n if (!it.estimationReason) it.estimationReason='No se encontró una tarifa oficial ni una referencia histórica suficientemente identificada para esta partida.';\n }\n md.priceSourceCode=code; md.priceSourceType=type; md.priceSourceRef=ref;\n return {...it,priceSourceCode:code,priceSourceType:type,priceSourceRef:ref,metadata:md};\n });\n return quote;\n}\n\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote) {\n parsed.quote = normalizePriceSources_(parsed.quote);\n}\n\n// Recalculo financiero determinista: Gemini propone la estructura, pero n8n valida toda la matemática.\nfunction n_(value, fallback = 0) {\n const n = Number(value);\n return Number.isFinite(n) ? n : fallback;\n}\nfunction money_(value) {\n return Math.round((n_(value, 0) + Number.EPSILON) * 100) / 100;\n}\nfunction isFeeSection_(section) {\n return String(section || '').toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').includes('fee');\n}\nfunction normalizeQuoteFinancials_(input) {\n if (!input || typeof input !== 'object') return input ?? null;\n const q = JSON.parse(JSON.stringify(input));\n const rate = n_(q.exchangeRate ?? q.exchange_rate, 1) > 0 ? n_(q.exchangeRate ?? q.exchange_rate, 1) : 1;\n const originalItems = Array.isArray(q.items) ? q.items : [];\n\n let baseUsd = 0;\n let explicitFeeUsd = 0;\n q.items = originalItems.map((raw) => {\n const it = raw && typeof raw === 'object' ? { ...raw } : {};\n const units = n_(it.units ?? it.quantity, 0);\n const daysRaw = it.days;\n const days = (daysRaw === '' || daysRaw === null || daysRaw === undefined) ? 1 : n_(daysRaw, 1);\n const unitUsd = n_(it.unitCostUsd ?? it.unit_cost_usd, 0);\n const computedUsd = money_(units * days * unitUsd);\n const suppliedUsd = n_(it.totalUsd ?? it.total_usd, computedUsd);\n const totalUsd = unitUsd > 0 && units > 0 ? computedUsd : money_(suppliedUsd);\n const unitLocal = money_(unitUsd * rate);\n const totalLocal = money_(totalUsd * rate);\n\n if (isFeeSection_(it.section)) explicitFeeUsd += totalUsd;\n else baseUsd += totalUsd;\n\n return {\n ...it,\n units,\n days,\n unitCostUsd: money_(unitUsd),\n totalUsd,\n unitCostLocal: unitLocal,\n totalLocal,\n };\n });\n\n baseUsd = money_(baseUsd);\n let feeRate = n_(q.agencyFeeRate ?? q.feeRate, NaN);\n if (!Number.isFinite(feeRate) && explicitFeeUsd > 0 && baseUsd > 0) feeRate = explicitFeeUsd / baseUsd;\n if (!Number.isFinite(feeRate) || feeRate < 0) feeRate = 0.06;\n if (feeRate > 1) feeRate = feeRate / 100;\n\n const feeUsd = money_(baseUsd * feeRate);\n const totalUsd = money_(baseUsd + feeUsd);\n const feeLocal = money_(feeUsd * rate);\n const totalLocal = money_(totalUsd * rate);\n\n q.exchangeRate = rate;\n q.agencyFeeRate = feeRate;\n q.subtotalUsd = baseUsd;\n q.subtotalLocal = money_(baseUsd * rate);\n q.agencyFeeUsd = feeUsd;\n q.agencyFeeLocal = feeLocal;\n q.totalUsd = totalUsd;\n q.totalLocal = totalLocal;\n return q;\n}\n\nif (['generate_quote','generate_proposal'].includes(action) && parsed?.quote) {\n const previousTotal = Number(parsed.quote.totalUsd);\n parsed.quote = normalizeQuoteFinancials_(parsed.quote);\n if (Number.isFinite(previousTotal) && Math.abs(previousTotal - parsed.quote.totalUsd) > 0.01) {\n parsed.warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];\n parsed.warnings.push(`Total recalculado automáticamente para incluir el Fee de agencia: USD ${parsed.quote.totalUsd.toFixed(2)}.`);\n }\n}\n\nconst normalized = {\n emailDisposition: String(parsed?.emailDisposition || (String(req.channel||'chat')==='email' ? 'process' : 'process')).toLowerCase()==='ignore' ? 'ignore' : 'process',\n reply: String(parsed?.reply || '').trim() || 'Listo.',\n action,\n requiresConfirmation: Boolean(parsed?.requiresConfirmation || action === 'tariff_change_proposal'),\n pendingAction: parsed?.pendingAction ?? null,\n pendingActionId: parsed?.pendingActionId ?? null,\n quote: parsed?.quote ?? null,\n artifact: parsed?.artifact ?? null,\n warnings: Array.isArray(parsed?.warnings) ? parsed.warnings.map(String).slice(0,10) : [],\n confidence: Number.isFinite(Number(parsed?.confidence)) ? Math.max(0,Math.min(1,Number(parsed.confidence))) : null,\n model: 'gemini-3.7-flash'\n};\n\nreturn [{json:{...req,agent:normalized,rawModelText:text || JSON.stringify(parsed),durationMs:Date.now()-req.startedAt}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5456,
|
||
24560
|
||
],
|
||
"id": "ec6d4762-c0b0-46f8-820c-deeeaa5cdc93",
|
||
"name": "Interpretar Gemini"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_process_my_agent_action",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_action:$json.agent,p_conversation_id:$json.conversationId,p_display_name:$json.fullName,p_session_id:$json.appSessionId}) }}",
|
||
"options": {
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-5232,
|
||
24560
|
||
],
|
||
"id": "87c90208-7fa7-4d29-8542-42aed83b13e8",
|
||
"name": "Procesar acción sensible",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x = $node[\"Interpretar Gemini\"].json;\nreturn [{json:{\n action:x.agent.action,\n quote:x.agent.quote,\n artifact:x.agent.artifact,\n user:{id:x.userId,email:x.verifiedEmail,name:x.fullName},\n conversationId:x.conversationId,\n appSessionId:x.appSessionId,\n model:x.agent.model,\n auth:x.auth\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5008,
|
||
24560
|
||
],
|
||
"id": "e2208ad4-d173-411c-9af3-30ba23cae6b2",
|
||
"name": "Preparar artefacto"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://agenteit.digitalcompass.agency/webhook/cotizador-wmc-artifacts",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({action:$json.action,quote:$json.quote,artifact:$json.artifact,user:$json.user,conversationId:$json.conversationId,appSessionId:$json.appSessionId,model:$json.model}) }}",
|
||
"options": {
|
||
"timeout": 180000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-4800,
|
||
24560
|
||
],
|
||
"id": "6c71a752-b2bd-4794-8bfa-6e0d3e2c4f87",
|
||
"name": "Generar artefacto",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x = $node[\"Interpretar Gemini\"].json;\n\nlet actionResult = {};\nlet artifactResult = {};\ntry { actionResult = $node[\"Procesar acción sensible\"].json ?? {}; } catch {}\ntry { artifactResult = $node[\"Generar artefacto\"].json ?? {}; } catch {}\n\nconst out = {...x.agent};\n\nfunction cleanUserReply_(value) {\n let s = String(value || '').trim();\n\n // La infraestructura es interna: nunca debe aparecer en el chat del usuario.\n s = s\n .replace(/tarifario\\s+oficial\\s+de\\s+supabase/gi, 'tarifario oficial')\n .replace(/tarifario\\s+de\\s+supabase/gi, 'tarifario oficial')\n .replace(/en\\s+supabase/gi, 'en el sistema')\n .replace(/\\bsupabase\\b/gi, 'el sistema')\n .replace(/\\bpostgres(?:ql)?\\b/gi, 'el sistema')\n .replace(/\\bpostgrest\\b/gi, 'el sistema')\n .replace(/\\brpc\\b/gi, 'proceso interno')\n .replace(/\\bpendingActionId\\b/gi, 'identificador de la propuesta');\n\n // Correcciones cosméticas de reemplazos.\n s = s\n .replace(/\\bde\\s+el\\s+sistema\\b/gi, 'del sistema')\n .replace(/\\ben\\s+el\\s+sistema\\s+oficial\\b/gi, 'en el sistema')\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n{3,}/g, '\\n\\n');\n\n return s.trim();\n}\n\nfunction friendlyActionError_(error, action) {\n const raw = String(error || '').trim();\n const low = raw.toLowerCase();\n\n if (\n low.includes('pertenece a otra conversacion') ||\n low.includes('pertenece a otra conversación') ||\n low.includes('ya fue aplicada/cancelada/expirada') ||\n low.includes('ya fue aplicada o pertenece a otro usuario') ||\n low.includes('accion pendiente no existe') ||\n low.includes('acción pendiente no existe')\n ) {\n return 'No pude confirmar ese cambio porque esa propuesta ya no está pendiente en este chat. Si deseas continuar, solicita una nueva propuesta de cambio.';\n }\n\n if (low.includes('falta pendingactionid')) {\n return action === 'cancel_tariff_change'\n ? 'No encontré una propuesta pendiente válida para cancelar en este chat.'\n : 'No encontré una propuesta pendiente válida para confirmar en este chat. Solicita una nueva propuesta de cambio.';\n }\n\n if (low.includes('expir') || low.includes('vencio') || low.includes('venció')) {\n return 'Esa propuesta ya venció y no puede aplicarse. Solicita una nueva propuesta de cambio.';\n }\n\n if (low.includes('permiso') || low.includes('habilitado')) {\n return 'No tienes permiso para modificar el tarifario oficial.';\n }\n\n if (low.includes('no se encontró el item') || low.includes('no se encontro el item')) {\n return 'No pude completar el cambio porque la tarifa seleccionada ya no está disponible. Consulta el tarifario vigente e inténtalo nuevamente.';\n }\n\n if (low.includes('operación pendiente no soportada') || low.includes('operacion pendiente no soportada')) {\n return 'No pude procesar ese cambio de tarifario. Genera una nueva propuesta e inténtalo nuevamente.';\n }\n\n return 'No pude completar el cambio solicitado. No se realizó ninguna modificación. Inténtalo nuevamente o genera una nueva propuesta.';\n}\n\nif (actionResult.pendingActionId) {\n out.pendingActionId = actionResult.pendingActionId;\n}\n\nif (typeof actionResult.requiresConfirmation === 'boolean') {\n out.requiresConfirmation = actionResult.requiresConfirmation;\n}\n\n// La respuesta del backend es AUTORITATIVA para acciones sensibles.\n// Nunca anexamos un error a una frase optimista de Gemini.\nif (actionResult.ok === false) {\n const friendly = friendlyActionError_(actionResult.error, out.action);\n out.reply = friendly;\n out.requiresConfirmation = false;\n out.pendingAction = null;\n out.warnings = [friendly];\n}\n\n// Para cancelación confirmada, usar un mensaje inequívoco aunque Gemini haya\n// redactado otra cosa.\nif (actionResult.ok === true && out.action === 'cancel_tariff_change') {\n out.reply = 'Entendido. La propuesta de cambio fue cancelada y no se aplicará.';\n out.requiresConfirmation = false;\n}\n\n// En éxito de confirmación, mantener la respuesta del agente, pero limpiar\n// cualquier referencia a tecnología interna.\nif (actionResult.ok === true && out.action === 'confirm_tariff_change') {\n out.requiresConfirmation = false;\n}\n\n// Resultado de artefactos.\nif (artifactResult?.artifactUrl) {\n out.artifactUrl = artifactResult.artifactUrl;\n out.artifactType = artifactResult.artifactType || null;\n out.quoteId = artifactResult.quoteId || null;\n} else if (['generate_quote','generate_proposal','generate_image','generate_monthly_glm'].includes(out.action)) {\n const err = artifactResult?.error || artifactResult?.message || 'No se pudo generar el archivo solicitado.';\n const friendly = 'No pude generar el archivo solicitado en este momento. Inténtalo nuevamente.';\n out.warnings = [...(out.warnings || []), friendly];\n out.reply = `${cleanUserReply_(out.reply)}\\n\\n⚠️ ${friendly}`.trim();\n}\n\n// Última barrera de presentación: ninguna respuesta visible debe filtrar\n// detalles de infraestructura.\nout.reply = cleanUserReply_(out.reply);\n\nif (Array.isArray(out.warnings)) {\n out.warnings = out.warnings\n .map(cleanUserReply_)\n .filter(Boolean)\n .slice(0, 10);\n}\n\nreturn [{json:{...x, final:{ok:true,...out}}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4576,
|
||
24560
|
||
],
|
||
"id": "6e53f791-7153-4db2-85fa-155083fdfb2b",
|
||
"name": "Respuesta final"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cotizador_wmc_log_my_agent_run",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "apikey",
|
||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||
},
|
||
{
|
||
"name": "Authorization",
|
||
"value": "={{ $json.auth }}"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({p_conversation_id:$json.conversationId,p_prompt:$json.text,p_response:$json.final.reply,p_action:$json.final.action,p_model:$json.final.model,p_duration_ms:$json.durationMs,p_status:'ok',p_metadata:{warnings:$json.final.warnings||[],confidence:$json.final.confidence,hasArtifact:!!$json.final.artifactUrl}}) }}",
|
||
"options": {
|
||
"timeout": 20000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-4352,
|
||
24560
|
||
],
|
||
"id": "2f62e2ee-cb7d-4f43-b91e-ccbe7b3b78c7",
|
||
"name": "Registrar ejecución",
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ $node[\"Respuesta final\"].json.final }}",
|
||
"options": {
|
||
"responseCode": 200
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-4112,
|
||
24560
|
||
],
|
||
"id": "75acb076-3bf4-4c6a-8da0-a73c91339419",
|
||
"name": "Responder al chat"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// Configuración local del workflow: NO usa variables de entorno.\nreturn [{json:{\n ...$json,\n config:{\n supabaseUrl:\"https://dbit.digitalcompass.agency\",\n supabaseAnonKey:\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE\",\n artifactsWebhookUrl:\"https://agenteit.digitalcompass.agency/webhook/cotizador-wmc-artifacts\",\n internalEmailSecret:\"fLf-Fl4AcRL912NsWfC4wGwlfEZ9kvOXJ33ylUjXJ9prTuML4b2sSJt3tDrG5D08\"\n }\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-9184,
|
||
24784
|
||
],
|
||
"id": "8337225f-58c9-4613-9b26-fe31ddca04bd",
|
||
"name": "Configuración - sin ENV"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"promptType": "define",
|
||
"text": "={{ $json.system + '\\n\\n' + $json.geminiPrompt }}",
|
||
"needsFallback": true,
|
||
"batching": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||
"typeVersion": 1.7,
|
||
"position": [
|
||
-6096,
|
||
24592
|
||
],
|
||
"id": "5ddf147d-5303-4856-94c5-53c0754fb521",
|
||
"name": "Gemini - Basic LLM Chain",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 4000
|
||
},
|
||
{
|
||
"parameters": {
|
||
"modelName": "models/gemini-3.7-flash",
|
||
"options": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-6096,
|
||
24800
|
||
],
|
||
"id": "b8af0928-afac-46aa-a907-daceff0e4356",
|
||
"name": "Gemini 3.7 Flash - Chat Model",
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Preparar análisis XLSX\"].json;\n\n// El HTTP Request de n8n puede convertir una respuesta JSON de varias filas\n// en varios items. Por eso NO debemos leer el historial con $node[...].json\n// como si fuera un array: eso solo toma un item.\nconst inputItems = $input.all();\nconst history = [];\n\nfunction pushRow(row) {\n if (!row || typeof row !== 'object') return;\n const role = String(row.role || '').trim();\n const content = String(row.content || '').trim();\n if (!role && !content) return;\n history.push({\n role,\n content,\n attachments: Array.isArray(row.attachments) ? row.attachments : [],\n created_at: row.created_at || row.createdAt || null,\n });\n}\n\nfor (const item of inputItems) {\n const raw = item?.json ?? {};\n if (Array.isArray(raw)) {\n raw.forEach(pushRow);\n } else if (Array.isArray(raw?.data)) {\n raw.data.forEach(pushRow);\n } else if (Array.isArray(raw?.body)) {\n raw.body.forEach(pushRow);\n } else if (raw?.body && typeof raw.body === 'object' && !Array.isArray(raw.body)) {\n pushRow(raw.body);\n } else {\n pushRow(raw);\n }\n}\n\nhistory.sort((a, b) => {\n const ta = a.created_at ? Date.parse(a.created_at) : 0;\n const tb = b.created_at ? Date.parse(b.created_at) : 0;\n return ta - tb;\n});\n\nconst countryDefs = [\n { code:'GT', names:['guatemala'] },\n { code:'CR', names:['costa rica'] },\n { code:'SV', names:['el salvador','salvador'] },\n { code:'HN', names:['honduras'] },\n { code:'NI', names:['nicaragua'] },\n];\n\nfunction detectCountry(text) {\n const original = String(text || '');\n const low = original.toLowerCase();\n for (const def of countryDefs) {\n if (def.names.some(name => low.includes(name))) return def.code;\n const re = new RegExp('(^|[^A-Za-z])' + def.code + '([^A-Za-z]|$)');\n if (re.test(original)) return def.code;\n }\n return null;\n}\n\nconst attachmentNames = (Array.isArray(req.attachments) ? req.attachments : []).map(a => String(a?.filename || '')).join(' ');\nlet effectiveCountryCode = req.countryCode || detectCountry(req.text) || detectCountry(attachmentNames);\nlet effectiveCountrySource = effectiveCountryCode ? 'mensaje_actual' : null;\n\n// Prioriza hechos que el propio usuario ya indicó en esta conversación.\nif (!effectiveCountryCode) {\n for (let i = history.length - 1; i >= 0; i--) {\n if (String(history[i].role || '').toLowerCase() !== 'user') continue;\n const found = detectCountry(history[i].content);\n if (found) {\n effectiveCountryCode = found;\n effectiveCountrySource = 'historial_usuario';\n break;\n }\n }\n}\n\n// Fallback: también puede recuperarse de una respuesta previa del agente.\nif (!effectiveCountryCode) {\n for (let i = history.length - 1; i >= 0; i--) {\n const found = detectCountry(history[i].content);\n if (found) {\n effectiveCountryCode = found;\n effectiveCountrySource = 'historial_conversacion';\n break;\n }\n }\n}\n\nconst lastUserMessages = history\n .filter(h => String(h.role || '').toLowerCase() === 'user' && h.content)\n .slice(-6)\n .map(h => h.content);\n\nconst searchQuery = [req.text, ...lastUserMessages]\n .filter(Boolean)\n .join(' | ')\n .slice(0, 12000);\n\nreturn [{\n json: {\n ...req,\n conversationHistory: history.slice(-40),\n effectiveCountryCode: effectiveCountryCode || null,\n effectiveCountrySource,\n searchQuery,\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6560,
|
||
24368
|
||
],
|
||
"id": "7b2ae93f-8c8d-4f6f-bd73-8915530d37df",
|
||
"name": "Resolver contexto conversacional"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "f5c2e703-bd56-43de-bde9-131d85873cc9",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase().startsWith('audio/') || /\\.(webm|mp3|wav|m4a|ogg|oga|aac|flac|mpeg|mpga)$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6992,
|
||
24352
|
||
],
|
||
"id": "40ebefcc-3635-41a1-8509-04e0f2a37055",
|
||
"name": "Tiene audio?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "audio",
|
||
"modelId": {
|
||
"__rl": true,
|
||
"value": "models/gemini-3.7-flash",
|
||
"mode": "id"
|
||
},
|
||
"audioUrls": "={{ ($node[\"Validar acceso\"].json.attachments || []).filter(a => String(a?.mediaType || '').toLowerCase().startsWith('audio/') || /\\.(webm|mp3|wav|m4a|ogg|oga|aac|flac|mpeg|mpga)$/i.test(String(a?.filename || ''))).map(a => a.url).filter(Boolean).join(',') }}",
|
||
"options": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.googleGemini",
|
||
"typeVersion": 1.2,
|
||
"position": [
|
||
-6768,
|
||
24176
|
||
],
|
||
"id": "d9d1e711-2999-491b-a7fd-b608ba801926",
|
||
"name": "Gemini - Transcribir audio",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Validar acceso\"].json;\nconst incomingItems = $input.all().map(i => i?.json ?? {});\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\n\nconst isAudio = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media.startsWith('audio/') || /\\.(webm|mp3|wav|m4a|ogg|oga|aac|flac|mpeg|mpga)$/i.test(name);\n};\n\nconst audioAttachments = attachments.filter(isAudio);\nconst hasAudio = audioAttachments.length > 0;\n\nfunction extractText(raw) {\n if (!raw || typeof raw !== 'object') return '';\n\n const direct = [\n raw.transcription,\n raw.transcript,\n raw.text,\n raw.output,\n raw.response,\n raw.result\n ];\n for (const value of direct) {\n if (typeof value === 'string' && value.trim()) return value.trim();\n }\n\n if (typeof raw.content === 'string' && raw.content.trim()) {\n return raw.content.trim();\n }\n\n const parts =\n (Array.isArray(raw.content?.parts) && raw.content.parts) ||\n (Array.isArray(raw.candidates?.[0]?.content?.parts) && raw.candidates[0].content.parts) ||\n null;\n\n if (parts) {\n const text = parts.map(p => typeof p?.text === 'string' ? p.text : '').filter(Boolean).join('\\n').trim();\n if (text) return text;\n }\n\n if (Array.isArray(raw.results)) {\n const text = raw.results.map(extractText).filter(Boolean).join('\\n').trim();\n if (text) return text;\n }\n\n return '';\n}\n\nconst transcriptions = hasAudio ? incomingItems.map(extractText).filter(Boolean) : [];\nconst audioTranscription = [...new Set(transcriptions)].join('\\n').trim();\nconst originalText = String(base.text || '').trim();\n\nlet text = originalText;\nlet audioTranscriptionError = false;\n\nif (hasAudio) {\n if (audioTranscription) {\n text = originalText\n ? `${originalText}\\n\\n[Transcripción de la nota de voz]\\n${audioTranscription}`\n : audioTranscription;\n } else {\n audioTranscriptionError = true;\n if (!text) {\n text = 'El usuario envió una nota de voz, pero no fue posible transcribirla automáticamente. Pídele que vuelva a intentarlo o que escriba el contenido.';\n }\n }\n}\n\nconst countryMap = [\n ['GT','guatemala'],\n ['CR','costa rica'],\n ['SV','el salvador'],\n ['SV','salvador'],\n ['HN','honduras'],\n ['NI','nicaragua']\n];\n\nconst low = text.toLowerCase();\nlet countryCode = base.countryCode || null;\nif (!countryCode) {\n for (const [code, term] of countryMap) {\n if (low.includes(term)) {\n countryCode = code;\n break;\n }\n }\n}\n\nreturn [{\n json: {\n ...base,\n text,\n countryCode,\n audioTranscription: audioTranscription || null,\n audioTranscriptionError,\n audioAttachmentCount: audioAttachments.length\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6544,
|
||
24176
|
||
],
|
||
"id": "a0de31a2-85c5-43d2-adc8-4d73c774e15e",
|
||
"name": "Preparar entrada final"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"modelName": "models/gemini-2.5-pro",
|
||
"options": {}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-5904,
|
||
24896
|
||
],
|
||
"id": "9118cb65-17cb-490a-bcb8-218d76b4a11d",
|
||
"name": "Gemini 2.5 Pro - Fallback",
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b7a46f9e-51c6-47db-bfae-c96c12775fe6",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase().startsWith('image/') || /\\.(png|jpe?g|webp|gif|bmp|heic|heif)$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6320,
|
||
24176
|
||
],
|
||
"id": "938efcc1-103b-40a0-aa64-359ffd4d5d26",
|
||
"name": "Tiene imágenes?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "image",
|
||
"operation": "analyze",
|
||
"modelId": {
|
||
"__rl": true,
|
||
"value": "models/gemini-2.5-pro",
|
||
"mode": "id"
|
||
},
|
||
"text": "Analiza fielmente las imágenes adjuntas para ayudar al Cotizador Walmart Connect. Describe solo información visible o explícita: textos legibles, marcas, productos, cantidades visibles, tipo de personal, uniformes, mobiliario, medidas si están escritas, materiales, formato/entorno de tienda y cualquier detalle útil para una cotización o propuesta. Si algo no puede determinarse con certeza, indícalo; no inventes datos ni precios.",
|
||
"imageUrls": "={{ ($node[\"Preparar entrada final\"].json.attachments || []).filter(a => String(a?.mediaType || '').toLowerCase().startsWith('image/') || /\\.(png|jpe?g|webp|gif|bmp|heic|heif)$/i.test(String(a?.filename || ''))).map(a => a.url).filter(Boolean).join(',') }}",
|
||
"options": {
|
||
"maxOutputTokens": 1800
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.googleGemini",
|
||
"typeVersion": 1.2,
|
||
"position": [
|
||
-6096,
|
||
24032
|
||
],
|
||
"id": "aa13f9c7-7a1f-484b-8551-6aca684c7725",
|
||
"name": "Gemini - Analizar imágenes",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar entrada final\"].json;\nconst incomingItems = $input.all().map(i => i?.json ?? {});\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isImage = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media.startsWith('image/') || /\\.(png|jpe?g|webp|gif|bmp|heic|heif)$/i.test(name);\n};\nconst imageAttachments = attachments.filter(isImage);\nfunction extractText(raw) {\n if (!raw || typeof raw !== 'object') return '';\n const direct = [raw.transcription, raw.transcript, raw.text, raw.output, raw.response, raw.result, raw.content];\n for (const value of direct) {\n if (typeof value === 'string' && value.trim()) return value.trim();\n if (Array.isArray(value)) {\n const joined = value.map(v => {\n if (typeof v === 'string') return v;\n if (v && typeof v.text === 'string') return v.text;\n return '';\n }).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n }\n const parts =\n (Array.isArray(raw.content?.parts) && raw.content.parts) ||\n (Array.isArray(raw.candidates?.[0]?.content?.parts) && raw.candidates[0].content.parts) ||\n null;\n if (parts) {\n const joined = parts.map(p => typeof p?.text === 'string' ? p.text : '').filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n if (Array.isArray(raw.results)) {\n const joined = raw.results.map(extractText).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n return '';\n}\nconst analyses = imageAttachments.length ? incomingItems.map(extractText).filter(Boolean) : [];\nconst imageAnalysis = [...new Set(analyses)].join('\\n').trim();\nconst imageAnalysisError = imageAttachments.length > 0 && !imageAnalysis;\nlet text = String(base.text || '').trim();\nif (imageAnalysis) {\n text += (text ? '\\n\\n' : '') + '[Análisis de imágenes adjuntas]\\n' + imageAnalysis;\n} else if (imageAnalysisError && !text) {\n text = 'El usuario adjuntó una o más imágenes, pero no fue posible analizarlas automáticamente. Pídele que vuelva a intentarlo con una imagen legible.';\n}\nreturn [{json:{...base,text,imageAnalysis:imageAnalysis || null,imageAnalysisError,imageAttachmentCount:imageAttachments.length}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5872,
|
||
24176
|
||
],
|
||
"id": "a25d08bb-5401-44a6-a864-10f1e3e00c1a",
|
||
"name": "Preparar análisis imágenes"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "3b78d6f1-4093-4422-9305-1a1da5abdb7c",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase() === 'application/pdf' || /\\.pdf$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-5648,
|
||
24176
|
||
],
|
||
"id": "15983c50-adbb-4736-81af-e383ecf7b397",
|
||
"name": "Tiene documentos PDF?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "document",
|
||
"modelId": {
|
||
"__rl": true,
|
||
"value": "models/gemini-2.5-pro",
|
||
"mode": "id"
|
||
},
|
||
"text": "Analiza fielmente los documentos PDF adjuntos para ayudar al Cotizador Walmart Connect. Extrae y resume únicamente información explícita relevante: país, anunciante, proyecto, fechas, formatos, tiendas/ubicaciones, personal, cantidades, días, mobiliario, equipos, insumos, logística, medidas, precios, monedas, condiciones, observaciones y cualquier instrucción de cotización. Lee también tablas, diagramas e imágenes del PDF cuando sean relevantes. No inventes información ni precios; marca claramente cualquier dato que no esté presente.",
|
||
"inputType": "binary",
|
||
"options": {
|
||
"maxOutputTokens": 2400
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.googleGemini",
|
||
"typeVersion": 1.2,
|
||
"position": [
|
||
-5424,
|
||
24032
|
||
],
|
||
"id": "98f26a8a-3f0b-41fe-a432-14d36ccaaa84",
|
||
"name": "Gemini - Analizar documentos",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "jvsXYwL6IOoY2DBU",
|
||
"name": "Isaac - Gemini Api Pago"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis imágenes\"].json;\nconst incomingItems = $input.all().map(i => i?.json ?? {});\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isPdf = (a) => String(a?.mediaType || '').toLowerCase() === 'application/pdf' || /\\.pdf$/i.test(String(a?.filename || ''));\nconst isOtherDocument = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n if (isPdf(a) || media.startsWith('image/') || media.startsWith('audio/') || media.startsWith('video/')) return false;\n return /\\.(docx?|xls|pptx?|csv|txt|rtf|odt|ods|md|html?|xml)$/i.test(name) ||\n media.startsWith('text/') ||\n media.includes('word') || media.includes('excel') || media.includes('spreadsheet') || media.includes('presentation') || media.includes('opendocument');\n};\nconst pdfAttachments = attachments.filter(isPdf);\nconst otherDocumentAttachments = attachments.filter(isOtherDocument);\nfunction extractText(raw) {\n if (!raw || typeof raw !== 'object') return '';\n const direct = [raw.transcription, raw.transcript, raw.text, raw.output, raw.response, raw.result, raw.content];\n for (const value of direct) {\n if (typeof value === 'string' && value.trim()) return value.trim();\n if (Array.isArray(value)) {\n const joined = value.map(v => {\n if (typeof v === 'string') return v;\n if (v && typeof v.text === 'string') return v.text;\n return '';\n }).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n }\n const parts =\n (Array.isArray(raw.content?.parts) && raw.content.parts) ||\n (Array.isArray(raw.candidates?.[0]?.content?.parts) && raw.candidates[0].content.parts) ||\n null;\n if (parts) {\n const joined = parts.map(p => typeof p?.text === 'string' ? p.text : '').filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n if (Array.isArray(raw.results)) {\n const joined = raw.results.map(extractText).filter(Boolean).join('\\n').trim();\n if (joined) return joined;\n }\n return '';\n}\nconst analyses = pdfAttachments.length ? incomingItems.map(extractText).filter(Boolean) : [];\nconst documentAnalysis = [...new Set(analyses)].join('\\n').trim();\nconst documentAnalysisError = pdfAttachments.length > 0 && !documentAnalysis;\nlet text = String(base.text || '').trim();\nif (documentAnalysis) {\n text += (text ? '\\n\\n' : '') + '[Análisis de documentos PDF adjuntos]\\n' + documentAnalysis;\n} else if (documentAnalysisError && !text) {\n text = 'El usuario adjuntó un documento PDF, pero no fue posible analizarlo automáticamente. Pídele que vuelva a intentarlo con un PDF legible.';\n}\nreturn [{json:{\n ...base,\n text,\n documentAnalysis:documentAnalysis || null,\n documentAnalysisError,\n documentAttachmentCount:pdfAttachments.length,\n unparsedDocumentAttachments:otherDocumentAttachments.map(a => ({filename:a.filename || 'archivo',mediaType:a.mediaType || ''})).slice(0,8)\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5200,
|
||
24176
|
||
],
|
||
"id": "1118bf03-ff30-4805-9e07-41020e00d23c",
|
||
"name": "Preparar análisis documentos"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis imágenes\"].json;\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isPdf = (a) => String(a?.mediaType || '').toLowerCase() === 'application/pdf' || /\\.pdf$/i.test(String(a?.filename || ''));\nconst pdfs = attachments.filter(isPdf).filter(a => String(a?.url || '').trim()).slice(0, 4);\n\nif (!pdfs.length) {\n return [{json:{...base,pdfDownloadMissing:true}}];\n}\n\nreturn pdfs.map((a,index) => ({\n json:{\n ...base,\n pdfAttachmentIndex:index,\n pdfFilename:String(a.filename || `documento-${index + 1}.pdf`),\n pdfMediaType:String(a.mediaType || 'application/pdf'),\n pdfUrl:String(a.url || '')\n }\n}));"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5424,
|
||
23840
|
||
],
|
||
"id": "446d2c79-1e0f-4be9-9953-e75444ce69d3",
|
||
"name": "Preparar PDFs para descarga"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ $json.pdfUrl }}",
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
},
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-5200,
|
||
23840
|
||
],
|
||
"id": "0e20a1cc-ec6e-4720-9615-a3d95f9ef89e",
|
||
"name": "Descargar PDFs",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c862e84c-64e9-474a-b247-1b2269b552f6",
|
||
"leftValue": "={{ Array.isArray($json.attachments) && $json.attachments.some(a => String(a?.mediaType || '').toLowerCase() === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || /\\.xlsx$/i.test(String(a?.filename || ''))) }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-4976,
|
||
24176
|
||
],
|
||
"id": "60fcc326-7180-4550-977e-d621f1c23d64",
|
||
"name": "Tiene Excel XLSX?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis documentos\"].json;\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isXlsx = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || /\\.xlsx$/i.test(name);\n};\nconst files = attachments.filter(isXlsx).filter(a => String(a?.url || '').trim()).slice(0, 4);\n\nif (!files.length) {\n return [{json:{...base,xlsxDownloadMissing:true}}];\n}\n\nreturn files.map((a,index) => ({\n json:{\n ...base,\n xlsxAttachmentIndex:index,\n xlsxFilename:String(a.filename || `excel-${index + 1}.xlsx`),\n xlsxWmcCode:((String(a.filename || '').match(/WMC\\d+/i) || [])[0] || '').toUpperCase(),\n xlsxMediaType:String(a.mediaType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),\n xlsxUrl:String(a.url || '')\n }\n}));"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4752,
|
||
24032
|
||
],
|
||
"id": "feac2eaf-9010-4377-a3fe-b5856ae77bdc",
|
||
"name": "Preparar XLSX para descarga"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ $json.xlsxUrl }}",
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
},
|
||
"timeout": 30000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-4528,
|
||
24032
|
||
],
|
||
"id": "a8744a5d-ca96-4f67-985f-dfa909a57f68",
|
||
"name": "Descargar XLSX",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const base = $node[\"Preparar análisis documentos\"].json;\nconst incoming = $input.all();\nconst attachments = Array.isArray(base.attachments) ? base.attachments : [];\nconst isXlsx = (a) => {\n const media = String(a?.mediaType || '').toLowerCase();\n const name = String(a?.filename || '');\n return media === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || /\\.xlsx$/i.test(name);\n};\nconst xlsxAttachments = attachments.filter(isXlsx).slice(0, 4);\n\n// Si no hay XLSX, esta misma salida mantiene intacto el flujo existente.\nif (!xlsxAttachments.length) {\n return [{json:{\n ...base,\n spreadsheetAnalysis:null,\n spreadsheetAnalysisError:false,\n spreadsheetAttachmentCount:0,\n spreadsheetAnalysisScope:null,\n spreadsheetSheetNames:[],\n spreadsheetExtractionWarnings:[]\n }}];\n}\n\nlet metas = [];\ntry { metas = $items(\"Mapear Google Sheet temporal\"); } catch {}\n\nconst MAX_TOTAL_CHARS = 80000;\nconst MAX_ROWS_PER_SHEET = 1500;\nconst MAX_COLS_PER_SHEET = 100;\nconst MAX_SHEETS_PER_FILE = 40;\nlet totalChars = 0;\nlet truncated = false;\nconst blocks = [];\nconst sheetInventory = [];\nconst extractionWarnings = [];\n\nfunction clean(v) {\n return String(v ?? '').replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]+/g,' ').replace(/\\s+/g,' ').trim();\n}\nfunction colLabel(index) {\n let n = Number(index) + 1;\n let out = '';\n while (n > 0) {\n const rem = (n - 1) % 26;\n out = String.fromCharCode(65 + rem) + out;\n n = Math.floor((n - 1) / 26);\n }\n return out;\n}\nfunction effectiveCellValue(cell) {\n if (!cell || typeof cell !== 'object') return '';\n if (cell.formattedValue !== undefined && cell.formattedValue !== null) return clean(cell.formattedValue);\n const ev = cell.effectiveValue;\n if (!ev || typeof ev !== 'object') return '';\n for (const key of ['stringValue','numberValue','boolValue','formulaValue']) {\n if (ev[key] !== undefined && ev[key] !== null) return clean(ev[key]);\n }\n if (ev.errorValue) return clean(ev.errorValue.message || ev.errorValue.type || 'ERROR');\n return '';\n}\nfunction errorText(raw, fallback) {\n const e = raw?.error ?? raw?.message ?? fallback;\n if (!e) return '';\n if (typeof e === 'string') return clean(e);\n if (typeof e?.message === 'string') return clean(e.message);\n try { return clean(JSON.stringify(e)); } catch { return clean(String(e)); }\n}\n\nfor (let fileIndex = 0; fileIndex < Math.max(incoming.length, xlsxAttachments.length); fileIndex++) {\n const raw = incoming[fileIndex]?.json || {};\n const meta = metas[fileIndex]?.json || {};\n const filename = clean(meta.xlsxFilename || xlsxAttachments[fileIndex]?.filename || `archivo-${fileIndex + 1}.xlsx`);\n const sheets = Array.isArray(raw.sheets) ? raw.sheets.slice(0, MAX_SHEETS_PER_FILE) : [];\n\n if (!sheets.length) {\n const err = errorText(raw, meta.tempGoogleSheetConversionError || meta.tempXlsxUploadError || 'No se recibieron hojas del libro convertido.');\n extractionWarnings.push(`${filename}: ${err}`);\n continue;\n }\n\n const names = [];\n for (const sheet of sheets) {\n if (totalChars >= MAX_TOTAL_CHARS) { truncated = true; break; }\n const props = sheet?.properties || {};\n const sheetName = clean(props.title || `Hoja ${names.length + 1}`);\n names.push(sheetName);\n const hiddenLabel = props.hidden === true ? ' (oculta)' : '';\n const rowMap = new Map();\n let maxSeenCol = -1;\n\n for (const grid of (Array.isArray(sheet?.data) ? sheet.data : [])) {\n const startRow = Number(grid?.startRow || 0);\n const startCol = Number(grid?.startColumn || 0);\n const rows = Array.isArray(grid?.rowData) ? grid.rowData : [];\n for (let r = 0; r < rows.length; r++) {\n const absoluteRow = startRow + r;\n if (absoluteRow >= MAX_ROWS_PER_SHEET) { truncated = true; break; }\n const vals = Array.isArray(rows[r]?.values) ? rows[r].values : [];\n if (!rowMap.has(absoluteRow)) rowMap.set(absoluteRow, new Map());\n const row = rowMap.get(absoluteRow);\n for (let c = 0; c < vals.length; c++) {\n const absoluteCol = startCol + c;\n if (absoluteCol >= MAX_COLS_PER_SHEET) { truncated = true; break; }\n const value = effectiveCellValue(vals[c]);\n if (value !== '') {\n row.set(absoluteCol, value);\n if (absoluteCol > maxSeenCol) maxSeenCol = absoluteCol;\n }\n }\n }\n }\n\n const lines = [];\n const sortedRows = [...rowMap.keys()].sort((a,b)=>a-b);\n for (const r of sortedRows) {\n if (totalChars >= MAX_TOTAL_CHARS) { truncated = true; break; }\n const row = rowMap.get(r);\n if (!row || !row.size) continue;\n const cells = [...row.entries()].sort((a,b)=>a[0]-b[0]);\n const line = `Fila ${r + 1}: ` + cells.map(([c,v]) => `[${colLabel(c)}] ${v}`).join(' | ');\n if (totalChars + line.length + 1 > MAX_TOTAL_CHARS) { truncated = true; break; }\n lines.push(line);\n totalChars += line.length + 1;\n }\n\n blocks.push(\n `Archivo: ${filename}\\n` +\n `Hoja analizada: ${sheetName}${hiddenLabel}\\n` +\n (lines.length ? lines.join('\\n') : '(Hoja sin celdas con contenido legible)')\n );\n }\n sheetInventory.push({file:filename,sheets:names});\n}\n\nif (truncated) extractionWarnings.push('La extracción XLSX fue truncada por límites de seguridad de tamaño; se conservaron las primeras hojas/filas/celdas dentro del límite.');\nconst spreadsheetAnalysis = blocks.join('\\n\\n').trim();\nconst spreadsheetAnalysisError = !spreadsheetAnalysis;\nlet text = String(base.text || '').trim();\n\nif (spreadsheetAnalysis) {\n text += (text ? '\\n\\n' : '') +\n '[Contenido REAL extraído de archivos Excel XLSX adjuntos mediante conversión temporal a Google Sheets]\\n' +\n spreadsheetAnalysis;\n} else if (!text) {\n text = 'El usuario adjuntó un archivo Excel XLSX, pero no fue posible leer su contenido automáticamente. Pídele únicamente que vuelva a adjuntarlo; no inventes su contenido.';\n}\n\nreturn [{json:{\n ...base,\n text,\n spreadsheetAnalysis: spreadsheetAnalysis || null,\n spreadsheetAnalysisError,\n spreadsheetAttachmentCount: xlsxAttachments.length,\n spreadsheetAnalysisScope: spreadsheetAnalysis ? 'Todas las hojas con contenido legible del XLSX, hasta 40 hojas por archivo, 1,500 filas y 100 columnas por hoja, con límite total de 80,000 caracteres.' : null,\n spreadsheetSheetNames: sheetInventory,\n spreadsheetExtractionWarnings: [...new Set(extractionWarnings)].slice(0,10)\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4080,
|
||
24176
|
||
],
|
||
"id": "a2a4f93c-3892-4465-bcd1-c18189abca2d",
|
||
"name": "Preparar análisis XLSX"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const downloaded = $input.all();\nlet metas = [];\ntry { metas = $items(\"Preparar XLSX para descarga\"); } catch {}\n\nreturn downloaded.map((item, i) => {\n const meta = metas[i]?.json || {};\n const binary = item?.binary || {};\n const hasBinary = Boolean(binary?.data);\n const rawError = item?.json?.error ?? item?.json?.message ?? null;\n const downloadError = hasBinary ? null : String(\n typeof rawError === 'string' ? rawError : (rawError?.message || 'No se recibió el binario del XLSX.')\n );\n const safeName = String(meta.xlsxFilename || `archivo-${i + 1}.xlsx`)\n .replace(/[^a-zA-Z0-9._-]+/g, '_')\n .slice(0, 120);\n\n return {\n json: {\n ...meta,\n xlsxDownloadOk: hasBinary,\n xlsxDownloadError: downloadError,\n tempUploadName: `_TEMP_WMC_XLSX_${Date.now()}_${i + 1}_${safeName}`\n },\n binary,\n pairedItem: { item: i }\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4416,
|
||
23888
|
||
],
|
||
"id": "02adedd6-7790-4ad7-b315-f7e0c4b4f556",
|
||
"name": "Reasociar XLSX descargado"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"name": "={{ $json.tempUploadName }}",
|
||
"driveId": {
|
||
"__rl": true,
|
||
"value": "My Drive",
|
||
"mode": "list"
|
||
},
|
||
"folderId": {
|
||
"__rl": true,
|
||
"value": "root",
|
||
"mode": "list",
|
||
"cachedResultName": "/ (Root folder)"
|
||
},
|
||
"options": {
|
||
"simplifyOutput": true
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.googleDrive",
|
||
"typeVersion": 3,
|
||
"position": [
|
||
-4192,
|
||
23888
|
||
],
|
||
"id": "f3b77965-bf96-489d-9195-b203b621fcf6",
|
||
"name": "Subir XLSX temporal a Drive",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const uploads = $input.all();\nlet sources = [];\ntry { sources = $items(\"Reasociar XLSX descargado\"); } catch {}\n\nreturn uploads.map((item, i) => {\n const base = sources[i]?.json || {};\n const raw = item?.json || {};\n const id = String(raw.id || '').trim();\n let error = null;\n if (!id) {\n const e = raw.error ?? raw.message ?? base.xlsxDownloadError ?? 'Google Drive no devolvió ID al subir el XLSX temporal.';\n error = String(typeof e === 'string' ? e : (e?.message || JSON.stringify(e)));\n }\n return {\n json: {\n ...base,\n tempXlsxDriveId: id || null,\n tempXlsxUploadError: error,\n tempXlsxUploadOk: Boolean(id)\n },\n pairedItem: { item: i }\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3968,
|
||
23888
|
||
],
|
||
"id": "bf657feb-7821-4e5e-8e31-103811cb1d1b",
|
||
"name": "Mapear XLSX temporal"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ 'https://www.googleapis.com/drive/v2/files/' + encodeURIComponent($json.tempXlsxDriveId || 'invalid') + '/copy?convert=true' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleDriveOAuth2Api",
|
||
"options": {
|
||
"timeout": 60000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-3744,
|
||
23888
|
||
],
|
||
"id": "122f6092-6335-4d56-8210-5a05d32cefef",
|
||
"name": "Convertir XLSX a Google Sheets",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const converted = $input.all();\nlet sources = [];\ntry { sources = $items(\"Mapear XLSX temporal\"); } catch {}\n\nreturn converted.map((item, i) => {\n const base = sources[i]?.json || {};\n const raw = item?.json || {};\n const id = String(raw.id || '').trim();\n const mime = String(raw.mimeType || '').trim();\n let error = null;\n if (!id) {\n const e = raw.error ?? raw.message ?? base.tempXlsxUploadError ?? 'No fue posible convertir el XLSX a Google Sheets.';\n error = String(typeof e === 'string' ? e : (e?.message || JSON.stringify(e)));\n }\n return {\n json: {\n ...base,\n tempGoogleSheetId: id || null,\n tempGoogleSheetMimeType: mime || null,\n tempGoogleSheetConversionOk: Boolean(id),\n tempGoogleSheetConversionError: error\n },\n pairedItem: { item: i }\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3520,
|
||
23888
|
||
],
|
||
"id": "7bc161be-1e46-416e-b455-9639eec6fa76",
|
||
"name": "Mapear Google Sheet temporal"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + encodeURIComponent($json.tempGoogleSheetId || 'invalid') }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleDriveOAuth2Api",
|
||
"sendQuery": true,
|
||
"queryParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "includeGridData",
|
||
"value": "true"
|
||
},
|
||
{
|
||
"name": "fields",
|
||
"value": "spreadsheetId,properties(title),sheets(properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)),data(startRow,startColumn,rowData(values(formattedValue,effectiveValue))))"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"timeout": 90000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-3296,
|
||
23888
|
||
],
|
||
"id": "e33a9fa1-2bd3-4bf2-801b-ea4569e37c98",
|
||
"name": "Leer todas las hojas XLSX",
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 3000,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "let metas = [];\ntry { metas = $items(\"Mapear Google Sheet temporal\"); } catch {}\nconst out = [];\nconst seen = new Set();\nfor (let i = 0; i < metas.length; i++) {\n const m = metas[i]?.json || {};\n for (const [kind, value] of [\n ['google_sheet', m.tempGoogleSheetId],\n ['xlsx_original', m.tempXlsxDriveId]\n ]) {\n const id = String(value || '').trim();\n if (!id || seen.has(id)) continue;\n seen.add(id);\n out.push({json:{deleteFileId:id,deleteKind:kind,xlsxFilename:m.xlsxFilename || null}});\n }\n}\nreturn out;"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3072,
|
||
23648
|
||
],
|
||
"id": "a1142b48-3f52-4344-9893-e137ef3fe4b2",
|
||
"name": "Preparar limpieza XLSX temporal"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "deleteFile",
|
||
"fileId": {
|
||
"__rl": true,
|
||
"value": "={{ $json.deleteFileId }}",
|
||
"mode": "id"
|
||
},
|
||
"options": {
|
||
"deletePermanently": true
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.googleDrive",
|
||
"typeVersion": 3,
|
||
"position": [
|
||
-2848,
|
||
23648
|
||
],
|
||
"id": "b8dce0cf-2bfc-47b9-95bd-82434e619033",
|
||
"name": "Eliminar temporales XLSX",
|
||
"retryOnFail": true,
|
||
"maxTries": 2,
|
||
"waitBetweenTries": 1500,
|
||
"credentials": {
|
||
"googleDriveOAuth2Api": {
|
||
"id": "g23xdGLZRzBGqKgH",
|
||
"name": "Isaac - Google Drive"
|
||
}
|
||
},
|
||
"continueOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"httpMethod": "POST",
|
||
"path": "cotizador-wmc-email-agent",
|
||
"responseMode": "responseNode",
|
||
"options": {
|
||
"allowedOrigins": "*"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.webhook",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
-9408,
|
||
25504
|
||
],
|
||
"id": "6bf85cb3-2529-4a9e-9560-27fec3fe73b7",
|
||
"name": "Webhook - Email Agent",
|
||
"webhookId": "e9d8ac3a-68e3-415d-8f1d-8ed232b10979"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const body = $json.body ?? {};\nconst headers = $json.headers ?? {};\nconst clean = (v, max = 12000) => typeof v === 'string' ? v.trim().slice(0,max) : '';\nconst suppliedSecret = clean(headers['x-wmc-internal-secret'] || headers['X-WMC-Internal-Secret'], 500);\nconst expectedSecret = 'fLf-Fl4AcRL912NsWfC4wGwlfEZ9kvOXJ33ylUjXJ9prTuML4b2sSJt3tDrG5D08';\nconst gmailThreadId = clean(body.gmailThreadId, 300);\nconst gmailMessageId = clean(body.gmailMessageId, 300);\nconst senderEmail = clean(body.senderEmail, 320).toLowerCase();\nconst senderName = clean(body.senderName, 240);\nconst text = clean(body.text, 20000);\nconst attachments = Array.isArray(body.attachments) ? body.attachments.slice(0,8).map(a => ({\n filename: clean(a?.filename,220), mediaType: clean(a?.mediaType,140), url: clean(a?.url,6000), storagePath:null\n})) : [];\nreturn [{json:{\n channel:'email', internalEmailAuthorized: suppliedSecret === expectedSecret,\n text, conversationId:gmailThreadId ? `email:${gmailThreadId}` : '', messageId:gmailMessageId,\n userEmail:senderEmail, userName:senderName, verifiedEmail:senderEmail, fullName:senderName || senderEmail,\n userId:null, canUseTarifario:false, auth:'', appSessionId:clean(body.appSessionId,250)||`email-${Date.now()}`,\n sentAt:clean(body.sentAt,100)||new Date().toISOString(), attachments,\n countryCode:clean(body.countryCode,10).toUpperCase()||null, startedAt:Date.now(),\n preloadedContext: body.context && typeof body.context==='object' ? body.context : {},\n preloadedHistory: Array.isArray(body.history) ? body.history : [],\n emailMeta: body.emailMeta && typeof body.emailMeta==='object' ? body.emailMeta : {},\n approvalRevision: body.approvalRevision && typeof body.approvalRevision==='object' ? body.approvalRevision : null\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8960,
|
||
25504
|
||
],
|
||
"id": "8e68ac73-ff84-4fcc-b5cb-b4b57319ecc8",
|
||
"name": "Normalizar email interno"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a9d37585-cd5b-4bbf-bdda-fbce4d60adb6",
|
||
"leftValue": "={{ $json.internalEmailAuthorized }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-8736,
|
||
25504
|
||
],
|
||
"id": "ee90351f-22ad-4193-bccd-61be44492b8a",
|
||
"name": "Email interno autorizado?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ {ok:false,error:'Solicitud interna no autorizada.'} }}",
|
||
"options": {
|
||
"responseCode": 401
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-8512,
|
||
25728
|
||
],
|
||
"id": "8f87cf16-5278-454b-9aca-0212a919e066",
|
||
"name": "Responder 401 Email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "7febb952-8ac4-4745-b462-edb0b99393ec",
|
||
"leftValue": "={{ $json.channel }}",
|
||
"rightValue": "email",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-7952,
|
||
24032
|
||
],
|
||
"id": "39120bee-42e1-4a45-b58c-57a9f1c78155",
|
||
"name": "¿Canal email?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Preparar análisis XLSX\"].json;\nconst history = Array.isArray(req.preloadedHistory) ? req.preloadedHistory.slice(-40) : [];\nconst context = req.preloadedContext && typeof req.preloadedContext === 'object' ? req.preloadedContext : {};\nreturn [{json:{\n ...req,\n channel:'email',\n conversationHistory:history,\n agentContext:context,\n pendingActions:[],\n canUseTarifario:false\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7728,
|
||
24032
|
||
],
|
||
"id": "84e36b64-fb96-4cc0-a6f1-3905fe92aab8",
|
||
"name": "Empaquetar contexto email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const req = $node[\"Resolver contexto conversacional\"].json;\nconst context = $node[\"Contexto Supabase\"].json ?? {};\nconst pendingItems = $items(\"Acciones pendientes\").map(i => i?.json ?? {}).filter(Boolean);\nconst pending = [];\nfor (const raw of pendingItems) {\n if (Array.isArray(raw)) pending.push(...raw);\n else if (Array.isArray(raw?.items)) pending.push(...raw.items);\n else if (Array.isArray(raw?.data)) pending.push(...raw.data);\n else if (Array.isArray(raw?.body)) pending.push(...raw.body);\n else if (raw && typeof raw === 'object' && Object.keys(raw).length) pending.push(raw);\n}\nreturn [{json:{\n ...req,\n channel:'chat',\n conversationHistory:Array.isArray(req.conversationHistory)?req.conversationHistory.slice(-30):[],\n agentContext:context,\n pendingActions:pending\n}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7504,
|
||
24256
|
||
],
|
||
"id": "00ce8222-3388-4ee3-acbe-0fff2399c258",
|
||
"name": "Empaquetar contexto chat"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a3a853f8-67a1-4393-b76a-888cf737e419",
|
||
"leftValue": "={{ $json.channel }}",
|
||
"rightValue": "email",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6640,
|
||
23616
|
||
],
|
||
"id": "a519787f-80af-45da-978a-ca08aaccff59",
|
||
"name": "¿Salida email?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 2
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "8d81eadb-cb73-4803-8110-17d1e972f305",
|
||
"leftValue": "={{ [\"generate_quote\",\"generate_proposal\",\"generate_image\",\"generate_monthly_glm\"].includes($json.agent.action) && $json.agent.emailDisposition !== \"ignore\" }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
-6416,
|
||
23424
|
||
],
|
||
"id": "bf995f50-959b-42c0-97a4-64d5527468bf",
|
||
"name": "¿Email requiere artefacto?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x=$node[\"Interpretar Gemini\"].json;\nreturn [{json:{action:x.agent.action,quote:x.agent.quote,artifact:x.agent.artifact,user:{id:null,email:'wmc@gomezlee.marketing',name:'Walmart Connect Email Agent'},conversationId:x.conversationId,appSessionId:x.appSessionId,model:x.agent.model,internalSecret:'fLf-Fl4AcRL912NsWfC4wGwlfEZ9kvOXJ33ylUjXJ9prTuML4b2sSJt3tDrG5D08'}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-6192,
|
||
23296
|
||
],
|
||
"id": "ef9f3dbf-2938-4f97-8efb-fafc4a56db6b",
|
||
"name": "Preparar artefacto email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://agenteit.digitalcompass.agency/webhook/cotizador-wmc-artifacts",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "X-WMC-Internal-Secret",
|
||
"value": "={{ $json.internalSecret }}"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "raw",
|
||
"rawContentType": "application/json",
|
||
"body": "={{ JSON.stringify({action:$json.action,quote:$json.quote,artifact:$json.artifact,user:$json.user,conversationId:$json.conversationId,appSessionId:$json.appSessionId,model:$json.model,internalEmail:true}) }}",
|
||
"options": {
|
||
"timeout": 180000
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-5968,
|
||
23296
|
||
],
|
||
"id": "9aac423b-63fb-4561-92ab-ed66b01872c8",
|
||
"name": "Generar artefacto email"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x=$node[\"Interpretar Gemini\"].json;\nconst g=$json??{};\nconst final={ok:g.ok!==false,...x.agent,artifactUrl:g.artifactUrl||null,artifactType:g.artifactType||null,artifactId:g.artifactId||null,quoteId:g.quoteId||null,title:g.title||null};\nif(g.ok===false){final.ok=false;final.warnings=[...(final.warnings||[]),'No se pudo generar el archivo solicitado.'];final.error=g.error||'Error de artefacto';}\nreturn [{json:final}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5744,
|
||
23296
|
||
],
|
||
"id": "e4f13906-99e7-4f1b-bad1-a5a885a9fb3f",
|
||
"name": "Preparar respuesta email con artefacto"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const x=$node[\"Interpretar Gemini\"].json;\nreturn [{json:{ok:true,...x.agent,artifactUrl:null,artifactType:null,artifactId:null,quoteId:null,title:null}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-5968,
|
||
23552
|
||
],
|
||
"id": "779035df-c7db-4f6f-ab15-f555ca429a9c",
|
||
"name": "Preparar respuesta email sin artefacto"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ $json }}",
|
||
"options": {
|
||
"responseCode": 200
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.4,
|
||
"position": [
|
||
-5520,
|
||
23424
|
||
],
|
||
"id": "bb243240-7d68-440d-a162-689e33320b98",
|
||
"name": "Responder email agente"
|
||
}
|
||
],
|
||
"connections": {
|
||
"Webhook - Chat": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Configuración - sin ENV",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar solicitud": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - validar JWT",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - validar JWT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Validar identidad",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Validar identidad": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "JWT válido?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"JWT válido?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - consultar acceso",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Responder 401",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - consultar acceso": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Validar acceso",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Validar acceso": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Acceso permitido?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Acceso permitido?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene audio?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Responder 403",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Contexto Supabase": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Acciones pendientes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Historial Supabase": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Resolver contexto conversacional",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Acciones pendientes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Empaquetar contexto chat",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Construir Gemini": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Basic LLM Chain",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Interpretar Gemini": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "¿Salida email?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Procesar acción sensible": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Generar artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Generar artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Respuesta final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Respuesta final": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Registrar ejecución",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Registrar ejecución": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Responder al chat",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Configuración - sin ENV": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar solicitud",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Basic LLM Chain": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Interpretar Gemini",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini 3.7 Flash - Chat Model": {
|
||
"ai_languageModel": [
|
||
[
|
||
{
|
||
"node": "Gemini - Basic LLM Chain",
|
||
"type": "ai_languageModel",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Resolver contexto conversacional": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Contexto Supabase",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene audio?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Transcribir audio",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar entrada final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Transcribir audio": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar entrada final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar entrada final": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene imágenes?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene imágenes?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Analizar imágenes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar análisis imágenes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Analizar imágenes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar análisis imágenes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar análisis imágenes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene documentos PDF?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene documentos PDF?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar PDFs para descarga",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar análisis documentos",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini - Analizar documentos": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar análisis documentos",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar análisis documentos": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene Excel XLSX?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Gemini 2.5 Pro - Fallback": {
|
||
"ai_languageModel": [
|
||
[
|
||
{
|
||
"node": "Gemini - Basic LLM Chain",
|
||
"type": "ai_languageModel",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar PDFs para descarga": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Descargar PDFs",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Descargar PDFs": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Gemini - Analizar documentos",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Tiene Excel XLSX?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar XLSX para descarga",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar análisis XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar XLSX para descarga": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Descargar XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Descargar XLSX": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Reasociar XLSX descargado",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar análisis XLSX": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "¿Canal email?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Reasociar XLSX descargado": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Subir XLSX temporal a Drive",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Subir XLSX temporal a Drive": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Mapear XLSX temporal",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Mapear XLSX temporal": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Convertir XLSX a Google Sheets",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Convertir XLSX a Google Sheets": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Mapear Google Sheet temporal",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Mapear Google Sheet temporal": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Leer todas las hojas XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Leer todas las hojas XLSX": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar análisis XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Preparar limpieza XLSX temporal",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar limpieza XLSX temporal": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Eliminar temporales XLSX",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Webhook - Email Agent": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar email interno",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar email interno": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Email interno autorizado?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Email interno autorizado?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Tiene audio?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Responder 401 Email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"¿Canal email?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Empaquetar contexto email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Historial Supabase",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Empaquetar contexto chat": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Construir Gemini",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Empaquetar contexto email": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Construir Gemini",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"¿Salida email?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "¿Email requiere artefacto?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Procesar acción sensible",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"¿Email requiere artefacto?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar artefacto email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Preparar respuesta email sin artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar artefacto email": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Generar artefacto email",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Generar artefacto email": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar respuesta email con artefacto",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar respuesta email con artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Responder email agente",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar respuesta email sin artefacto": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Responder email agente",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"authors": "Isaac Aracena",
|
||
"name": "Version f870cadc",
|
||
"description": "",
|
||
"autosaved": true,
|
||
"workflowPublishHistory": [
|
||
{
|
||
"createdAt": "2026-08-29T16:12:57.511Z",
|
||
"id": 5300,
|
||
"workflowId": "rtnO23gHlJfj45pA",
|
||
"versionId": "f870cadc-50ae-4e5e-83dd-2df02594dc9b",
|
||
"event": "activated",
|
||
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
||
}
|
||
]
|
||
}
|
||
} |