From edecd271b96ffd1040755a060d3cafb2a9df3a34 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 20:01:14 -0300 Subject: [PATCH] fix(v2): fix missing transitive dependency (normalize.js) in 3 node drivers Found while syncing this cycle's fixes to the live DEV workflow and preparing Run A: tools/build-workflow.js's readLogicSource() only inlines whatever a node's own `@requires` header lists -- it does not resolve transitive dependencies. context.js started doing `require('./normalize')` earlier this cycle, but the three node drivers that need normalizeTicket/ buildAiContextText (which call normalizeGlpiText internally) still only declared `@requires context.js`, never `normalize.js` itself. The result compiles fine (new Function(...) syntax-checks pass -- a ReferenceError is a runtime error, not a syntax error) but would throw "normalizeGlpiText is not defined" the moment any of these three nodes actually ran: - Code - Mesclar Contexto e Calcular Hash (merge-context-hash.node.js) - Code - Normalizar Candidatos Base (normalize-candidates.node.js) - Code - Preparar Prompt IA (prepare-ai-prompt.node.js) This never surfaced in Run 1 (prior session) because normalize.js/the descricao field didn't exist yet at that point, and it didn't surface in this cycle's local test suite because node--test only exercises the pure logic/*.js modules directly (with real requires), never the inlined, concatenated jsCode a node actually runs in n8n. Fixed by adding normalize.js explicitly to each of the three headers, matching the codebase's existing convention for multi-file deps (e.g. compute-score.node.js already lists `@requires cooldown.js score.js` together instead of relying on any transitive resolution). Added a regression test that scans every generated Code node for calls to normalizeGlpiText/normalizeTicket/buildAiContextText and asserts the normalizeGlpiText function definition is actually present in that same inlined source -- would have caught this before it ever reached DEV. 102/102 tests passing (was 101/101 -- +1 new regression test). Co-Authored-By: Claude Sonnet 5 --- n8n/deployed/coordenador-glpi-v2.n8n.json | 6 +++--- n8n/nodes/merge-context-hash.node.js | 2 +- n8n/nodes/normalize-candidates.node.js | 2 +- n8n/nodes/prepare-ai-prompt.node.js | 2 +- tools/build-workflow.test.js | 18 ++++++++++++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json index 1cff084..f76e518 100644 --- a/n8n/deployed/coordenador-glpi-v2.n8n.json +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -199,7 +199,7 @@ -160 ], "parameters": { - "jsCode": "function normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst items = $input.all();\nreturn items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));" + "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst items = $input.all();\nreturn items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));" } }, { @@ -240,7 +240,7 @@ -160 ], "parameters": { - "jsCode": "function normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nfunction stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];\n\n// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint\n// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma\n// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca\n// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\nconst candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);\nconst contextRows = $input.all().map((item) => item.json);\n\nfunction groupBy(rows, key) {\n const map = {};\n for (const row of rows) {\n const k = String(row[key]);\n if (!map[k]) map[k] = [];\n map[k].push(row);\n }\n return map;\n}\n\nconst followupRows = contextRows.filter((row) => row.tipo === 'followup');\nconst taskRows = contextRows.filter((row) => row.tipo === 'task');\n\nconst followupsByTicket = groupBy(followupRows, 'tickets_id');\nconst tasksByTicket = groupBy(taskRows, 'tickets_id');\n\nreturn candidatosBase.map((base) => {\n const idKey = String(base.id);\n const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));\n const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));\n const ticket = normalizeTicket(base, { followups, tasks });\n const contextHash = buildContextFingerprint(ticket);\n return { json: { ticket, contextHash } };\n});" + "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nfunction stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];\n\n// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint\n// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma\n// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca\n// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\nconst candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);\nconst contextRows = $input.all().map((item) => item.json);\n\nfunction groupBy(rows, key) {\n const map = {};\n for (const row of rows) {\n const k = String(row[key]);\n if (!map[k]) map[k] = [];\n map[k].push(row);\n }\n return map;\n}\n\nconst followupRows = contextRows.filter((row) => row.tipo === 'followup');\nconst taskRows = contextRows.filter((row) => row.tipo === 'task');\n\nconst followupsByTicket = groupBy(followupRows, 'tickets_id');\nconst tasksByTicket = groupBy(taskRows, 'tickets_id');\n\nreturn candidatosBase.map((base) => {\n const idKey = String(base.id);\n const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));\n const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));\n const ticket = normalizeTicket(base, { followups, tasks });\n const contextHash = buildContextFingerprint(ticket);\n return { json: { ticket, contextHash } };\n});" } }, { @@ -370,7 +370,7 @@ -400 ], "parameters": { - "jsCode": "function normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nconst systemPrompt = [\n 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',\n 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',\n 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',\n 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',\n 'Idade do chamado sozinha NAO e evidencia de acionabilidade.',\n 'Identifique: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos que justifique a conclusao?',\n 'Se a informacao for insuficiente para decidir com confianca, use a classificacao INSUFFICIENT_CONTEXT e confidence baixo -- nao invente uma acao.',\n `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas).`,\n].join('\\n');\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);\n\n const requestBody = {\n model: data.config.aiModel,\n temperature: 0.1,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'ticket_classification',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n classification: { type: 'string', enum: VALID_CLASSIFICATIONS },\n actionable_today: { type: 'boolean' },\n confidence: { type: 'number' },\n blocker_type: { type: ['string', 'null'] },\n waiting_for: { type: ['string', 'null'] },\n next_action: { type: 'string' },\n suggested_action_type: { type: 'string' },\n business_risk: { type: 'string' },\n reason: { type: 'string' },\n evidence: { type: 'array', items: { type: 'string' } },\n },\n required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],\n },\n },\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n return { json: { ...data, requestBody } };\n});" + "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nconst systemPrompt = [\n 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',\n 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',\n 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',\n 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',\n 'Idade do chamado sozinha NAO e evidencia de acionabilidade.',\n 'Identifique: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos que justifique a conclusao?',\n 'Se a informacao for insuficiente para decidir com confianca, use a classificacao INSUFFICIENT_CONTEXT e confidence baixo -- nao invente uma acao.',\n `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas).`,\n].join('\\n');\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);\n\n const requestBody = {\n model: data.config.aiModel,\n temperature: 0.1,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'ticket_classification',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n classification: { type: 'string', enum: VALID_CLASSIFICATIONS },\n actionable_today: { type: 'boolean' },\n confidence: { type: 'number' },\n blocker_type: { type: ['string', 'null'] },\n waiting_for: { type: ['string', 'null'] },\n next_action: { type: 'string' },\n suggested_action_type: { type: 'string' },\n business_risk: { type: 'string' },\n reason: { type: 'string' },\n evidence: { type: 'array', items: { type: 'string' } },\n },\n required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],\n },\n },\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n return { json: { ...data, requestBody } };\n});" } }, { diff --git a/n8n/nodes/merge-context-hash.node.js b/n8n/nodes/merge-context-hash.node.js index 3d1fed0..18ef44c 100644 --- a/n8n/nodes/merge-context-hash.node.js +++ b/n8n/nodes/merge-context-hash.node.js @@ -1,4 +1,4 @@ -// @requires context.js hash.js +// @requires normalize.js context.js hash.js const candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json); const contextRows = $input.all().map((item) => item.json); diff --git a/n8n/nodes/normalize-candidates.node.js b/n8n/nodes/normalize-candidates.node.js index 5ce5e31..74f02f3 100644 --- a/n8n/nodes/normalize-candidates.node.js +++ b/n8n/nodes/normalize-candidates.node.js @@ -1,3 +1,3 @@ -// @requires context.js +// @requires normalize.js context.js const items = $input.all(); return items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) })); diff --git a/n8n/nodes/prepare-ai-prompt.node.js b/n8n/nodes/prepare-ai-prompt.node.js index d92b341..fef63fe 100644 --- a/n8n/nodes/prepare-ai-prompt.node.js +++ b/n8n/nodes/prepare-ai-prompt.node.js @@ -1,4 +1,4 @@ -// @requires context.js +// @requires normalize.js context.js const VALID_CLASSIFICATIONS = [ 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR', 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION', diff --git a/tools/build-workflow.test.js b/tools/build-workflow.test.js index 9156c0b..e41821d 100644 --- a/tools/build-workflow.test.js +++ b/tools/build-workflow.test.js @@ -66,6 +66,24 @@ test('glpi_base_url no Config nao e mais o placeholder do V1', () => { assert.equal(glpiField.value, 'https://glpi.gtigrupo.com.br'); }); +test('todo node que chama normalizeGlpiText/normalizeTicket/buildAiContextText tem a definicao dessas funcoes inlined (deps transitivas de context.js -> normalize.js)', () => { + // Regressao: context.js passou a fazer require('./normalize') nesta correcao, mas + // readLogicSource() so inlina o que o header @requires do node lista -- nao resolve + // deps transitivas. Um node com "@requires context.js" e sem "normalize.js" no header + // compila (sintaxe valida), mas explode em runtime com "normalizeGlpiText is not defined". + const wf = buildWorkflow(); + for (const node of wf.nodes) { + if (node.type !== 'n8n-nodes-base.code') continue; + const code = node.parameters.jsCode; + const callsHelper = /\bnormalizeGlpiText\(|\bnormalizeTicket\(|\bbuildAiContextText\(/.test(code); + if (!callsHelper) continue; + assert.ok( + code.includes('function normalizeGlpiText'), + `node ${node.name} chama uma funcao de normalize.js mas nao tem normalizeGlpiText inlined (adicionar normalize.js ao @requires)`, + ); + } +}); + test('link_glpi na query de candidatos usa a variavel de config, nao um dominio fixo no SQL', () => { const fs = require('node:fs'); const path = require('node:path');