From 124516e64a38a3200797e4eb719d538d9a987b72 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 21:49:42 -0300 Subject: [PATCH] fix(v2): fix "Validar e executar" bug (real root cause), expand generic-phrase blacklist, add FORCE_SEND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause found for why the real Slack message showed "→ Validar e executar" for nearly every ACTIONABLE_NOW ticket: logic/message.js's ACTION_LABELS map had a hardcoded entry for ACTIONABLE_NOW that always won over the AI's actual next_action field in shortActionLabel(). The AI's real, specific, already-validated-as-non-generic next_action (e.g. "Realizar inventário das máquinas") was being silently discarded for display purposes on every single message. Fixed by removing the ACTIONABLE_NOW entry -- it now falls through to the existing next_action-based fallback (already there, just unreachable for that class before). UNASSIGNED_ACTIONABLE intentionally keeps its fixed "Atribuir técnico" label -- that IS the correct display for that class. logic/classification.js: expanded GENERIC_NEXT_ACTION_PHRASES with the exact phrase that leaked through ("validar e executar") plus the mission's additional list (validar chamado, prosseguir atendimento, executar chamado, tratar chamado, analisar chamado). Bumped AI_CLASSIFIER_VERSION 3->4 since this changes what next_action values validate as non-generic -- forces reclassification of any cached candidate that only passed under the older, narrower blacklist. n8n/nodes/validate-ai-response.node.js: generic-next-action rejections now get their own distinct fallback_reason ('generic_or_unsupported_ next_action') instead of being lumped into 'invalid_json', matching the same pattern already used for the technician-vendor conflict. logic/slack.js + logic/config.js: added FORCE_SEND (default false). Manual-triggered runs now never send for real even with SEND_ENABLED= true in production, unless FORCE_SEND is also explicitly set -- protects against someone clicking "Execute Workflow" in production and accidentally re-sending today's digest. Added format-safety regression tests (no literal backslash-n, URL never bleeds into the next item's id, no markdown-wrapped URLs) -- verified these were never actually triggered in a real send (the reported corruption was n8n's raw JSON data-inspector view, not a delivered Slack message; both manual runs in question had shouldSend:false). 169/169 tests passing. Co-Authored-By: Claude Sonnet 5 --- logic/classification.js | 10 ++++-- logic/classification.test.js | 12 +++++++ logic/config.js | 5 +++ logic/message.js | 9 ++++- logic/message.test.js | 43 +++++++++++++++++++++++ logic/slack.js | 18 ++++++---- logic/slack.test.js | 22 ++++++++++++ n8n/deployed/coordenador-glpi-v2.n8n.json | 24 ++++++++----- n8n/nodes/validate-ai-response.node.js | 8 ++++- n8n/workflow.template.json | 6 ++++ 10 files changed, 138 insertions(+), 19 deletions(-) diff --git a/logic/classification.js b/logic/classification.js index 885eb9a..9ddaff6 100644 --- a/logic/classification.js +++ b/logic/classification.js @@ -7,7 +7,10 @@ const VALID_CLASSIFICATIONS = [ // Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de // validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO // chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao. -const AI_CLASSIFIER_VERSION = 3; +// v4: lista de frases genericas rejeitadas expandida ("Validar e executar" e variantes -- +// missao "correcao final", secao 5). Bump forca reclassificacao de qualquer candidato +// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases. +const AI_CLASSIFIER_VERSION = 4; // O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes // devolve a STRING "null" (ou variantes equivalentes) em vez do valor JSON null. Normaliza @@ -27,10 +30,13 @@ function normalizeNullableString(value) { // do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma // frase curta ("aguardar resposta") e legitima ali. const ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']); +// Lista expandida na missao "correcao final" (secao 5) -- "Validar e executar" era o rotulo +// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js). const GENERIC_NEXT_ACTION_PHRASES = new Set([ 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado', - 'analisar situacao', 'analisar a situacao', 'verificar chamado', 'verificar o chamado', + 'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado', 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado', + 'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado', ]); function normalizeForComparison(text) { diff --git a/logic/classification.test.js b/logic/classification.test.js index 43b102a..ba1d8d5 100644 --- a/logic/classification.test.js +++ b/logic/classification.test.js @@ -133,3 +133,15 @@ test('next_action curto tipo "aguardar resposta" e permitido em classes WAITING_ const result = validateAiClassification(validRaw({ classification: 'WAITING_REQUESTER', next_action: 'Aguardar resposta do cliente', actionable_today: false })); assert.equal(result.valid, true); }); + +test('"Validar e executar" e rejeitado -- era o rotulo generico que apareceu em quase toda mensagem real (missao "correcao final" secao 5)', () => { + const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Validar e executar' })); + assert.equal(result.valid, false); +}); + +test('demais frases genericas da lista expandida sao rejeitadas: "Prosseguir atendimento", "Executar chamado", "Tratar chamado", "Validar chamado", "Analisar chamado"', () => { + for (const frase of ['Prosseguir atendimento', 'Executar chamado', 'Tratar chamado', 'Validar chamado', 'Analisar chamado']) { + const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: frase })); + assert.equal(result.valid, false, `"${frase}" deveria ser rejeitada`); + } +}); diff --git a/logic/config.js b/logic/config.js index 60cab4b..ada71ca 100644 --- a/logic/config.js +++ b/logic/config.js @@ -33,6 +33,11 @@ function parseConfig(env) { // explicitamente no Config node manda mensagem de verdade. environment: e.ENVIRONMENT || 'dev', sendEnabled: toBool(e.SEND_ENABLED, false), + // So importa pra execucoes 'manual' -- protege contra alguem clicar "Execute Workflow" em + // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca + // olham esse campo. Default false: manual nunca envia de verdade sem override explicito + // (missao "correcao final", secao 16/17). + forceSend: toBool(e.FORCE_SEND, false), }; } diff --git a/logic/message.js b/logic/message.js index 1cb6240..3c9c043 100644 --- a/logic/message.js +++ b/logic/message.js @@ -1,6 +1,13 @@ +// BUG REAL (missao "correcao final", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui +// ("Validar e executar") que sempre vencia o next_action de verdade da IA -- por isso TODA +// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel, +// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js) +// um next_action especifico como "Realizar inventario das maquinas". Removido daqui: agora +// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado). +// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso "atribuir +// tecnico" E a acao real, independente do que o next_action interno da IA descreva. const ACTION_LABELS = { UNASSIGNED_ACTIONABLE: 'Atribuir técnico', - ACTIONABLE_NOW: 'Validar e executar', WAITING_APPROVAL: 'Cobrar aprovação', INSUFFICIENT_CONTEXT: 'Completar informação', }; diff --git a/logic/message.test.js b/logic/message.test.js index 8afb63b..7239606 100644 --- a/logic/message.test.js +++ b/logic/message.test.js @@ -50,6 +50,49 @@ test('digest de coordenacao lista apenas os itens passados (novos/alterados)', ( assert.ok(digest.includes('#37691')); }); +test('BUG REAL corrigido: ACTIONABLE_NOW mostra o next_action real da IA, nao o rotulo fixo "Validar e executar" -- missao "correcao final" secao 2/5', () => { + const item = selectedItem({ + aiClassification: { classification: 'ACTIONABLE_NOW', next_action: 'Realizar inventário das máquinas', is_fallback: false }, + }); + const msg = buildTopMessage([item], '24/08', {}); + assert.ok(msg.includes('Realizar inventário das máquinas')); + assert.ok(!msg.includes('Validar e executar')); +}); + +test('UNASSIGNED_ACTIONABLE continua mostrando "Atribuir técnico" (rotulo fixo correto, nao e o bug)', () => { + const item = selectedItem({ + aiClassification: { classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Levar patch cord e etiquetar', is_fallback: false }, + }); + const msg = buildTopMessage([item], '24/08', {}); + assert.ok(msg.includes('Atribuir técnico')); +}); + +test('mensagem nunca contem a sequencia literal de dois caracteres barra-n (bug reportado: \\\\n aparecendo como texto) -- teste obrigatorio #1', () => { + const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({ + ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Chamado ${i}` }, + aiClassification: { classification: 'ACTIONABLE_NOW', next_action: `Diagnosticar item ${i}`, is_fallback: false }, + })); + const msg = buildTopMessage(dezItens, '24/08', {}); + assert.ok(!msg.includes('\\n'), 'mensagem nao pode conter a sequencia literal barra-n'); +}); + +test('cada URL termina exatamente no ticket_id -- o proximo item nunca vaza pra dentro do link anterior -- teste obrigatorio #3', () => { + const dois = [ + selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38413, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413' } }), + selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38477, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477' } }), + ]; + const msg = buildTopMessage(dois, '24/08', {}); + assert.ok(msg.includes('id=38413\n'), 'a URL do primeiro chamado deve terminar em id=38413 seguido de quebra de linha real, nao emendar no proximo item'); + assert.ok(!/id=38413\d/.test(msg), 'nenhum digito deveria colar direto depois do id do primeiro chamado (URL corrompida)'); + assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413')); + assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477')); +}); + +test('URL fica pura (sem colchetes markdown [url](url)) -- Slack ja auto-linka texto puro -- teste obrigatorio #4', () => { + const msg = buildTopMessage([selectedItem({})], '24/08', {}); + assert.ok(!/\[https?:\/\//.test(msg), 'nao deveria embrulhar a URL em markdown [texto](url)'); +}); + test('mensagem com 10 chamados continua curta (teste obrigatorio #19 -- nao vira relatorio longo)', () => { const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Titulo do chamado numero ${i} com texto razoavelmente longo` }, diff --git a/logic/slack.js b/logic/slack.js index 48ac4e2..4197213 100644 --- a/logic/slack.js +++ b/logic/slack.js @@ -7,10 +7,15 @@ function computeMessageHash(text) { return djb2Hash(String(text || '')); } -// SEND_ENABLED e o unico gate de "isso pode realmente sair pro Slack" -- nunca depende de -// editar codigo (missao "finalizacao para producao", secao 22). -function shouldSendByConfig(config) { - return Boolean(config && config.sendEnabled); +// SEND_ENABLED e o gate principal de "isso pode realmente sair pro Slack" -- nunca depende de +// editar codigo (missao "finalizacao para producao", secao 22). Runs 'manual' tem uma segunda +// trava: mesmo com SEND_ENABLED=true (producao), clicar "Execute Workflow" NAO envia de +// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por +// alguem testando/depurando em producao (missao "correcao final", secao 16/17). +function shouldSendByConfig(config, runType) { + if (!config || !config.sendEnabled) return false; + if (runType === 'manual' && !config.forceSend) return false; + return true; } // Idempotencia: so conta como "ja enviado" um registro com status 'success' para a MESMA @@ -29,8 +34,9 @@ function alreadySentSuccessfully(existingRecords, { reportDate, runType, message // Decisao unica de "devo mandar pro Slack agora" -- centraliza os dois motivos possiveis de // NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel. function evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) { - if (!shouldSendByConfig(config)) { - return { shouldSend: false, reason: 'send_disabled' }; + if (!shouldSendByConfig(config, runType)) { + const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled'; + return { shouldSend: false, reason }; } if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) { return { shouldSend: false, reason: 'already_sent' }; diff --git a/logic/slack.test.js b/logic/slack.test.js index fc4164f..4a5dccc 100644 --- a/logic/slack.test.js +++ b/logic/slack.test.js @@ -56,6 +56,28 @@ test('evaluateSendGuard: envio duplicado e bloqueado -- teste obrigatorio #15', assert.equal(result.reason, 'already_sent'); }); +test('runType manual NAO envia mesmo com SEND_ENABLED=true, a menos que FORCE_SEND tambem esteja ligado -- missao "correcao final" secao 16', () => { + const result = evaluateSendGuard({ + config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc', + }); + assert.equal(result.shouldSend, false); + assert.equal(result.reason, 'manual_run_needs_force_send'); +}); + +test('runType manual envia quando FORCE_SEND esta explicitamente ligado -- missao "correcao final" secao 17', () => { + const result = evaluateSendGuard({ + config: { sendEnabled: true, forceSend: true }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc', + }); + assert.equal(result.shouldSend, true); +}); + +test('runType scheduled envia normalmente com SEND_ENABLED=true, independente de FORCE_SEND', () => { + const result = evaluateSendGuard({ + config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc', + }); + assert.equal(result.shouldSend, true); +}); + test('evaluateSendGuard: retry (mensagem diferente) apos falha anterior nao e bloqueado', () => { const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'failed' }]; const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: existing, reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }); diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json index 2b479ab..d31ec49 100644 --- a/n8n/deployed/coordenador-glpi-v2.n8n.json +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -139,6 +139,12 @@ "name": "SEND_ENABLED", "type": "boolean", "value": false + }, + { + "id": "cfg-force-send", + "name": "FORCE_SEND", + "type": "boolean", + "value": false } ] }, @@ -155,7 +161,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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\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.content AS descricao, 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, descricao, 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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\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.content AS descricao, 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, descricao, 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 } }];" } }, { @@ -301,7 +307,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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\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// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO\n// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe\n// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao\n// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem\n// precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\nfunction buildCacheKey(contextHash, classifierVersion) {\n return `${contextHash}:v${classifierVersion}`;\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\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\nconst AI_CLASSIFIER_VERSION = 3;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\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 // Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de\n // AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no\n // GLPI -- sem precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\n const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);\n const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });\n return {\n ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,\n };\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 cacheKey: item.cacheKey,\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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\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// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO\n// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe\n// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao\n// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem\n// precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\nfunction buildCacheKey(contextHash, classifierVersion) {\n return `${contextHash}:v${classifierVersion}`;\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\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\n// v4: lista de frases genericas rejeitadas expandida (\"Validar e executar\" e variantes --\n// missao \"correcao final\", secao 5). Bump forca reclassificacao de qualquer candidato\n// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases.\nconst AI_CLASSIFIER_VERSION = 4;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\n// Lista expandida na missao \"correcao final\" (secao 5) -- \"Validar e executar\" era o rotulo\n// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js).\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n 'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\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 // Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de\n // AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no\n // GLPI -- sem precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\n const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);\n const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });\n return {\n ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,\n };\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 cacheKey: item.cacheKey,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});" } }, { @@ -425,7 +431,7 @@ -400 ], "parameters": { - "jsCode": "const 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\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\nconst AI_CLASSIFIER_VERSION = 3;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\n}\n\n// AI_FALLBACK e um estado tecnico: significa \"a IA nunca analisou este chamado\" (erro,\n// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e\n// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar\n// (ver logic/classification.js). Politica de elegibilidade fica centralizada em\n// logic/cooldown.js (applyFallbackPolicy), nao aqui.\nconst AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: AI_FALLBACK_CLASSIFICATION,\n actionable_today: false,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente (nao analisado pela IA)',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nconst promptItems = $('Code - Preparar Prompt IA').all();\nconst responseItems = $input.all();\n\nreturn responseItems.map((responseItem, index) => {\n const original = (promptItems[index] && promptItems[index].json) || {};\n const httpJson = responseItem.json || {};\n\n if (httpJson.error) {\n return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };\n }\n\n let raw = null;\n try {\n const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;\n raw = content ? JSON.parse(content) : null;\n } catch (e) {\n raw = null;\n }\n\n // Passa o ticket pra validacao poder aplicar a guarda deterministica \"tecnico interno nunca\n // vira WAITING_VENDOR\" (logic/classification.js -> isWaitingForInternalTechnician). Sem\n // isso a validacao so olha o JSON cru, nunca os fatos do proprio chamado.\n const validation = validateAiClassification(raw, original.ticket);\n let aiClassification;\n if (validation.valid) {\n aiClassification = validation.data;\n } else {\n const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno'));\n aiClassification = buildFallbackClassification(isSemanticConflict ? 'ai_semantic_conflict' : 'invalid_json');\n }\n\n return { json: { ...original, aiClassification } };\n});" + "jsCode": "const 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\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\n// v4: lista de frases genericas rejeitadas expandida (\"Validar e executar\" e variantes --\n// missao \"correcao final\", secao 5). Bump forca reclassificacao de qualquer candidato\n// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases.\nconst AI_CLASSIFIER_VERSION = 4;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\n// Lista expandida na missao \"correcao final\" (secao 5) -- \"Validar e executar\" era o rotulo\n// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js).\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n 'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\n}\n\n// AI_FALLBACK e um estado tecnico: significa \"a IA nunca analisou este chamado\" (erro,\n// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e\n// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar\n// (ver logic/classification.js). Politica de elegibilidade fica centralizada em\n// logic/cooldown.js (applyFallbackPolicy), nao aqui.\nconst AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: AI_FALLBACK_CLASSIFICATION,\n actionable_today: false,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente (nao analisado pela IA)',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nconst promptItems = $('Code - Preparar Prompt IA').all();\nconst responseItems = $input.all();\n\nreturn responseItems.map((responseItem, index) => {\n const original = (promptItems[index] && promptItems[index].json) || {};\n const httpJson = responseItem.json || {};\n\n if (httpJson.error) {\n return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };\n }\n\n let raw = null;\n try {\n const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;\n raw = content ? JSON.parse(content) : null;\n } catch (e) {\n raw = null;\n }\n\n // Passa o ticket pra validacao poder aplicar a guarda deterministica \"tecnico interno nunca\n // vira WAITING_VENDOR\" (logic/classification.js -> isWaitingForInternalTechnician). Sem\n // isso a validacao so olha o JSON cru, nunca os fatos do proprio chamado.\n const validation = validateAiClassification(raw, original.ticket);\n let aiClassification;\n if (validation.valid) {\n aiClassification = validation.data;\n } else {\n // Motivo especifico e auditavel por que a IA caiu pra fallback -- nao joga tudo em\n // \"invalid_json\" (missao \"correcao final\", secao 9: decision_reason distinguivel).\n const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno'));\n const isGenericAction = validation.errors.some((e) => e.includes('generico demais'));\n let reason = 'invalid_json';\n if (isSemanticConflict) reason = 'ai_semantic_conflict';\n else if (isGenericAction) reason = 'generic_or_unsupported_next_action';\n aiClassification = buildFallbackClassification(reason);\n }\n\n return { json: { ...original, aiClassification } };\n});" } }, { @@ -552,7 +558,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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\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 decisionReason: data.scoreResult.decisionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n // 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir\n // Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas\n // de IA reais), senao se perde depois deste node.\n resolucao: data.resolucao,\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];" + "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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\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 decisionReason: data.scoreResult.decisionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n // 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir\n // Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas\n // de IA reais), senao se perde depois deste node.\n resolucao: data.resolucao,\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];" } }, { @@ -619,7 +625,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": "// BUG REAL (missao \"correcao final\", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui\n// (\"Validar e executar\") que sempre vencia o next_action de verdade da IA -- por isso TODA\n// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel,\n// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js)\n// um next_action especifico como \"Realizar inventario das maquinas\". Removido daqui: agora\n// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado).\n// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso \"atribuir\n// tecnico\" E a acao real, independente do que o next_action interno da IA descreva.\nconst ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\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 } }];" } }, { @@ -632,7 +638,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": "// BUG REAL (missao \"correcao final\", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui\n// (\"Validar e executar\") que sempre vencia o next_action de verdade da IA -- por isso TODA\n// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel,\n// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js)\n// um next_action especifico como \"Realizar inventario das maquinas\". Removido daqui: agora\n// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado).\n// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso \"atribuir\n// tecnico\" E a acao real, independente do que o next_action interno da IA descreva.\nconst ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\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 } }];" } }, { @@ -728,7 +734,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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\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\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o unico gate de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22).\nfunction shouldSendByConfig(config) {\n return Boolean(config && config.sendEnabled);\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config)) {\n return { shouldSend: false, reason: 'send_disabled' };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// Roda depois de \"Data Table - Buscar Envios Recentes\" (fetch por report_date=hoje), que existe\n// so pra trazer os registros de envio ja existentes; os dados do digest sao lidos por nome\n// (mesmo padrao ja usado em Code - Comparar Legacy x V2).\nconst digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }\nconst config = digestData.selected.length ? digestData.selected[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst existingRecords = $input.all().map((item) => item.json);\n\n// $execution.mode: 'manual' quando disparado pelo Manual Trigger, senao ('trigger' no\n// Schedule Trigger) tratamos como execucao agendada. Nao inventa um mecanismo novo de deteccao.\nconst runType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst messageHash = computeMessageHash(digestData.mensagem);\nconst guard = evaluateSendGuard({\n config, existingRecords, reportDate: digestData.dateStr, runType, messageHash,\n});\n\nreturn [{\n json: {\n mensagem: digestData.mensagem,\n dateStr: digestData.dateStr,\n runType,\n messageHash,\n shouldSend: guard.shouldSend,\n reason: guard.reason,\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 // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\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 // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\n };\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\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o gate principal de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22). Runs 'manual' tem uma segunda\n// trava: mesmo com SEND_ENABLED=true (producao), clicar \"Execute Workflow\" NAO envia de\n// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por\n// alguem testando/depurando em producao (missao \"correcao final\", secao 16/17).\nfunction shouldSendByConfig(config, runType) {\n if (!config || !config.sendEnabled) return false;\n if (runType === 'manual' && !config.forceSend) return false;\n return true;\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config, runType)) {\n const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';\n return { shouldSend: false, reason };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// Roda depois de \"Data Table - Buscar Envios Recentes\" (fetch por report_date=hoje), que existe\n// so pra trazer os registros de envio ja existentes; os dados do digest sao lidos por nome\n// (mesmo padrao ja usado em Code - Comparar Legacy x V2).\nconst digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }\nconst config = digestData.selected.length ? digestData.selected[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst existingRecords = $input.all().map((item) => item.json);\n\n// $execution.mode: 'manual' quando disparado pelo Manual Trigger, senao ('trigger' no\n// Schedule Trigger) tratamos como execucao agendada. Nao inventa um mecanismo novo de deteccao.\nconst runType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst messageHash = computeMessageHash(digestData.mensagem);\nconst guard = evaluateSendGuard({\n config, existingRecords, reportDate: digestData.dateStr, runType, messageHash,\n});\n\nreturn [{\n json: {\n mensagem: digestData.mensagem,\n dateStr: digestData.dateStr,\n runType,\n messageHash,\n shouldSend: guard.shouldSend,\n reason: guard.reason,\n },\n}];" } }, { @@ -822,7 +828,7 @@ -500 ], "parameters": { - "jsCode": "function 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\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o unico gate de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22).\nfunction shouldSendByConfig(config) {\n return Boolean(config && config.sendEnabled);\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config)) {\n return { shouldSend: false, reason: 'send_disabled' };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de sucesso do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registrar sent_at/status so DEPOIS da confirmacao real do Slack -- nunca antes (missao\n// \"finalizacao para producao\", secao 19).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'success',\n});\n\nreturn [{ json: record }];" + "jsCode": "function 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\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o gate principal de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22). Runs 'manual' tem uma segunda\n// trava: mesmo com SEND_ENABLED=true (producao), clicar \"Execute Workflow\" NAO envia de\n// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por\n// alguem testando/depurando em producao (missao \"correcao final\", secao 16/17).\nfunction shouldSendByConfig(config, runType) {\n if (!config || !config.sendEnabled) return false;\n if (runType === 'manual' && !config.forceSend) return false;\n return true;\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config, runType)) {\n const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';\n return { shouldSend: false, reason };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de sucesso do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registrar sent_at/status so DEPOIS da confirmacao real do Slack -- nunca antes (missao\n// \"finalizacao para producao\", secao 19).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'success',\n});\n\nreturn [{ json: record }];" } }, { @@ -835,7 +841,7 @@ -340 ], "parameters": { - "jsCode": "function 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\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o unico gate de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22).\nfunction shouldSendByConfig(config) {\n return Boolean(config && config.sendEnabled);\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config)) {\n return { shouldSend: false, reason: 'send_disabled' };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de erro do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registra status=failed com o erro real ANTES de propagar a falha (proximo node lanca o\n// erro de verdade, pra execucao ficar visivelmente vermelha no n8n -- nao silenciar).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst errorItem = $input.first().json || {};\nconst errorMessage = String(errorItem.error || errorItem.message || 'Erro desconhecido ao enviar mensagem ao Slack').slice(0, 500);\n\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'failed',\n errorDetail: errorMessage,\n});\n\nreturn [{ json: record }];" + "jsCode": "function 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\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o gate principal de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22). Runs 'manual' tem uma segunda\n// trava: mesmo com SEND_ENABLED=true (producao), clicar \"Execute Workflow\" NAO envia de\n// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por\n// alguem testando/depurando em producao (missao \"correcao final\", secao 16/17).\nfunction shouldSendByConfig(config, runType) {\n if (!config || !config.sendEnabled) return false;\n if (runType === 'manual' && !config.forceSend) return false;\n return true;\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config, runType)) {\n const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';\n return { shouldSend: false, reason };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de erro do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registra status=failed com o erro real ANTES de propagar a falha (proximo node lanca o\n// erro de verdade, pra execucao ficar visivelmente vermelha no n8n -- nao silenciar).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst errorItem = $input.first().json || {};\nconst errorMessage = String(errorItem.error || errorItem.message || 'Erro desconhecido ao enviar mensagem ao Slack').slice(0, 500);\n\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'failed',\n errorDetail: errorMessage,\n});\n\nreturn [{ json: record }];" } }, { diff --git a/n8n/nodes/validate-ai-response.node.js b/n8n/nodes/validate-ai-response.node.js index 53383a5..14164c0 100644 --- a/n8n/nodes/validate-ai-response.node.js +++ b/n8n/nodes/validate-ai-response.node.js @@ -26,8 +26,14 @@ return responseItems.map((responseItem, index) => { if (validation.valid) { aiClassification = validation.data; } else { + // Motivo especifico e auditavel por que a IA caiu pra fallback -- nao joga tudo em + // "invalid_json" (missao "correcao final", secao 9: decision_reason distinguivel). const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno')); - aiClassification = buildFallbackClassification(isSemanticConflict ? 'ai_semantic_conflict' : 'invalid_json'); + const isGenericAction = validation.errors.some((e) => e.includes('generico demais')); + let reason = 'invalid_json'; + if (isSemanticConflict) reason = 'ai_semantic_conflict'; + else if (isGenericAction) reason = 'generic_or_unsupported_next_action'; + aiClassification = buildFallbackClassification(reason); } return { json: { ...original, aiClassification } }; diff --git a/n8n/workflow.template.json b/n8n/workflow.template.json index a88a31c..9d8f8d3 100644 --- a/n8n/workflow.template.json +++ b/n8n/workflow.template.json @@ -139,6 +139,12 @@ "name": "SEND_ENABLED", "type": "boolean", "value": false + }, + { + "id": "cfg-force-send", + "name": "FORCE_SEND", + "type": "boolean", + "value": false } ] },