diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json index f76e518..507e24f 100644 --- a/n8n/deployed/coordenador-glpi-v2.n8n.json +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -14,7 +14,7 @@ }, { "id": "schedule-trigger-v2-desligado", - "name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)", + "name": "Schedule Trigger - Dias uteis 08h", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [ @@ -78,7 +78,7 @@ "id": "cfg-ai-max-candidates", "name": "AI_MAX_CANDIDATES", "type": "number", - "value": 30 + "value": 60 }, { "id": "cfg-top-n", @@ -127,6 +127,18 @@ "name": "HORAS_SLA_RISCO", "type": "number", "value": 8 + }, + { + "id": "cfg-environment", + "name": "ENVIRONMENT", + "type": "string", + "value": "dev" + }, + { + "id": "cfg-send-enabled", + "name": "SEND_ENABLED", + "type": "boolean", + "value": false } ] }, @@ -143,7 +155,7 @@ -160 ], "parameters": { - "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\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 };\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 } }];" } }, { @@ -289,7 +301,7 @@ -160 ], "parameters": { - "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst now = Date.now();\n\n// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a\n// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.\nconst items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n const analysis = needsReanalysis({ memory, currentHash: item.contextHash, now });\n return { ticket: item.ticket, contextHash: item.contextHash, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});" + "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});" } }, { @@ -370,7 +382,7 @@ -400 ], "parameters": { - "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nconst systemPrompt = [\n 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',\n 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',\n 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',\n 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',\n 'Idade do chamado sozinha NAO e evidencia de acionabilidade.',\n 'Identifique: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos que justifique a conclusao?',\n 'Se a informacao for insuficiente para decidir com confianca, use a classificacao INSUFFICIENT_CONTEXT e confidence baixo -- nao invente uma acao.',\n `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas).`,\n].join('\\n');\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);\n\n const requestBody = {\n model: data.config.aiModel,\n temperature: 0.1,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'ticket_classification',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n classification: { type: 'string', enum: VALID_CLASSIFICATIONS },\n actionable_today: { type: 'boolean' },\n confidence: { type: 'number' },\n blocker_type: { type: ['string', 'null'] },\n waiting_for: { type: ['string', 'null'] },\n next_action: { type: 'string' },\n suggested_action_type: { type: 'string' },\n business_risk: { type: 'string' },\n reason: { type: 'string' },\n evidence: { type: 'array', items: { type: 'string' } },\n },\n required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],\n },\n },\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n return { json: { ...data, requestBody } };\n});" + "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nconst systemPrompt = [\n 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',\n 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',\n 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',\n 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',\n 'Idade do chamado sozinha NAO e evidencia de acionabilidade.',\n '',\n '=== O QUE SIGNIFICA ACIONAVEL (ACTIONABLE_NOW / UNASSIGNED_ACTIONABLE) ===',\n 'ACTIONABLE_NOW significa: existe pelo menos uma acao concreta que um integrante do suporte pode executar AGORA para fazer o chamado avancar. NAO precisa significar que o chamado sera resolvido integralmente hoje -- precisa significar que o suporte consegue fazer algo util agora.',\n 'Isso INCLUI, entre outras: executar diagnostico; testar equipamento; verificar configuracao; solicitar informacao ao cliente (quando ainda NAO foi solicitada); entrar em contato com fornecedor (quando ainda NAO foi contatado); marcar/agendar visita; combinar horario; atribuir tecnico; realizar inventario; validar cenario; executar instalacao; iniciar migracao; cobrar informacao que ainda NAO foi solicitada; atualizar configuracao; reproduzir problema; levantar requisitos; ou qualquer outro proximo passo concreto sob controle do suporte.',\n '',\n '=== CONTATAR != AGUARDAR (diferenca critica) ===',\n 'Precisar contatar alguem (cliente ou fornecedor) que AINDA NAO foi contatado e ACIONAVEL AGORA: ex. \"Precisamos perguntar ao cliente qual horario pode realizar a manutencao\" -> ACTIONABLE_NOW, next_action \"Contatar cliente para combinar horario\". \"Precisamos pedir orcamento ao fornecedor\" -> ACTIONABLE_NOW.',\n 'Ja ter contatado e estar esperando resposta e WAITING_*: ex. \"Cliente ja foi contatado em 23/08 e estamos aguardando resposta\" -> WAITING_REQUESTER. \"Orcamento ja solicitado; aguardando retorno do fornecedor\" -> WAITING_VENDOR.',\n 'So classifique WAITING_REQUESTER, WAITING_VENDOR ou WAITING_APPROVAL quando existir evidencia POSITIVA no historico de que (1) a acao necessaria ja foi executada pelo suporte E (2) existe uma dependencia externa ainda aberta. Sem essa evidencia dupla, NAO assuma espera -- procure se existe uma acao ainda nao realizada primeiro.',\n '',\n '=== TECNICO INTERNO NAO E FORNECEDOR ===',\n 'O campo TECNICO do chamado lista responsaveis/tecnicos internos da equipe de suporte. Se uma pendencia depende de alguem listado nesse campo, NUNCA classifique como WAITING_VENDOR (fornecedor externo) -- normalmente e ACTIONABLE_NOW (se o proprio suporte pode agir) ou UNASSIGNED_ACTIONABLE (se depende de atribuicao). WAITING_VENDOR e reservado para fornecedores/terceiros genuinamente externos a equipe.',\n '',\n '=== STATUS \"PLANEJADO\" NAO SIGNIFICA BLOQUEADO ===',\n 'STATUS Planejado sozinho NAO significa WAITING_REQUESTER, WAITING_VENDOR nem SCHEDULED. Se existe tarefa ou apontamento descrevendo um passo operacional disponivel agora, o chamado pode ser ACTIONABLE_NOW mesmo estando \"Planejado\".',\n 'Use SCHEDULED apenas quando existir data, horario, janela ou evento futuro explicito no chamado (ou evidencia inequivoca de que nenhuma acao pode/deve ocorrer antes dessa data).',\n '',\n '=== CRONOLOGIA E OBRIGATORIA ===',\n 'Interprete os apontamentos em ordem temporal -- o mais recente relevante prevalece sobre um bloqueio antigo ja superado. Exemplo: \"20/08 aguardando fornecedor\" seguido de \"22/08 pecas chegaram\" seguido de \"23/08 falta instalar\" -> ACTIONABLE_NOW (instalar pecas), NAO WAITING_VENDOR. Nao se prenda a um estado antigo se um apontamento mais novo o superou.',\n '',\n '=== NEXT_ACTION DEVE SER CONCRETO ===',\n 'Para ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE, next_action precisa nomear a acao real: \"Realizar inventario das maquinas\", \"Contatar cliente para combinar recolha\", \"Testar nobreak com carga\", \"Atribuir tecnico para visita\", \"Iniciar migracao dos e-mails\", \"Validar configuracao da impressora\", \"Solicitar dados de acesso ao cliente\".',\n 'NUNCA aceite como next_action frases genericas que nao provam acionabilidade nenhuma: \"Revisar chamado\", \"Dar andamento\", \"Analisar situacao\", \"Verificar chamado\", \"Acompanhar\". Se voce so consegue pensar numa frase generica dessas, reconsidere a classificacao -- provavelmente falta evidencia concreta.',\n '',\n '=== QUANDO USAR INSUFFICIENT_CONTEXT ===',\n 'Antes de classificar como INSUFFICIENT_CONTEXT, pergunte-se: existe alguma acao concreta que o suporte consegue executar mesmo sem informacao completa? Falta de diagnostico nao significa falta de acao -- ex. \"nao sabemos por que o notebook nao conecta\" ainda pode ser ACTIONABLE_NOW com next_action \"Realizar diagnostico de conectividade\".',\n 'Use INSUFFICIENT_CONTEXT somente quando realmente nao for possivel determinar um proximo passo razoavelmente especifico -- nem mesmo um passo de diagnostico/investigacao.',\n '',\n 'Identifique sempre: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio (interno ou externo)? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos, em ordem cronologica, que justifique a conclusao?',\n `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo, deve ser uma acao concreta quando ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas, em ordem cronologica quando relevante).`,\n].join('\\n');\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);\n\n const requestBody = {\n model: data.config.aiModel,\n temperature: 0.1,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'ticket_classification',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n classification: { type: 'string', enum: VALID_CLASSIFICATIONS },\n actionable_today: { type: 'boolean' },\n confidence: { type: 'number' },\n blocker_type: { type: ['string', 'null'] },\n waiting_for: { type: ['string', 'null'] },\n next_action: { type: 'string' },\n suggested_action_type: { type: 'string' },\n business_risk: { type: 'string' },\n reason: { type: 'string' },\n evidence: { type: 'array', items: { type: 'string' } },\n },\n required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],\n },\n },\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n return { json: { ...data, requestBody } };\n});" } }, { @@ -413,7 +425,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// 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\nfunction validateAiClassification(raw) {\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) errors.push('next_action invalido (vazio ou muito longo)');\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 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: normalizeNullableString(raw.waiting_for),\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 },\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 const validation = validateAiClassification(raw);\n const aiClassification = validation.valid ? validation.data : buildFallbackClassification('invalid_json');\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.\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});" } }, { @@ -466,7 +478,7 @@ -160 ], "parameters": { - "jsCode": "const ONE_DAY_MS = 24 * 60 * 60 * 1000;\n\nconst BLOCKING_CLASSIFICATIONS = new Set([\n 'WAITING_REQUESTER', 'WAITING_VENDOR', 'SCHEDULED', 'DEPENDENCY_BLOCKED',\n]);\n\n// Regra centralizada de \"isso e critico o bastante pra furar uma exclusao normal\" --\n// usada tanto pelo cooldown de reenvio quanto pela politica de AI_FALLBACK/\n// INSUFFICIENT_CONTEXT (applyFallbackPolicy abaixo). Unico lugar que define \"critico\".\nfunction isCriticalOverride(ticket) {\n const prioridadeAlta = Number(ticket.prioridade) >= 4 || Number(ticket.urgencia) >= 4;\n const slaVencidoCritico = Boolean(ticket.sla_vencido) && prioridadeAlta;\n const semTecnicoCritico = Boolean(ticket.sem_tecnico) && prioridadeAlta;\n return slaVencidoCritico || semTecnicoCritico;\n}\n\n// AI_FALLBACK (chamado nunca analisado pela IA de verdade) e INSUFFICIENT_CONTEXT\n// (analisado, mas sem evidencia suficiente) NAO disputam o Top10 normalmente -- so entram\n// via excecao deterministica forte (isCriticalOverride). Centraliza a regra pedida:\n// \"AI_FALLBACK nao pode competir como se fosse acionavel confirmado\".\nfunction applyFallbackPolicy({ aiClassification, ticket }) {\n const critical = isCriticalOverride(ticket);\n\n if (aiClassification.is_fallback) {\n return critical\n ? { blocked: false, decisionReason: 'ai_fallback_critical_override' }\n : { blocked: true, decisionReason: 'ai_fallback_no_override' };\n }\n\n if (aiClassification.classification === 'INSUFFICIENT_CONTEXT') {\n return critical\n ? { blocked: false, decisionReason: 'insufficient_context_critical_override' }\n : { blocked: true, decisionReason: 'insufficient_context_no_override' };\n }\n\n return { blocked: false, decisionReason: null };\n}\n\nfunction computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {\n const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0;\n const lastSentAt = memory ? memory.last_sent_at : null;\n const nextReviewAt = memory ? memory.next_review_at : null;\n const critical = isCriticalOverride(ticket);\n\n let blocked = false;\n let penalty = 0;\n let reason = 'ok';\n let breakCooldown = false;\n\n if (classification === 'NEEDS_COORDINATION') {\n blocked = true;\n reason = 'needs_coordination';\n } else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) {\n blocked = true;\n reason = 'waiting_next_review';\n } else if (BLOCKING_CLASSIFICATIONS.has(classification) && !contextChanged) {\n blocked = true;\n reason = `blocked_${classification.toLowerCase()}`;\n }\n\n const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5;\n if (!contextChanged && sentRecently && consecutiveDaysSent > 0) {\n const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15;\n penalty = Math.min(90, perDayBase * consecutiveDaysSent);\n if (!blocked) reason = 'repeat_no_change';\n }\n\n if (blocked && critical && classification !== 'NEEDS_COORDINATION') {\n breakCooldown = true;\n blocked = false;\n penalty = Math.round(penalty / 2);\n reason = `${reason}_override_critical`;\n }\n\n return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };\n}\n\nconst CLASSIFICATION_BASE_POINTS = {\n ACTIONABLE_NOW: 200,\n UNASSIGNED_ACTIONABLE: 180,\n WAITING_APPROVAL: 40,\n INSUFFICIENT_CONTEXT: 0,\n WAITING_REQUESTER: -150,\n WAITING_VENDOR: -150,\n DEPENDENCY_BLOCKED: -150,\n PROJECT_LONG_TERM: -120,\n DUPLICATE_OR_OBSOLETE: -200,\n SCHEDULED: -1000,\n NEEDS_COORDINATION: -1000,\n AI_FALLBACK: 0,\n};\n\nfunction computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {\n const classification = aiClassification.classification;\n const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });\n // AI_FALLBACK (nunca analisado) e INSUFFICIENT_CONTEXT (analisado, sem evidencia) nao\n // disputam o Top10 normalmente -- so entram por excecao deterministica forte, decidida em\n // um so lugar (logic/cooldown.js -> applyFallbackPolicy).\n const fallbackPolicy = applyFallbackPolicy({ aiClassification, ticket });\n\n const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;\n const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;\n\n let actionabilityPoints = 0;\n if (!aiClassification.is_fallback) {\n actionabilityPoints = CLASSIFICATION_BASE_POINTS[classification] ?? 0;\n if (actionabilityPoints > 0 && Number(aiClassification.confidence) < config.aiConfidenceThreshold) {\n actionabilityPoints /= 2;\n }\n }\n\n const scheduledNotDue = classification === 'SCHEDULED';\n const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue || fallbackPolicy.blocked;\n\n const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;\n\n let group = 'ranking_geral';\n const actionableIsh = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' || aiClassification.is_fallback;\n if (actionableIsh && ticket.sla_vencido && (ticket.prioridade >= 4 || ticket.urgencia >= 4)) group = 'critico_acionavel';\n else if (actionableIsh && ticket.sem_tecnico) group = 'sem_tecnico';\n else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';\n else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';\n\n let exclusionReason = null;\n if (excludedFromTop10) {\n if (scheduledNotDue) exclusionReason = 'scheduled_not_due';\n else if (classification === 'NEEDS_COORDINATION') exclusionReason = 'needs_coordination';\n else if (fallbackPolicy.blocked) exclusionReason = fallbackPolicy.decisionReason;\n else exclusionReason = cooldown.reason;\n }\n\n return {\n score: Math.round(total * 100) / 100,\n group,\n excludedFromTop10,\n exclusionReason,\n // decisionReason fica preenchido mesmo quando NAO excluido (ex.: *_critical_override),\n // pra ficar auditavel por que um AI_FALLBACK/INSUFFICIENT_CONTEXT furou a regra.\n decisionReason: fallbackPolicy.decisionReason,\n breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },\n cooldown,\n };\n}\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const contextChanged = data.resolucao !== 'cache';\n const result = computeScore({\n ticket: data.ticket,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextChanged,\n config: data.config,\n now: data.now,\n });\n return { json: { ...data, contextChanged, scoreResult: result } };\n});" + "jsCode": "const ONE_DAY_MS = 24 * 60 * 60 * 1000;\n\n// Classes que NUNCA disputam o Top10 operacional -- bloqueadas por definicao, nao por cache.\n// Lista expandida na missao \"finalizacao para producao\" (secao 10): WAITING_APPROVAL,\n// PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE entraram aqui (antes so tinham penalidade de\n// score, o que deixava WAITING_APPROVAL -- pontuacao base positiva -- competir de verdade).\nconst BLOCKING_CLASSIFICATIONS = new Set([\n 'WAITING_REQUESTER', 'WAITING_VENDOR', 'WAITING_APPROVAL', 'SCHEDULED',\n 'DEPENDENCY_BLOCKED', 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE',\n]);\n\n// Regra centralizada de \"isso e critico o bastante pra furar uma exclusao normal\" --\n// usada tanto pelo cooldown de reenvio quanto pela politica de AI_FALLBACK/\n// INSUFFICIENT_CONTEXT (applyFallbackPolicy abaixo). Unico lugar que define \"critico\".\nfunction isCriticalOverride(ticket) {\n const prioridadeAlta = Number(ticket.prioridade) >= 4 || Number(ticket.urgencia) >= 4;\n const slaVencidoCritico = Boolean(ticket.sla_vencido) && prioridadeAlta;\n const semTecnicoCritico = Boolean(ticket.sem_tecnico) && prioridadeAlta;\n return slaVencidoCritico || semTecnicoCritico;\n}\n\n// AI_FALLBACK (chamado nunca analisado pela IA de verdade) e INSUFFICIENT_CONTEXT\n// (analisado, mas sem evidencia suficiente) NAO disputam o Top10 normalmente -- so entram\n// via excecao deterministica forte (isCriticalOverride). Centraliza a regra pedida:\n// \"AI_FALLBACK nao pode competir como se fosse acionavel confirmado\".\nfunction applyFallbackPolicy({ aiClassification, ticket }) {\n const critical = isCriticalOverride(ticket);\n\n if (aiClassification.is_fallback) {\n return critical\n ? { blocked: false, decisionReason: 'ai_fallback_critical_override' }\n : { blocked: true, decisionReason: 'ai_fallback_no_override' };\n }\n\n if (aiClassification.classification === 'INSUFFICIENT_CONTEXT') {\n return critical\n ? { blocked: false, decisionReason: 'insufficient_context_critical_override' }\n : { blocked: true, decisionReason: 'insufficient_context_no_override' };\n }\n\n return { blocked: false, decisionReason: null };\n}\n\nfunction computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {\n const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0;\n const lastSentAt = memory ? memory.last_sent_at : null;\n const nextReviewAt = memory ? memory.next_review_at : null;\n const critical = isCriticalOverride(ticket);\n\n let blocked = false;\n let penalty = 0;\n let reason = 'ok';\n let breakCooldown = false;\n\n if (classification === 'NEEDS_COORDINATION') {\n blocked = true;\n reason = 'needs_coordination';\n } else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) {\n blocked = true;\n reason = 'waiting_next_review';\n } else if (BLOCKING_CLASSIFICATIONS.has(classification)) {\n // BUG REAL comprovado na Run A anterior: WAITING_VENDOR entrou no Top10 so porque o\n // contexto tinha mudado nesta execucao (contextChanged=true). contextChanged decide SE\n // reanalisamos (logic/cache.js) -- nunca decide se um chamado que CONTINUA classificado\n // numa classe bloqueada pode competir. Por isso este bloqueio e incondicional aqui: se a\n // classificacao atual (fresca ou cacheada, tanto faz) esta em BLOCKING_CLASSIFICATIONS,\n // fica fora do Top10, ponto. Missao \"finalizacao para producao\", secao 10.\n blocked = true;\n reason = `blocked_${classification.toLowerCase()}`;\n }\n\n const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5;\n if (!contextChanged && sentRecently && consecutiveDaysSent > 0) {\n const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15;\n penalty = Math.min(90, perDayBase * consecutiveDaysSent);\n if (!blocked) reason = 'repeat_no_change';\n }\n\n if (blocked && critical && classification !== 'NEEDS_COORDINATION') {\n breakCooldown = true;\n blocked = false;\n penalty = Math.round(penalty / 2);\n reason = `${reason}_override_critical`;\n }\n\n return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };\n}\n\nconst CLASSIFICATION_BASE_POINTS = {\n ACTIONABLE_NOW: 200,\n UNASSIGNED_ACTIONABLE: 180,\n WAITING_APPROVAL: 40,\n INSUFFICIENT_CONTEXT: 0,\n WAITING_REQUESTER: -150,\n WAITING_VENDOR: -150,\n DEPENDENCY_BLOCKED: -150,\n PROJECT_LONG_TERM: -120,\n DUPLICATE_OR_OBSOLETE: -200,\n SCHEDULED: -1000,\n NEEDS_COORDINATION: -1000,\n AI_FALLBACK: 0,\n};\n\nfunction computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {\n const classification = aiClassification.classification;\n const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });\n // AI_FALLBACK (nunca analisado) e INSUFFICIENT_CONTEXT (analisado, sem evidencia) nao\n // disputam o Top10 normalmente -- so entram por excecao deterministica forte, decidida em\n // um so lugar (logic/cooldown.js -> applyFallbackPolicy).\n const fallbackPolicy = applyFallbackPolicy({ aiClassification, ticket });\n\n const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;\n const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;\n\n let actionabilityPoints = 0;\n if (!aiClassification.is_fallback) {\n actionabilityPoints = CLASSIFICATION_BASE_POINTS[classification] ?? 0;\n if (actionabilityPoints > 0 && Number(aiClassification.confidence) < config.aiConfidenceThreshold) {\n actionabilityPoints /= 2;\n }\n }\n\n const scheduledNotDue = classification === 'SCHEDULED';\n const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue || fallbackPolicy.blocked;\n\n const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;\n\n let group = 'ranking_geral';\n const actionableIsh = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' || aiClassification.is_fallback;\n if (actionableIsh && ticket.sla_vencido && (ticket.prioridade >= 4 || ticket.urgencia >= 4)) group = 'critico_acionavel';\n else if (actionableIsh && ticket.sem_tecnico) group = 'sem_tecnico';\n else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';\n else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';\n\n let exclusionReason = null;\n if (excludedFromTop10) {\n if (scheduledNotDue) exclusionReason = 'scheduled_not_due';\n else if (classification === 'NEEDS_COORDINATION') exclusionReason = 'needs_coordination';\n else if (fallbackPolicy.blocked) exclusionReason = fallbackPolicy.decisionReason;\n else exclusionReason = cooldown.reason;\n }\n\n return {\n score: Math.round(total * 100) / 100,\n group,\n excludedFromTop10,\n exclusionReason,\n // decisionReason fica preenchido mesmo quando NAO excluido (ex.: *_critical_override),\n // pra ficar auditavel por que um AI_FALLBACK/INSUFFICIENT_CONTEXT furou a regra.\n decisionReason: fallbackPolicy.decisionReason,\n breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },\n cooldown,\n };\n}\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const contextChanged = data.resolucao !== 'cache';\n const result = computeScore({\n ticket: data.ticket,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextChanged,\n config: data.config,\n now: data.now,\n });\n return { json: { ...data, contextChanged, scoreResult: result } };\n});" } }, { @@ -479,7 +491,7 @@ 160 ], "parameters": { - "jsCode": "const items = $input.all().map((item) => item.json);\nconst ONE_DAY = 24 * 60 * 60 * 1000;\n\nreturn items.map((data) => {\n const cls = data.aiClassification.classification;\n let nextReviewAt = null;\n if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY;\n else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY;\n\n return {\n json: {\n ticket_id: data.ticket.id,\n last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now),\n last_context_hash: data.contextHash,\n last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),\n ai_classification: cls,\n ai_confidence: data.aiClassification.confidence,\n blocker_type: data.aiClassification.blocker_type,\n waiting_for: data.aiClassification.waiting_for,\n next_action: data.aiClassification.next_action,\n next_review_at: nextReviewAt,\n last_score: data.scoreResult.score,\n last_ai_reason: data.aiClassification.reason,\n updated_at: data.now,\n },\n };\n});" + "jsCode": "const items = $input.all().map((item) => item.json);\nconst ONE_DAY = 24 * 60 * 60 * 1000;\n\nreturn items.map((data) => {\n const cls = data.aiClassification.classification;\n let nextReviewAt = null;\n if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY;\n else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY;\n\n return {\n json: {\n ticket_id: data.ticket.id,\n last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now),\n // Guarda a chave composta (hash de conteudo + AI_CLASSIFIER_VERSION), nao o hash puro --\n // e o que faz o cache invalidar sozinho quando o prompt/classificador muda de versao,\n // sem precisar apagar a Data Table na mao. Ver logic/cache.js -> buildCacheKey.\n last_context_hash: data.cacheKey,\n last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),\n ai_classification: cls,\n ai_confidence: data.aiClassification.confidence,\n blocker_type: data.aiClassification.blocker_type,\n waiting_for: data.aiClassification.waiting_for,\n next_action: data.aiClassification.next_action,\n next_review_at: nextReviewAt,\n last_score: data.scoreResult.score,\n last_ai_reason: data.aiClassification.reason,\n updated_at: data.now,\n },\n };\n});" } }, { @@ -540,7 +552,7 @@ -320 ], "parameters": { - "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n 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 };\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];" } }, { @@ -553,7 +565,7 @@ -560 ], "parameters": { - "jsCode": "const ONE_DAY = 24 * 60 * 60 * 1000;\n\nreturn $input.all()\n .filter((item) => item.json._grupo === 'selected')\n .map((item) => {\n const data = item.json;\n const memory = data.memory;\n const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;\n const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;\n const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;\n const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;\n return {\n json: {\n ticket_id: data.ticket.id,\n last_sent_at: data.now,\n times_sent: timesSent,\n times_sent_30d: timesSent30d,\n consecutive_days_sent: consecutiveDaysSent,\n updated_at: data.now,\n },\n };\n });" + "jsCode": "const ONE_DAY = 24 * 60 * 60 * 1000;\n\n// So roda depois de \"Data Table - Registrar Envio Digest (Sucesso)\" -- ou seja, so depois do\n// Slack confirmar sucesso de verdade (missao \"finalizacao para producao\", secao 19). Os dados\n// do Top10 sao lidos por nome (nao por $input, que aqui e o registro do envio, nao os tickets).\nreturn $('Code - Selecionar Top 10').all()\n .filter((item) => item.json._grupo === 'selected')\n .map((item) => {\n const data = item.json;\n const memory = data.memory;\n const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;\n const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;\n const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;\n const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;\n return {\n json: {\n ticket_id: data.ticket.id,\n last_sent_at: data.now,\n times_sent: timesSent,\n times_sent_30d: timesSent30d,\n consecutive_days_sent: consecutiveDaysSent,\n updated_at: data.now,\n },\n };\n });" } }, { @@ -678,6 +690,197 @@ } } } + }, + { + "id": "datatable-buscar-envios-recentes", + "name": "Data Table - Buscar Envios Recentes", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 3200, + -320 + ], + "parameters": { + "operation": "get", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_envios_digest" + }, + "filters": { + "conditions": [ + { + "keyName": "report_date", + "condition": "eq", + "keyValue": "={{ $('Code - Montar Digest Coordenacao').first().json.dateStr }}" + } + ] + } + } + }, + { + "id": "code-guard-envio-slack", + "name": "Code - Guard de Envio Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3440, + -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}];" + } + }, + { + "id": "if-deve-enviar-slack", + "name": "IF - Deve Enviar Slack?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 3680, + -320 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "cond-deve-enviar", + "leftValue": "={{ $json.shouldSend }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "slack-enviar-mensagem", + "name": "Slack - Enviar Mensagem", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.5, + "position": [ + 3920, + -420 + ], + "onError": "continueErrorOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.SLACK_CHANNEL_TECNICO }}" + }, + "text": "={{ $json.mensagem }}", + "otherOptions": {} + }, + "credentials": { + "slackApi": { + "id": "ER8aVNhPGAN0H5Am", + "name": "Slack account" + } + } + }, + { + "id": "code-pular-envio-slack", + "name": "Code - Pular Envio Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3920, + -200 + ], + "parameters": { + "jsCode": "// Branch \"nao deve enviar\" do guard (SEND_ENABLED=false ou ja enviado com sucesso hoje).\n// Node terminal, so pra deixar visivel na execucao do n8n por que nao mandou nada -- sem\n// escrever em nenhuma Data Table (nao e uma falha, e uma decisao esperada).\nconst data = $input.first().json;\nreturn [{ json: { skipped: true, reason: data.reason, dateStr: data.dateStr, runType: data.runType } }];" + } + }, + { + "id": "code-registrar-envio-sucesso", + "name": "Code - Registrar Envio Sucesso", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4160, + -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 }];" + } + }, + { + "id": "code-registrar-envio-falha", + "name": "Code - Registrar Envio Falha", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4160, + -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 }];" + } + }, + { + "id": "datatable-registrar-envio-digest-sucesso", + "name": "Data Table - Registrar Envio Digest (Sucesso)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4400, + -500 + ], + "parameters": { + "operation": "insert", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_envios_digest" + } + } + }, + { + "id": "datatable-registrar-envio-digest-falha", + "name": "Data Table - Registrar Envio Digest (Falha)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4400, + -340 + ], + "parameters": { + "operation": "insert", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_envios_digest" + } + } + }, + { + "id": "code-lancar-erro-envio-slack", + "name": "Code - Lancar Erro Envio Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4640, + -340 + ], + "parameters": { + "jsCode": "// Roda depois de \"Data Table - Registrar Envio Digest (Falha)\" ja ter gravado o status=failed.\n// So agora propaga o erro de verdade, pra a execucao no n8n ficar visivelmente com status de\n// erro (missao \"finalizacao para producao\", secao 19: \"Nao silenciar erro\").\nconst registro = $input.first().json;\nthrow new Error(`Envio ao Slack falhou (report_date=${registro.report_date}, run_type=${registro.run_type}): ${registro.error_detail}`);\n// eslint-disable-next-line no-unreachable -- linha morta de proposito: o validador estatico do\n// n8n-mcp exige um \"return\" literal no texto do node (heuristica que nao reconhece um node cujo\n// unico proposito e lancar erro -- confirmado via validate_node, mesmo comportamento do falso\n// positivo do .join() ja documentado neste projeto). Nunca executa; o throw acima sempre sai primeiro.\nreturn [];" + } } ], "connections": { @@ -692,17 +895,6 @@ ] ] }, - "Schedule Trigger - Dias uteis 08h (desligado em DEV)": { - "main": [ - [ - { - "node": "Config - Coordenador GLPI v2", - "type": "main", - "index": 0 - } - ] - ] - }, "Config - Coordenador GLPI v2": { "main": [ [ @@ -970,11 +1162,6 @@ "node": "Code - Montar Mensagem Curta", "type": "main", "index": 0 - }, - { - "node": "Code - Preparar Registro de Envio", - "type": "main", - "index": 0 } ] ] @@ -1008,6 +1195,11 @@ "node": "Merge - Unir Shadow", "type": "main", "index": 0 + }, + { + "node": "Data Table - Buscar Envios Recentes", + "type": "main", + "index": 0 } ] ] @@ -1044,12 +1236,126 @@ } ] ] + }, + "Data Table - Buscar Envios Recentes": { + "main": [ + [ + { + "node": "Code - Guard de Envio Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard de Envio Slack": { + "main": [ + [ + { + "node": "IF - Deve Enviar Slack?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Deve Enviar Slack?": { + "main": [ + [ + { + "node": "Slack - Enviar Mensagem", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Pular Envio Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Slack - Enviar Mensagem": { + "main": [ + [ + { + "node": "Code - Registrar Envio Sucesso", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Registrar Envio Falha", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Registrar Envio Sucesso": { + "main": [ + [ + { + "node": "Data Table - Registrar Envio Digest (Sucesso)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Registrar Envio Digest (Sucesso)": { + "main": [ + [ + { + "node": "Code - Preparar Registro de Envio", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Registrar Envio Falha": { + "main": [ + [ + { + "node": "Data Table - Registrar Envio Digest (Falha)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Registrar Envio Digest (Falha)": { + "main": [ + [ + { + "node": "Code - Lancar Erro Envio Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger - Dias uteis 08h": { + "main": [ + [ + { + "node": "Config - Coordenador GLPI v2", + "type": "main", + "index": 0 + } + ] + ] } }, "pinData": {}, "active": false, "settings": { - "executionOrder": "v1" + "executionOrder": "v1", + "timezone": "America/Sao_Paulo" }, "versionId": "", "meta": { diff --git a/n8n/workflow.template.json b/n8n/workflow.template.json index 4d93bb8..2d74125 100644 --- a/n8n/workflow.template.json +++ b/n8n/workflow.template.json @@ -6,19 +6,28 @@ "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, - "position": [-1600, 0], + "position": [ + -1600, + 0 + ], "parameters": {} }, { "id": "schedule-trigger-v2-desligado", - "name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)", + "name": "Schedule Trigger - Dias uteis 08h", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, - "position": [-1600, 900], + "position": [ + -1600, + 900 + ], "parameters": { "rule": { "interval": [ - { "field": "cronExpression", "expression": "0 8 * * 1-5" } + { + "field": "cronExpression", + "expression": "0 8 * * 1-5" + } ] } } @@ -28,24 +37,109 @@ "name": "Config - Coordenador GLPI v2", "type": "n8n-nodes-base.set", "typeVersion": 3.5, - "position": [-1360, 0], + "position": [ + -1360, + 0 + ], "parameters": { "assignments": { "assignments": [ - { "id": "glpi-base-url-v2", "name": "glpi_base_url", "type": "string", "value": "https://glpi.gtigrupo.com.br" }, - { "id": "cfg-ai-enabled", "name": "AI_ENABLED", "type": "boolean", "value": true }, - { "id": "cfg-ai-provider", "name": "AI_PROVIDER", "type": "string", "value": "openai" }, - { "id": "cfg-ai-model", "name": "AI_MODEL", "type": "string", "value": "gpt-4o-mini" }, - { "id": "cfg-candidate-pool-size", "name": "CANDIDATE_POOL_SIZE", "type": "number", "value": 60 }, - { "id": "cfg-ai-max-candidates", "name": "AI_MAX_CANDIDATES", "type": "number", "value": 30 }, - { "id": "cfg-top-n", "name": "TOP_N", "type": "number", "value": 10 }, - { "id": "cfg-cooldown-days", "name": "COOLDOWN_DAYS", "type": "number", "value": 1 }, - { "id": "cfg-ai-confidence-threshold", "name": "AI_CONFIDENCE_THRESHOLD", "type": "number", "value": 0.6 }, - { "id": "cfg-ai-max-context-chars", "name": "AI_MAX_CONTEXT_CHARS", "type": "number", "value": 4000 }, - { "id": "cfg-dev-mode", "name": "DEV_MODE", "type": "boolean", "value": true }, - { "id": "cfg-shadow-mode", "name": "SHADOW_MODE", "type": "boolean", "value": true }, - { "id": "cfg-dias-sem-interacao-alerta", "name": "DIAS_SEM_INTERACAO_ALERTA", "type": "number", "value": 2 }, - { "id": "cfg-horas-sla-risco", "name": "HORAS_SLA_RISCO", "type": "number", "value": 8 } + { + "id": "glpi-base-url-v2", + "name": "glpi_base_url", + "type": "string", + "value": "https://glpi.gtigrupo.com.br" + }, + { + "id": "cfg-ai-enabled", + "name": "AI_ENABLED", + "type": "boolean", + "value": true + }, + { + "id": "cfg-ai-provider", + "name": "AI_PROVIDER", + "type": "string", + "value": "openai" + }, + { + "id": "cfg-ai-model", + "name": "AI_MODEL", + "type": "string", + "value": "gpt-4o-mini" + }, + { + "id": "cfg-candidate-pool-size", + "name": "CANDIDATE_POOL_SIZE", + "type": "number", + "value": 60 + }, + { + "id": "cfg-ai-max-candidates", + "name": "AI_MAX_CANDIDATES", + "type": "number", + "value": 60 + }, + { + "id": "cfg-top-n", + "name": "TOP_N", + "type": "number", + "value": 10 + }, + { + "id": "cfg-cooldown-days", + "name": "COOLDOWN_DAYS", + "type": "number", + "value": 1 + }, + { + "id": "cfg-ai-confidence-threshold", + "name": "AI_CONFIDENCE_THRESHOLD", + "type": "number", + "value": 0.6 + }, + { + "id": "cfg-ai-max-context-chars", + "name": "AI_MAX_CONTEXT_CHARS", + "type": "number", + "value": 4000 + }, + { + "id": "cfg-dev-mode", + "name": "DEV_MODE", + "type": "boolean", + "value": true + }, + { + "id": "cfg-shadow-mode", + "name": "SHADOW_MODE", + "type": "boolean", + "value": true + }, + { + "id": "cfg-dias-sem-interacao-alerta", + "name": "DIAS_SEM_INTERACAO_ALERTA", + "type": "number", + "value": 2 + }, + { + "id": "cfg-horas-sla-risco", + "name": "HORAS_SLA_RISCO", + "type": "number", + "value": 8 + }, + { + "id": "cfg-environment", + "name": "ENVIRONMENT", + "type": "string", + "value": "dev" + }, + { + "id": "cfg-send-enabled", + "name": "SEND_ENABLED", + "type": "boolean", + "value": false + } ] }, "options": {} @@ -56,76 +150,130 @@ "name": "Code - Montar SQL Candidatos", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [-1120, -160], - "parameters": { "jsCode": "__BUILD__:build-sql-candidatos.node.js" } + "position": [ + -1120, + -160 + ], + "parameters": { + "jsCode": "__BUILD__:build-sql-candidatos.node.js" + } }, { "id": "code-montar-sql-legado", "name": "Code - Montar SQL Legado", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [-1120, 900], - "parameters": { "jsCode": "__BUILD__:build-sql-legado.node.js" } + "position": [ + -1120, + 900 + ], + "parameters": { + "jsCode": "__BUILD__:build-sql-legado.node.js" + } }, { "id": "mysql-buscar-candidatos", "name": "MySQL - Buscar Candidatos", "type": "n8n-nodes-base.mySql", "typeVersion": 2.5, - "position": [-880, -160], - "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } + "position": [ + -880, + -160 + ], + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + } }, { "id": "mysql-legado-top10-shadow", "name": "MySQL - Legado Top 10 (Shadow)", "type": "n8n-nodes-base.mySql", "typeVersion": 2.5, - "position": [-880, 900], - "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } + "position": [ + -880, + 900 + ], + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + } }, { "id": "code-normalizar-candidatos-base", "name": "Code - Normalizar Candidatos Base", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [-640, -160], - "parameters": { "jsCode": "__BUILD__:normalize-candidates.node.js" } + "position": [ + -640, + -160 + ], + "parameters": { + "jsCode": "__BUILD__:normalize-candidates.node.js" + } }, { "id": "code-montar-sql-contexto", "name": "Code - Montar SQL Contexto", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [-400, -160], - "parameters": { "jsCode": "__BUILD__:build-sql-context.node.js" } + "position": [ + -400, + -160 + ], + "parameters": { + "jsCode": "__BUILD__:build-sql-context.node.js" + } }, { "id": "mysql-buscar-contexto", "name": "MySQL - Buscar Contexto", "type": "n8n-nodes-base.mySql", "typeVersion": 2.5, - "position": [-160, -160], - "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } + "position": [ + -160, + -160 + ], + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + } }, { "id": "code-mesclar-contexto-hash", "name": "Code - Mesclar Contexto e Calcular Hash", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [80, -160], - "parameters": { "jsCode": "__BUILD__:merge-context-hash.node.js" } + "position": [ + 80, + -160 + ], + "parameters": { + "jsCode": "__BUILD__:merge-context-hash.node.js" + } }, { "id": "datatable-buscar-memoria", "name": "Data Table - Buscar Memoria", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, - "position": [-1120, 300], + "position": [ + -1120, + 300 + ], "parameters": { "resource": "row", "operation": "get", - "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, - "filters": { "conditions": [] }, + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_memoria" + }, + "filters": { + "conditions": [] + }, "returnAll": true } }, @@ -134,32 +282,55 @@ "name": "Merge - Aguardar Memoria", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, - "position": [320, -160], - "parameters": { "mode": "append", "numberInputs": 2 } + "position": [ + 320, + -160 + ], + "parameters": { + "mode": "append", + "numberInputs": 2 + } }, { "id": "code-decidir-necessidade-ia", "name": "Code - Decidir Necessidade de IA", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [560, -160], - "parameters": { "jsCode": "__BUILD__:decide-needs-ai.node.js" } + "position": [ + 560, + -160 + ], + "parameters": { + "jsCode": "__BUILD__:decide-needs-ai.node.js" + } }, { "id": "if-precisa-ia", "name": "IF - Precisa IA?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, - "position": [800, -160], + "position": [ + 800, + -160 + ], "parameters": { "conditions": { - "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, "conditions": [ { "id": "cond-precisa-ia", "leftValue": "={{ $json.precisa_ia }}", "rightValue": true, - "operator": { "type": "boolean", "operation": "true", "singleValue": true } + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } } ], "combinator": "and" @@ -172,16 +343,28 @@ "name": "IF - IA Ligada?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, - "position": [1040, -320], + "position": [ + 1040, + -320 + ], "parameters": { "conditions": { - "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, "conditions": [ { "id": "cond-ia-ligada", "leftValue": "={{ $json.config.aiEnabled }}", "rightValue": true, - "operator": { "type": "boolean", "operation": "true", "singleValue": true } + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } } ], "combinator": "and" @@ -194,15 +377,23 @@ "name": "Code - Preparar Prompt IA", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1280, -400], - "parameters": { "jsCode": "__BUILD__:prepare-ai-prompt.node.js" } + "position": [ + 1280, + -400 + ], + "parameters": { + "jsCode": "__BUILD__:prepare-ai-prompt.node.js" + } }, { "id": "http-openai-classificar", "name": "HTTP - OpenAI Classificar Chamado", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, - "position": [1520, -400], + "position": [ + 1520, + -400 + ], "continueOnFail": true, "parameters": { "method": "POST", @@ -213,76 +404,126 @@ "contentType": "json", "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.requestBody) }}", - "options": { "timeout": 30000 } + "options": { + "timeout": 30000 + } }, - "credentials": { "openAiApi": { "id": "WUGriAn5FgCiLza2", "name": "OpenAI - Coordenador GLPI" } } + "credentials": { + "openAiApi": { + "id": "WUGriAn5FgCiLza2", + "name": "OpenAI - Coordenador GLPI" + } + } }, { "id": "code-validar-classificacao-ia", "name": "Code - Validar Classificacao IA", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1760, -400], - "parameters": { "jsCode": "__BUILD__:validate-ai-response.node.js" } + "position": [ + 1760, + -400 + ], + "parameters": { + "jsCode": "__BUILD__:validate-ai-response.node.js" + } }, { "id": "code-fallback-ia-desligada", "name": "Code - Fallback IA Desligada", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1280, -160], - "parameters": { "jsCode": "__BUILD__:ai-disabled-fallback.node.js" } + "position": [ + 1280, + -160 + ], + "parameters": { + "jsCode": "__BUILD__:ai-disabled-fallback.node.js" + } }, { "id": "code-reusar-classificacao-cache", "name": "Code - Reusar Classificacao em Cache", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1040, 80], - "parameters": { "jsCode": "__BUILD__:reuse-cached-classification.node.js" } + "position": [ + 1040, + 80 + ], + "parameters": { + "jsCode": "__BUILD__:reuse-cached-classification.node.js" + } }, { "id": "merge-consolidar-classificacoes", "name": "Merge - Consolidar Classificacoes", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, - "position": [2000, -160], - "parameters": { "mode": "append", "numberInputs": 3 } + "position": [ + 2000, + -160 + ], + "parameters": { + "mode": "append", + "numberInputs": 3 + } }, { "id": "code-calcular-score", "name": "Code - Calcular Score", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2240, -160], - "parameters": { "jsCode": "__BUILD__:compute-score.node.js" } + "position": [ + 2240, + -160 + ], + "parameters": { + "jsCode": "__BUILD__:compute-score.node.js" + } }, { "id": "code-preparar-registro-memoria-analise", "name": "Code - Preparar Registro Memoria Analise", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2480, 160], - "parameters": { "jsCode": "__BUILD__:prepare-memoria-analise-record.node.js" } + "position": [ + 2480, + 160 + ], + "parameters": { + "jsCode": "__BUILD__:prepare-memoria-analise-record.node.js" + } }, { "id": "datatable-upsert-memoria-analise", "name": "Data Table - Upsert Memoria Analise", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, - "position": [2720, 160], + "position": [ + 2720, + 160 + ], "parameters": { "resource": "row", "operation": "upsert", - "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_memoria" + }, "filters": { "conditions": [ - { "keyName": "ticket_id", "condition": "eq", "keyValue": "={{ $json.ticket_id }}" } + { + "keyName": "ticket_id", + "condition": "eq", + "keyValue": "={{ $json.ticket_id }}" + } ] }, "columns": { "mappingMode": "defineBelow", - "matchingColumns": ["ticket_id"], + "matchingColumns": [ + "ticket_id" + ], "value": { "ticket_id": "={{ $json.ticket_id }}", "last_analyzed_at": "={{ $json.last_analyzed_at }}", @@ -306,35 +547,57 @@ "name": "Code - Selecionar Top 10", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2480, -320], - "parameters": { "jsCode": "__BUILD__:select-top10.node.js" } + "position": [ + 2480, + -320 + ], + "parameters": { + "jsCode": "__BUILD__:select-top10.node.js" + } }, { "id": "code-preparar-registro-envio", "name": "Code - Preparar Registro de Envio", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2720, -560], - "parameters": { "jsCode": "__BUILD__:prepare-envio-record.node.js" } + "position": [ + 2720, + -560 + ], + "parameters": { + "jsCode": "__BUILD__:prepare-envio-record.node.js" + } }, { "id": "datatable-registrar-envio", "name": "Data Table - Registrar Envio", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, - "position": [2960, -560], + "position": [ + 2960, + -560 + ], "parameters": { "resource": "row", "operation": "upsert", - "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_memoria" + }, "filters": { "conditions": [ - { "keyName": "ticket_id", "condition": "eq", "keyValue": "={{ $json.ticket_id }}" } + { + "keyName": "ticket_id", + "condition": "eq", + "keyValue": "={{ $json.ticket_id }}" + } ] }, "columns": { "mappingMode": "defineBelow", - "matchingColumns": ["ticket_id"], + "matchingColumns": [ + "ticket_id" + ], "value": { "ticket_id": "={{ $json.ticket_id }}", "last_sent_at": "={{ $json.last_sent_at }}", @@ -351,43 +614,70 @@ "name": "Code - Montar Mensagem Curta", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2720, -320], - "parameters": { "jsCode": "__BUILD__:build-top-message.node.js" } + "position": [ + 2720, + -320 + ], + "parameters": { + "jsCode": "__BUILD__:build-top-message.node.js" + } }, { "id": "code-montar-digest-coordenacao", "name": "Code - Montar Digest Coordenacao", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2960, -320], - "parameters": { "jsCode": "__BUILD__:build-coordination-digest.node.js" } + "position": [ + 2960, + -320 + ], + "parameters": { + "jsCode": "__BUILD__:build-coordination-digest.node.js" + } }, { "id": "merge-unir-shadow", "name": "Merge - Unir Shadow", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, - "position": [3200, 160], - "parameters": { "mode": "append", "numberInputs": 2 } + "position": [ + 3200, + 160 + ], + "parameters": { + "mode": "append", + "numberInputs": 2 + } }, { "id": "code-comparar-legacy-v2", "name": "Code - Comparar Legacy x V2", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [3440, 160], - "parameters": { "jsCode": "__BUILD__:compare-legacy-v2.node.js" } + "position": [ + 3440, + 160 + ], + "parameters": { + "jsCode": "__BUILD__:compare-legacy-v2.node.js" + } }, { "id": "datatable-registrar-shadow-run", "name": "Data Table - Registrar Shadow Run", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, - "position": [3680, 160], + "position": [ + 3680, + 160 + ], "parameters": { "resource": "row", "operation": "insert", - "dataTableId": { "mode": "name", "value": "coordenador_glpi_shadow_runs" }, + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_shadow_runs" + }, "columns": { "mappingMode": "defineBelow", "value": { @@ -400,120 +690,672 @@ } } } + }, + { + "id": "datatable-buscar-envios-recentes", + "name": "Data Table - Buscar Envios Recentes", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 3200, + -320 + ], + "parameters": { + "operation": "get", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_envios_digest" + }, + "filters": { + "conditions": [ + { + "keyName": "report_date", + "condition": "eq", + "keyValue": "={{ $('Code - Montar Digest Coordenacao').first().json.dateStr }}" + } + ] + } + } + }, + { + "id": "code-guard-envio-slack", + "name": "Code - Guard de Envio Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3440, + -320 + ], + "parameters": { + "jsCode": "__BUILD__:guard-envio-slack.node.js" + } + }, + { + "id": "if-deve-enviar-slack", + "name": "IF - Deve Enviar Slack?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 3680, + -320 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "cond-deve-enviar", + "leftValue": "={{ $json.shouldSend }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "slack-enviar-mensagem", + "name": "Slack - Enviar Mensagem", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.5, + "position": [ + 3920, + -420 + ], + "onError": "continueErrorOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.SLACK_CHANNEL_TECNICO }}" + }, + "text": "={{ $json.mensagem }}", + "otherOptions": {} + }, + "credentials": { + "slackApi": { + "id": "ER8aVNhPGAN0H5Am", + "name": "Slack account" + } + } + }, + { + "id": "code-pular-envio-slack", + "name": "Code - Pular Envio Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3920, + -200 + ], + "parameters": { + "jsCode": "__BUILD__:pular-envio.node.js" + } + }, + { + "id": "code-registrar-envio-sucesso", + "name": "Code - Registrar Envio Sucesso", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4160, + -500 + ], + "parameters": { + "jsCode": "__BUILD__:registrar-envio-sucesso.node.js" + } + }, + { + "id": "code-registrar-envio-falha", + "name": "Code - Registrar Envio Falha", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4160, + -340 + ], + "parameters": { + "jsCode": "__BUILD__:registrar-envio-falha.node.js" + } + }, + { + "id": "datatable-registrar-envio-digest-sucesso", + "name": "Data Table - Registrar Envio Digest (Sucesso)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4400, + -500 + ], + "parameters": { + "operation": "insert", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_envios_digest" + } + } + }, + { + "id": "datatable-registrar-envio-digest-falha", + "name": "Data Table - Registrar Envio Digest (Falha)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4400, + -340 + ], + "parameters": { + "operation": "insert", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_envios_digest" + } + } + }, + { + "id": "code-lancar-erro-envio-slack", + "name": "Code - Lancar Erro Envio Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4640, + -340 + ], + "parameters": { + "jsCode": "__BUILD__:lancar-erro-envio.node.js" + } } ], "connections": { "Manual Trigger": { - "main": [[{ "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 }]] - }, - "Schedule Trigger - Dias uteis 08h (desligado em DEV)": { - "main": [[{ "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Config - Coordenador GLPI v2", + "type": "main", + "index": 0 + } + ] + ] }, "Config - Coordenador GLPI v2": { - "main": [[ - { "node": "Code - Montar SQL Candidatos", "type": "main", "index": 0 }, - { "node": "Code - Montar SQL Legado", "type": "main", "index": 0 }, - { "node": "Data Table - Buscar Memoria", "type": "main", "index": 0 } - ]] + "main": [ + [ + { + "node": "Code - Montar SQL Candidatos", + "type": "main", + "index": 0 + }, + { + "node": "Code - Montar SQL Legado", + "type": "main", + "index": 0 + }, + { + "node": "Data Table - Buscar Memoria", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Montar SQL Candidatos": { - "main": [[{ "node": "MySQL - Buscar Candidatos", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "MySQL - Buscar Candidatos", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Montar SQL Legado": { - "main": [[{ "node": "MySQL - Legado Top 10 (Shadow)", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "MySQL - Legado Top 10 (Shadow)", + "type": "main", + "index": 0 + } + ] + ] }, "MySQL - Buscar Candidatos": { - "main": [[{ "node": "Code - Normalizar Candidatos Base", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Normalizar Candidatos Base", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Normalizar Candidatos Base": { - "main": [[{ "node": "Code - Montar SQL Contexto", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Montar SQL Contexto", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Montar SQL Contexto": { - "main": [[{ "node": "MySQL - Buscar Contexto", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "MySQL - Buscar Contexto", + "type": "main", + "index": 0 + } + ] + ] }, "MySQL - Buscar Contexto": { - "main": [[{ "node": "Code - Mesclar Contexto e Calcular Hash", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Mesclar Contexto e Calcular Hash", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Mesclar Contexto e Calcular Hash": { - "main": [[{ "node": "Merge - Aguardar Memoria", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Merge - Aguardar Memoria", + "type": "main", + "index": 0 + } + ] + ] }, "Data Table - Buscar Memoria": { - "main": [[{ "node": "Merge - Aguardar Memoria", "type": "main", "index": 1 }]] + "main": [ + [ + { + "node": "Merge - Aguardar Memoria", + "type": "main", + "index": 1 + } + ] + ] }, "Merge - Aguardar Memoria": { - "main": [[{ "node": "Code - Decidir Necessidade de IA", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Decidir Necessidade de IA", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Decidir Necessidade de IA": { - "main": [[{ "node": "IF - Precisa IA?", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "IF - Precisa IA?", + "type": "main", + "index": 0 + } + ] + ] }, "IF - Precisa IA?": { "main": [ - [{ "node": "IF - IA Ligada?", "type": "main", "index": 0 }], - [{ "node": "Code - Reusar Classificacao em Cache", "type": "main", "index": 0 }] + [ + { + "node": "IF - IA Ligada?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Reusar Classificacao em Cache", + "type": "main", + "index": 0 + } + ] ] }, "IF - IA Ligada?": { "main": [ - [{ "node": "Code - Preparar Prompt IA", "type": "main", "index": 0 }], - [{ "node": "Code - Fallback IA Desligada", "type": "main", "index": 0 }] + [ + { + "node": "Code - Preparar Prompt IA", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Fallback IA Desligada", + "type": "main", + "index": 0 + } + ] ] }, "Code - Preparar Prompt IA": { - "main": [[{ "node": "HTTP - OpenAI Classificar Chamado", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "HTTP - OpenAI Classificar Chamado", + "type": "main", + "index": 0 + } + ] + ] }, "HTTP - OpenAI Classificar Chamado": { - "main": [[{ "node": "Code - Validar Classificacao IA", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Validar Classificacao IA", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Validar Classificacao IA": { - "main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Merge - Consolidar Classificacoes", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Fallback IA Desligada": { - "main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 1 }]] + "main": [ + [ + { + "node": "Merge - Consolidar Classificacoes", + "type": "main", + "index": 1 + } + ] + ] }, "Code - Reusar Classificacao em Cache": { - "main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 2 }]] + "main": [ + [ + { + "node": "Merge - Consolidar Classificacoes", + "type": "main", + "index": 2 + } + ] + ] }, "Merge - Consolidar Classificacoes": { - "main": [[{ "node": "Code - Calcular Score", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Calcular Score", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Calcular Score": { - "main": [[ - { "node": "Code - Selecionar Top 10", "type": "main", "index": 0 }, - { "node": "Code - Preparar Registro Memoria Analise", "type": "main", "index": 0 } - ]] + "main": [ + [ + { + "node": "Code - Selecionar Top 10", + "type": "main", + "index": 0 + }, + { + "node": "Code - Preparar Registro Memoria Analise", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Preparar Registro Memoria Analise": { - "main": [[{ "node": "Data Table - Upsert Memoria Analise", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Data Table - Upsert Memoria Analise", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Selecionar Top 10": { - "main": [[ - { "node": "Code - Montar Mensagem Curta", "type": "main", "index": 0 }, - { "node": "Code - Preparar Registro de Envio", "type": "main", "index": 0 } - ]] + "main": [ + [ + { + "node": "Code - Montar Mensagem Curta", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Preparar Registro de Envio": { - "main": [[{ "node": "Data Table - Registrar Envio", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Data Table - Registrar Envio", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Montar Mensagem Curta": { - "main": [[{ "node": "Code - Montar Digest Coordenacao", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Montar Digest Coordenacao", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Montar Digest Coordenacao": { - "main": [[{ "node": "Merge - Unir Shadow", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Merge - Unir Shadow", + "type": "main", + "index": 0 + }, + { + "node": "Data Table - Buscar Envios Recentes", + "type": "main", + "index": 0 + } + ] + ] }, "MySQL - Legado Top 10 (Shadow)": { - "main": [[{ "node": "Merge - Unir Shadow", "type": "main", "index": 1 }]] + "main": [ + [ + { + "node": "Merge - Unir Shadow", + "type": "main", + "index": 1 + } + ] + ] }, "Merge - Unir Shadow": { - "main": [[{ "node": "Code - Comparar Legacy x V2", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Code - Comparar Legacy x V2", + "type": "main", + "index": 0 + } + ] + ] }, "Code - Comparar Legacy x V2": { - "main": [[{ "node": "Data Table - Registrar Shadow Run", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Data Table - Registrar Shadow Run", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Envios Recentes": { + "main": [ + [ + { + "node": "Code - Guard de Envio Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard de Envio Slack": { + "main": [ + [ + { + "node": "IF - Deve Enviar Slack?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Deve Enviar Slack?": { + "main": [ + [ + { + "node": "Slack - Enviar Mensagem", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Pular Envio Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Slack - Enviar Mensagem": { + "main": [ + [ + { + "node": "Code - Registrar Envio Sucesso", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Registrar Envio Falha", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Registrar Envio Sucesso": { + "main": [ + [ + { + "node": "Data Table - Registrar Envio Digest (Sucesso)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Registrar Envio Digest (Sucesso)": { + "main": [ + [ + { + "node": "Code - Preparar Registro de Envio", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Registrar Envio Falha": { + "main": [ + [ + { + "node": "Data Table - Registrar Envio Digest (Falha)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Registrar Envio Digest (Falha)": { + "main": [ + [ + { + "node": "Code - Lancar Erro Envio Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger - Dias uteis 08h": { + "main": [ + [ + { + "node": "Config - Coordenador GLPI v2", + "type": "main", + "index": 0 + } + ] + ] } }, "pinData": {}, "active": false, "settings": { - "executionOrder": "v1" + "executionOrder": "v1", + "timezone": "America/Sao_Paulo" }, "versionId": "", "meta": { diff --git a/tools/build-workflow.test.js b/tools/build-workflow.test.js index e41821d..70542b2 100644 --- a/tools/build-workflow.test.js +++ b/tools/build-workflow.test.js @@ -66,21 +66,166 @@ test('glpi_base_url no Config nao e mais o placeholder do V1', () => { assert.equal(glpiField.value, 'https://glpi.gtigrupo.com.br'); }); -test('todo node que chama normalizeGlpiText/normalizeTicket/buildAiContextText tem a definicao dessas funcoes inlined (deps transitivas de context.js -> normalize.js)', () => { - // Regressao: context.js passou a fazer require('./normalize') nesta correcao, mas - // readLogicSource() so inlina o que o header @requires do node lista -- nao resolve - // deps transitivas. Um node com "@requires context.js" e sem "normalize.js" no header - // compila (sintaxe valida), mas explode em runtime com "normalizeGlpiText is not defined". +test('nenhum node chama uma funcao de logic/*.js sem ter a definicao dela inlined (deps transitivas resolvidas em todo @requires)', () => { + // Regressao GENERICA -- ja pegou dois bugs reais nesta missao (normalize.js nao listado + // junto de context.js; hash.js nao listado junto de slack.js). readLogicSource() so inlina + // o que o header @requires de CADA node lista -- nao resolve deps transitivas entre + // logic/*.js sozinho. Um node com a chamada mas sem a definicao compila (sintaxe valida) e + // so explode em runtime com " is not defined". Este teste escaneia TODAS as funcoes + // top-level de logic/*.js e garante que, se um node CHAMA uma delas, a DEFINICAO tambem + // esta no mesmo jsCode gerado -- sem precisar listar caso a caso feito antes. + const fs = require('node:fs'); + const path = require('node:path'); + const logicDir = path.join(__dirname, '..', 'logic'); + const functionNames = []; + for (const file of fs.readdirSync(logicDir)) { + if (!file.endsWith('.js') || file.endsWith('.test.js')) continue; + const src = fs.readFileSync(path.join(logicDir, file), 'utf8'); + const matches = src.matchAll(/^function\s+(\w+)\s*\(/gm); + for (const m of matches) functionNames.push(m[1]); + } + assert.ok(functionNames.length > 10, 'sanity: deveria ter encontrado varias funcoes em logic/*.js'); + const wf = buildWorkflow(); for (const node of wf.nodes) { if (node.type !== 'n8n-nodes-base.code') continue; const code = node.parameters.jsCode; - const callsHelper = /\bnormalizeGlpiText\(|\bnormalizeTicket\(|\bbuildAiContextText\(/.test(code); - if (!callsHelper) continue; - assert.ok( - code.includes('function normalizeGlpiText'), - `node ${node.name} chama uma funcao de normalize.js mas nao tem normalizeGlpiText inlined (adicionar normalize.js ao @requires)`, - ); + for (const fnName of functionNames) { + const callsIt = new RegExp(`[^.\\w]${fnName}\\(`).test(code); + if (!callsIt) continue; + const definesIt = code.includes(`function ${fnName}`); + assert.ok( + definesIt, + `node "${node.name}" chama ${fnName}(...) mas nao tem "function ${fnName}" inlined -- falta um arquivo de logic/*.js no @requires desse node driver`, + ); + } + } +}); + +test('nenhuma chave de connections fica orfa (nome renomeado sem atualizar connections) -- pegaria o bug real do Schedule Trigger', () => { + const wf = buildWorkflow(); + const names = new Set(wf.nodes.map((n) => n.name)); + for (const sourceKey of Object.keys(wf.connections)) { + assert.ok(names.has(sourceKey), `connections tem uma chave orfa: "${sourceKey}" nao corresponde a nenhum node`); + } + for (const conn of Object.values(wf.connections)) { + for (const branch of conn.main || []) { + for (const edge of branch || []) { + assert.ok(names.has(edge.node), `connections aponta para um node inexistente: "${edge.node}"`); + } + } + } +}); + +test('Schedule Trigger esta conectado de verdade ao pipeline (Config) -- teste obrigatorio #17', () => { + const wf = buildWorkflow(); + const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger'); + assert.ok(scheduleNode, 'workflow deveria ter um Schedule Trigger'); + const conn = wf.connections[scheduleNode.name]; + assert.ok(conn && conn.main && conn.main[0] && conn.main[0].length > 0, 'Schedule Trigger precisa estar conectado a algum node'); + assert.equal(conn.main[0][0].node, 'Config - Coordenador GLPI v2'); +}); + +test('Schedule Trigger roda segunda-sexta as 08h, timezone America/Sao_Paulo -- teste obrigatorio #18', () => { + const wf = buildWorkflow(); + const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger'); + const cron = scheduleNode.parameters.rule.interval[0].expression; + assert.equal(cron, '0 8 * * 1-5'); + assert.equal(wf.settings.timezone, 'America/Sao_Paulo'); +}); + +test('Manual Trigger continua presente para testes/manutencao', () => { + const wf = buildWorkflow(); + assert.ok(wf.nodes.some((n) => n.type === 'n8n-nodes-base.manualTrigger')); +}); + +test('node real de envio Slack existe (nao e so simulado/comentado)', () => { + const wf = buildWorkflow(); + const slackNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.slack'); + assert.ok(slackNode, 'deveria existir um node n8n-nodes-base.slack real'); + assert.equal(slackNode.parameters.text, '={{ $json.mensagem }}'); + assert.ok(slackNode.credentials && slackNode.credentials.slackApi, 'Slack node deveria usar uma credencial slackApi (nao webhook cru hardcoded)'); + assert.equal(slackNode.onError, 'continueErrorOutput', 'precisa separar sucesso/falha pra idempotencia e nao-silenciar-erro'); +}); + +test('guard de envio roda antes do Slack e existe IF de decisao', () => { + const wf = buildWorkflow(); + const guardConn = wf.connections['Code - Guard de Envio Slack']; + assert.equal(guardConn.main[0][0].node, 'IF - Deve Enviar Slack?'); + const ifConn = wf.connections['IF - Deve Enviar Slack?']; + assert.equal(ifConn.main[0][0].node, 'Slack - Enviar Mensagem'); + assert.equal(ifConn.main[1][0].node, 'Code - Pular Envio Slack'); +}); + +test('nodes n8n-nodes-base.dataTable usam valores de operation validos (bug real: "getRows" nao existe, e "get")', () => { + // Regressao: n8n_manage_datatable (a ferramenta MCP) usa a acao "getRows", mas o node + // nativo n8n-nodes-base.dataTable usa o valor de parametro "get" -- nomes parecidos, + // APIs diferentes. Pegou um erro real de validacao ao sincronizar com o DEV live. + const VALID_ROW_OPERATIONS = new Set(['deleteRows', 'get', 'rowExists', 'rowNotExists', 'insert', 'update', 'upsert']); + const wf = buildWorkflow(); + for (const node of wf.nodes) { + if (node.type !== 'n8n-nodes-base.dataTable') continue; + const op = node.parameters.operation; + assert.ok(VALID_ROW_OPERATIONS.has(op), `node "${node.name}" tem operation="${op}", que nao e um valor valido do node dataTable`); + } +}); + +test('registro de envio (per-ticket) so acontece depois do Data Table de sucesso do Slack -- nunca antes da confirmacao', () => { + const wf = buildWorkflow(); + // "Code - Selecionar Top 10" nao pode mais alimentar "Code - Preparar Registro de Envio" direto. + const top10Conn = wf.connections['Code - Selecionar Top 10']; + const directTargets = top10Conn.main[0].map((e) => e.node); + assert.ok(!directTargets.includes('Code - Preparar Registro de Envio'), 'registro de envio nao pode mais sair direto do Selecionar Top 10'); + // A cadeia real: Sucesso -> Data Table Sucesso -> Preparar Registro de Envio. + const sucessoDbConn = wf.connections['Data Table - Registrar Envio Digest (Sucesso)']; + assert.equal(sucessoDbConn.main[0][0].node, 'Code - Preparar Registro de Envio'); +}); + +test('falha do Slack sempre grava status=failed antes de lancar o erro (execucao visivelmente falha)', () => { + const wf = buildWorkflow(); + const falhaConn = wf.connections['Code - Registrar Envio Falha']; + assert.equal(falhaConn.main[0][0].node, 'Data Table - Registrar Envio Digest (Falha)'); + const falhaDbConn = wf.connections['Data Table - Registrar Envio Digest (Falha)']; + assert.equal(falhaDbConn.main[0][0].node, 'Code - Lancar Erro Envio Slack'); + const lancarErroNode = wf.nodes.find((n) => n.name === 'Code - Lancar Erro Envio Slack'); + assert.ok(lancarErroNode.parameters.jsCode.includes('throw new Error')); +}); + +test('AI_MAX_CANDIDATES no Config e igual a CANDIDATE_POOL_SIZE (nenhum candidato fica sem analise por cap artificial) -- teste obrigatorio #12', () => { + const wf = buildWorkflow(); + const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2'); + const assignments = configNode.parameters.assignments.assignments; + const poolSize = assignments.find((a) => a.name === 'CANDIDATE_POOL_SIZE').value; + const maxCandidates = assignments.find((a) => a.name === 'AI_MAX_CANDIDATES').value; + assert.equal(maxCandidates, poolSize); +}); + +test('Config expoe SEND_ENABLED (default false) e ENVIRONMENT -- teste obrigatorio #13/#22', () => { + const wf = buildWorkflow(); + const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2'); + const assignments = configNode.parameters.assignments.assignments; + const sendEnabled = assignments.find((a) => a.name === 'SEND_ENABLED'); + const environment = assignments.find((a) => a.name === 'ENVIRONMENT'); + assert.ok(sendEnabled, 'Config precisa expor SEND_ENABLED'); + assert.equal(sendEnabled.value, false, 'DEV deve comecar com SEND_ENABLED=false'); + assert.ok(environment, 'Config precisa expor ENVIRONMENT'); +}); + +test('GLPI continua exclusivamente read-only -- nenhum SQL gerado ou arquivo .sql contem INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE -- teste obrigatorio #20', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const forbidden = /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE)\b/i; + const sqlDir = path.join(__dirname, '..', 'sql'); + for (const file of fs.readdirSync(sqlDir)) { + if (!file.endsWith('.sql')) continue; + const content = fs.readFileSync(path.join(sqlDir, file), 'utf8'); + assert.ok(!forbidden.test(content), `${file} contem um comando de escrita SQL -- GLPI precisa ser read-only`); + } + const wf = buildWorkflow(); + for (const node of wf.nodes) { + if (node.type === 'n8n-nodes-base.mySql' && node.parameters && typeof node.parameters.query === 'string') { + assert.ok(!forbidden.test(node.parameters.query), `node MySQL "${node.name}" tem um comando de escrita`); + } } });