From ea105d525cb6b3b4936353121fda9ce30d7f941a Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 16:30:13 -0300 Subject: [PATCH] fix(v2): three real bugs found by an actual end-to-end run against production GLPI data Ran the deployed workflow for real (temporary webhook trigger, briefly activated, immediately deactivated and removed after each attempt) against the real GLPI MySQL database and a real OpenAI credential. Found and fixed: 1. $env access is blocked on this n8n instance (N8N_BLOCK_ENV_ACCESS_IN_NODE). parseConfig($env) threw "access to env vars denied" on the very first Code node. Config now comes from the "Config - Coordenador GLPI v2" Set node instead (same pattern the V1 workflow already used) -- expanded that node with all 13 tunable parameters, and pointed the 3 affected node drivers (build-sql-candidatos, decide-needs-ai, select-top10) at it instead of $env. 2. Data Table "upsert" operation requires both `columns` (values) and a separate `filters` (match condition) -- I had only set columns with matchingColumns, which isn't enough; n8n refused to activate the workflow ("Missing or invalid required parameters: filters"). Added filters.conditions matching on ticket_id to both memory-writing Data Table nodes. 3. Real architecture bug: "Data Table - Buscar Memoria" sat inline in the 41-candidate chain (Mesclar Contexto -> Buscar Memoria -> Decidir Necessidade de IA). A bulk "get all rows" Data Table node run with 41 input items produced 41 identical fetches and its OWN output (the empty memory table on a first run) replaced the 41 candidates entirely -- "Decidir Necessidade de IA" received 0 items and the whole main branch silently skipped. Moved the memory fetch to a parallel branch off Config (fires once) and added a real Merge node ("Merge - Aguardar Memoria") to guarantee both the candidates and the memory rows have executed before "Decidir Necessidade de IA" runs -- same synchronization pattern already used for context and for the shadow comparison. Also fixed a cosmetic bug found in the real message output: buildTopMessage produced a quadruple newline between the header and the first ticket block ([header, '', ...blocks].join('\n\n') double-counts the blank line already inserted by the '\n\n' join). Added a regression test. Verified with a real execution (id 13599, 24/08/2026 19:25 UTC) against the live GLPI database: 41 real candidates, 30 sent to OpenAI (capped), 11 fallback-capped, memory table populated with 41 real rows including genuine WAITING_VENDOR/WAITING_REQUESTER classifications with real blocker_type/waiting_for fields extracted from real ticket task content, and a real shadow_runs row comparing Legacy x V2 (4 tickets removed by V2: 2 for INSUFFICIENT_CONTEXT, 1 WAITING_REQUESTER, 1 WAITING_VENDOR; 4 added). Co-Authored-By: Claude Sonnet 5 --- logic/message.js | 2 +- logic/message.test.js | 5 + n8n/deployed/coordenador-glpi-v2.n8n.json | 142 ++++++++++++++++++++-- n8n/nodes/build-sql-candidatos.node.js | 8 +- n8n/nodes/decide-needs-ai.node.js | 7 +- n8n/nodes/select-top10.node.js | 3 +- n8n/workflow.template.json | 48 ++++++-- 7 files changed, 192 insertions(+), 23 deletions(-) diff --git a/logic/message.js b/logic/message.js index eddc4c0..1cb6240 100644 --- a/logic/message.js +++ b/logic/message.js @@ -41,7 +41,7 @@ function buildTopMessage(selected, dateStr, config) { ]; return linhasDoBloco.join('\n'); }); - const partesDaMensagem = [header, '', ...blocks]; + const partesDaMensagem = [header, ...blocks]; return partesDaMensagem.join('\n\n'); } diff --git a/logic/message.test.js b/logic/message.test.js index a31d288..89f0676 100644 --- a/logic/message.test.js +++ b/logic/message.test.js @@ -40,6 +40,11 @@ test('digest de coordenacao vazio retorna null (nao manda lista igual todo dia)' assert.equal(buildCoordinationDigest([], '24/08'), null); }); +test('mensagem nao tem linhas em branco duplicadas entre cabecalho e blocos', () => { + const msg = buildTopMessage([selectedItem({}), selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38612 } })], '24/08', {}); + assert.ok(!msg.includes('\n\n\n'), 'nao deveria haver 3+ quebras de linha seguidas'); +}); + test('digest de coordenacao lista apenas os itens passados (novos/alterados)', () => { const digest = buildCoordinationDigest([{ ticket: { id: 37691 }, aiClassification: { reason: 'aguardando definicao' }, exclusionReason: 'needs_coordination' }], '24/08'); assert.ok(digest.includes('#37691')); diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json index 96a83e8..5b5d28e 100644 --- a/n8n/deployed/coordenador-glpi-v2.n8n.json +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -49,6 +49,84 @@ "name": "glpi_base_url", "type": "string", "value": "https://glpi.suaempresa.com.br" + }, + { + "id": "cfg-ai-enabled", + "name": "AI_ENABLED", + "type": "boolean", + "value": true + }, + { + "id": "cfg-ai-provider", + "name": "AI_PROVIDER", + "type": "string", + "value": "openai" + }, + { + "id": "cfg-ai-model", + "name": "AI_MODEL", + "type": "string", + "value": "gpt-4o-mini" + }, + { + "id": "cfg-candidate-pool-size", + "name": "CANDIDATE_POOL_SIZE", + "type": "number", + "value": 60 + }, + { + "id": "cfg-ai-max-candidates", + "name": "AI_MAX_CANDIDATES", + "type": "number", + "value": 30 + }, + { + "id": "cfg-top-n", + "name": "TOP_N", + "type": "number", + "value": 10 + }, + { + "id": "cfg-cooldown-days", + "name": "COOLDOWN_DAYS", + "type": "number", + "value": 1 + }, + { + "id": "cfg-ai-confidence-threshold", + "name": "AI_CONFIDENCE_THRESHOLD", + "type": "number", + "value": 0.6 + }, + { + "id": "cfg-ai-max-context-chars", + "name": "AI_MAX_CONTEXT_CHARS", + "type": "number", + "value": 4000 + }, + { + "id": "cfg-dev-mode", + "name": "DEV_MODE", + "type": "boolean", + "value": true + }, + { + "id": "cfg-shadow-mode", + "name": "SHADOW_MODE", + "type": "boolean", + "value": true + }, + { + "id": "cfg-dias-sem-interacao-alerta", + "name": "DIAS_SEM_INTERACAO_ALERTA", + "type": "number", + "value": 2 + }, + { + "id": "cfg-horas-sla-risco", + "name": "HORAS_SLA_RISCO", + "type": "number", + "value": 8 } ] }, @@ -65,7 +143,7 @@ -160 ], "parameters": { - "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nconst config = parseConfig($env);\nconst glpiBaseUrl = String($('Config - Coordenador GLPI v2').first().json.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];" + "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\n// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),\n// entao a configuracao vem do node \"Config - Coordenador GLPI v2\" (mesmo padrao do V1),\n// nao de variaveis de ambiente reais.\nconst configSource = $('Config - Coordenador GLPI v2').first().json;\nconst config = parseConfig(configSource);\nconst glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];" } }, { @@ -171,8 +249,8 @@ "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ - 320, - -160 + -1120, + 300 ], "parameters": { "resource": "row", @@ -187,6 +265,20 @@ "returnAll": true } }, + { + "id": "merge-aguardar-memoria", + "name": "Merge - Aguardar Memoria", + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 320, + -160 + ], + "parameters": { + "mode": "append", + "numberInputs": 2 + } + }, { "id": "code-decidir-necessidade-ia", "name": "Code - Decidir Necessidade de IA", @@ -197,7 +289,7 @@ -160 ], "parameters": { - "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\nconst config = parseConfig($env);\nconst now = Date.now();\n\nconst items = $input.all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n const analysis = needsReanalysis({ memory, currentHash: item.contextHash, now });\n return { ticket: item.ticket, contextHash: item.contextHash, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});" + "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst now = Date.now();\n\n// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a\n// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.\nconst items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n const analysis = needsReanalysis({ memory, currentHash: item.contextHash, now });\n return { ticket: item.ticket, contextHash: item.contextHash, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});" } }, { @@ -406,6 +498,15 @@ "mode": "name", "value": "coordenador_glpi_memoria" }, + "filters": { + "conditions": [ + { + "keyName": "ticket_id", + "condition": "eq", + "keyValue": "={{ $json.ticket_id }}" + } + ] + }, "columns": { "mappingMode": "defineBelow", "matchingColumns": [ @@ -439,7 +540,7 @@ -320 ], "parameters": { - "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n config: data.config,\n now: data.now,\n}));\n\nconst config = scored.length ? scored[0].config : parseConfig($env);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];" + "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n config: data.config,\n now: data.now,\n}));\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];" } }, { @@ -471,6 +572,15 @@ "mode": "name", "value": "coordenador_glpi_memoria" }, + "filters": { + "conditions": [ + { + "keyName": "ticket_id", + "condition": "eq", + "keyValue": "={{ $json.ticket_id }}" + } + ] + }, "columns": { "mappingMode": "defineBelow", "matchingColumns": [ @@ -497,7 +607,7 @@ -320 ], "parameters": { - "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, '', ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];" + "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];" } }, { @@ -510,7 +620,7 @@ -320 ], "parameters": { - "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, '', ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];" + "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];" } }, { @@ -671,6 +781,11 @@ "node": "Code - Montar SQL Legado", "type": "main", "index": 0 + }, + { + "node": "Data Table - Buscar Memoria", + "type": "main", + "index": 0 } ] ] @@ -745,7 +860,7 @@ "main": [ [ { - "node": "Data Table - Buscar Memoria", + "node": "Merge - Aguardar Memoria", "type": "main", "index": 0 } @@ -753,6 +868,17 @@ ] }, "Data Table - Buscar Memoria": { + "main": [ + [ + { + "node": "Merge - Aguardar Memoria", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Aguardar Memoria": { "main": [ [ { diff --git a/n8n/nodes/build-sql-candidatos.node.js b/n8n/nodes/build-sql-candidatos.node.js index 148df1c..33881d8 100644 --- a/n8n/nodes/build-sql-candidatos.node.js +++ b/n8n/nodes/build-sql-candidatos.node.js @@ -1,6 +1,10 @@ // @requires config.js -const config = parseConfig($env); -const glpiBaseUrl = String($('Config - Coordenador GLPI v2').first().json.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''"); +// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE), +// entao a configuracao vem do node "Config - Coordenador GLPI v2" (mesmo padrao do V1), +// nao de variaveis de ambiente reais. +const configSource = $('Config - Coordenador GLPI v2').first().json; +const config = parseConfig(configSource); +const glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''"); const queryTemplate = /* __SQL_CANDIDATOS_V2__ */; diff --git a/n8n/nodes/decide-needs-ai.node.js b/n8n/nodes/decide-needs-ai.node.js index 21f07f2..f11fb8d 100644 --- a/n8n/nodes/decide-needs-ai.node.js +++ b/n8n/nodes/decide-needs-ai.node.js @@ -1,8 +1,11 @@ // @requires config.js cache.js -const config = parseConfig($env); +// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2". +const config = parseConfig($('Config - Coordenador GLPI v2').first().json); const now = Date.now(); -const items = $input.all().map((item) => item.json); // { ticket, contextHash } +// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a +// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome. +const items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash } const memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json); const memoryByTicket = {}; for (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row; diff --git a/n8n/nodes/select-top10.node.js b/n8n/nodes/select-top10.node.js index a58da88..3a047a2 100644 --- a/n8n/nodes/select-top10.node.js +++ b/n8n/nodes/select-top10.node.js @@ -15,7 +15,8 @@ const scored = items.map((data) => ({ now: data.now, })); -const config = scored.length ? scored[0].config : parseConfig($env); +// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2". +const config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json); const { selected, excluded } = selectTop(scored, config.topN, config); const selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } })); diff --git a/n8n/workflow.template.json b/n8n/workflow.template.json index a9f8a98..505191a 100644 --- a/n8n/workflow.template.json +++ b/n8n/workflow.template.json @@ -32,12 +32,20 @@ "parameters": { "assignments": { "assignments": [ - { - "id": "glpi-base-url-v2", - "name": "glpi_base_url", - "type": "string", - "value": "https://glpi.suaempresa.com.br" - } + { "id": "glpi-base-url-v2", "name": "glpi_base_url", "type": "string", "value": "https://glpi.suaempresa.com.br" }, + { "id": "cfg-ai-enabled", "name": "AI_ENABLED", "type": "boolean", "value": true }, + { "id": "cfg-ai-provider", "name": "AI_PROVIDER", "type": "string", "value": "openai" }, + { "id": "cfg-ai-model", "name": "AI_MODEL", "type": "string", "value": "gpt-4o-mini" }, + { "id": "cfg-candidate-pool-size", "name": "CANDIDATE_POOL_SIZE", "type": "number", "value": 60 }, + { "id": "cfg-ai-max-candidates", "name": "AI_MAX_CANDIDATES", "type": "number", "value": 30 }, + { "id": "cfg-top-n", "name": "TOP_N", "type": "number", "value": 10 }, + { "id": "cfg-cooldown-days", "name": "COOLDOWN_DAYS", "type": "number", "value": 1 }, + { "id": "cfg-ai-confidence-threshold", "name": "AI_CONFIDENCE_THRESHOLD", "type": "number", "value": 0.6 }, + { "id": "cfg-ai-max-context-chars", "name": "AI_MAX_CONTEXT_CHARS", "type": "number", "value": 4000 }, + { "id": "cfg-dev-mode", "name": "DEV_MODE", "type": "boolean", "value": true }, + { "id": "cfg-shadow-mode", "name": "SHADOW_MODE", "type": "boolean", "value": true }, + { "id": "cfg-dias-sem-interacao-alerta", "name": "DIAS_SEM_INTERACAO_ALERTA", "type": "number", "value": 2 }, + { "id": "cfg-horas-sla-risco", "name": "HORAS_SLA_RISCO", "type": "number", "value": 8 } ] }, "options": {} @@ -112,7 +120,7 @@ "name": "Data Table - Buscar Memoria", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, - "position": [320, -160], + "position": [-1120, 300], "parameters": { "resource": "row", "operation": "get", @@ -121,6 +129,14 @@ "returnAll": true } }, + { + "id": "merge-aguardar-memoria", + "name": "Merge - Aguardar Memoria", + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [320, -160], + "parameters": { "mode": "append", "numberInputs": 2 } + }, { "id": "code-decidir-necessidade-ia", "name": "Code - Decidir Necessidade de IA", @@ -259,6 +275,11 @@ "resource": "row", "operation": "upsert", "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, + "filters": { + "conditions": [ + { "keyName": "ticket_id", "condition": "eq", "keyValue": "={{ $json.ticket_id }}" } + ] + }, "columns": { "mappingMode": "defineBelow", "matchingColumns": ["ticket_id"], @@ -306,6 +327,11 @@ "resource": "row", "operation": "upsert", "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, + "filters": { + "conditions": [ + { "keyName": "ticket_id", "condition": "eq", "keyValue": "={{ $json.ticket_id }}" } + ] + }, "columns": { "mappingMode": "defineBelow", "matchingColumns": ["ticket_id"], @@ -429,7 +455,8 @@ "Config - Coordenador GLPI v2": { "main": [[ { "node": "Code - Montar SQL Candidatos", "type": "main", "index": 0 }, - { "node": "Code - Montar SQL Legado", "type": "main", "index": 0 } + { "node": "Code - Montar SQL Legado", "type": "main", "index": 0 }, + { "node": "Data Table - Buscar Memoria", "type": "main", "index": 0 } ]] }, "Code - Montar SQL Candidatos": { @@ -451,9 +478,12 @@ "main": [[{ "node": "Code - Mesclar Contexto e Calcular Hash", "type": "main", "index": 0 }]] }, "Code - Mesclar Contexto e Calcular Hash": { - "main": [[{ "node": "Data Table - Buscar Memoria", "type": "main", "index": 0 }]] + "main": [[{ "node": "Merge - Aguardar Memoria", "type": "main", "index": 0 }]] }, "Data Table - Buscar Memoria": { + "main": [[{ "node": "Merge - Aguardar Memoria", "type": "main", "index": 1 }]] + }, + "Merge - Aguardar Memoria": { "main": [[{ "node": "Code - Decidir Necessidade de IA", "type": "main", "index": 0 }]] }, "Code - Decidir Necessidade de IA": {