Found running the real validation execution against DEV: the envios
digest table starts empty (the normal state for every day until the
first successful send), the "get" operation returned 0 rows, and n8n
does not execute downstream nodes when a node outputs 0 items -- so
Code - Guard de Envio Slack, the IF, the real Slack node, and everything
else in the send branch silently never ran. This would have broken the
happy path on every single day in production, not just an edge case.
Fixed with alwaysOutputData: true on that node (forces at least one
output item even with 0 matches; the resulting empty {} item doesn't
false-positive in alreadySentSuccessfully since none of its fields match
a real record's shape). Re-ran the validation execution after the fix:
Code - Guard de Envio Slack now runs correctly (shouldSend: false,
reason: 'send_disabled' with SEND_ENABLED=false as expected), and the
IF node's true branch (Slack - Enviar Mensagem) is confirmed to never
fire while disabled.
Added a regression test asserting alwaysOutputData on this node.
157/157 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1366 lines
133 KiB
JSON
1366 lines
133 KiB
JSON
{
|
|
"name": "[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)",
|
|
"nodes": [
|
|
{
|
|
"id": "manual-trigger-v2",
|
|
"name": "Manual Trigger",
|
|
"type": "n8n-nodes-base.manualTrigger",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
-1600,
|
|
0
|
|
],
|
|
"parameters": {}
|
|
},
|
|
{
|
|
"id": "schedule-trigger-v2-desligado",
|
|
"name": "Schedule Trigger - Dias uteis 08h",
|
|
"type": "n8n-nodes-base.scheduleTrigger",
|
|
"typeVersion": 1.3,
|
|
"position": [
|
|
-1600,
|
|
900
|
|
],
|
|
"parameters": {
|
|
"rule": {
|
|
"interval": [
|
|
{
|
|
"field": "cronExpression",
|
|
"expression": "0 8 * * 1-5"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"id": "config-v2",
|
|
"name": "Config - Coordenador GLPI v2",
|
|
"type": "n8n-nodes-base.set",
|
|
"typeVersion": 3.5,
|
|
"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": 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": {}
|
|
}
|
|
},
|
|
{
|
|
"id": "code-montar-sql-candidatos",
|
|
"name": "Code - Montar SQL Candidatos",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
-1120,
|
|
-160
|
|
],
|
|
"parameters": {
|
|
"jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\n}\n\n// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),\n// entao a configuracao vem do node \"Config - Coordenador GLPI v2\" (mesmo padrao do V1),\n// nao de variaveis de ambiente reais.\nconst configSource = $('Config - Coordenador GLPI v2').first().json;\nconst config = parseConfig(configSource);\nconst glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.content AS descricao, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, descricao, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];"
|
|
}
|
|
},
|
|
{
|
|
"id": "code-montar-sql-legado",
|
|
"name": "Code - Montar SQL Legado",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
-1120,
|
|
900
|
|
],
|
|
"parameters": {
|
|
"jsCode": "const config = $('Config - Coordenador GLPI v2').first().json;\nconst glpiBaseUrl = String(config.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Top 10 chamados abertos por blocos operacionais.\n-- Blocos:\n-- - 3 chamados com SLA vencido mais criticos\n-- - 3 chamados com maior tempo sem interacao\n-- - 2 chamados sem tecnico atribuido\n-- - 2 chamados de prioridade alta mais recentes\n-- Se faltar item em algum bloco, o complemento vem do ranking geral.\n--\n-- Ajuste os parametros abaixo no workflow n8n:\n-- {DIAS_SEM_INTERACAO_ALERTA}\n-- {HORAS_SLA_RISCO}\n-- {GLPI_BASE_URL}\n\nWITH ultima_interacao AS (\n SELECT\n tickets_id,\n 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\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_mod AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_creation AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n\n UNION ALL\n\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_mod AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_creation AS data_interacao\n 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\n 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,\n t.name AS titulo,\n t.status,\n CASE t.status\n WHEN 1 THEN 'Novo'\n WHEN 2 THEN 'Em atendimento'\n WHEN 3 THEN 'Planejado'\n WHEN 4 THEN 'Pendente'\n ELSE CONCAT('Status ', t.status)\n END AS status_nome,\n t.date AS aberto_em,\n t.date_mod AS modificado_em,\n t.time_to_resolve AS sla_resolver_ate,\n t.priority,\n t.urgency,\n 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(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ), 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\n WHEN t.time_to_resolve IS NOT NULL\n AND t.time_to_resolve >= NOW()\n AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1\n ELSE 0\n 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,\n titulo,\n status_nome,\n aberto_em,\n modificado_em,\n ultima_interacao_em,\n dias_aberto,\n dias_sem_interacao,\n sla_resolver_ate,\n horas_ate_sla,\n sla_vencido,\n sla_em_risco,\n priority AS prioridade,\n urgency AS urgencia,\n impact AS impacto,\n entidade,\n categoria,\n tecnico_atribuido,\n sem_tecnico,\n 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\n WHEN dias_sem_interacao >= 7 THEN 35\n WHEN dias_sem_interacao >= 3 THEN 25\n WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10\n ELSE 0\n END\n + LEAST(dias_aberto, 30)\n + (priority * 8)\n + (urgency * 5)\n + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n), sla_top AS (\n SELECT *, 'SLA vencido' AS bloco_origem, 1 AS bloco_ordem\n FROM chamados_ranked\n WHERE sla_vencido = 1\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 3\n), sem_interacao_top AS (\n SELECT *, 'Sem interacao' AS bloco_origem, 2 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM sla_top)\n ORDER BY dias_sem_interacao DESC, score_criticidade DESC, dias_aberto DESC\n LIMIT 3\n), sem_tecnico_top AS (\n SELECT *, 'Sem tecnico' AS bloco_origem, 3 AS bloco_ordem\n FROM chamados_ranked\n WHERE sem_tecnico = 1\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 2\n), prioridade_alta_recente_top AS (\n SELECT *, 'Prioridade alta recente' AS bloco_origem, 4 AS bloco_ordem\n FROM chamados_ranked\n WHERE prioridade >= 4\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n AND id NOT IN (SELECT id FROM sem_tecnico_top)\n ORDER BY aberto_em DESC, score_criticidade DESC\n LIMIT 2\n), selecionados AS (\n SELECT * FROM sla_top\n UNION ALL\n SELECT * FROM sem_interacao_top\n UNION ALL\n SELECT * FROM sem_tecnico_top\n UNION ALL\n SELECT * FROM prioridade_alta_recente_top\n), complemento AS (\n SELECT *, 'Ranking geral' AS bloco_origem, 5 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM selecionados)\n ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 10\n)\nSELECT *\nFROM (\n SELECT * FROM selecionados\n UNION ALL\n SELECT * FROM complemento\n) ranking_final\nORDER BY bloco_ordem ASC, score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT 10;\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', '2')\n .replaceAll('{HORAS_SLA_RISCO}', '8')\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl);\n\nreturn [{ json: { query } }];"
|
|
}
|
|
},
|
|
{
|
|
"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": {}
|
|
}
|
|
},
|
|
{
|
|
"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": {}
|
|
}
|
|
},
|
|
{
|
|
"id": "code-normalizar-candidatos-base",
|
|
"name": "Code - Normalizar Candidatos Base",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
-640,
|
|
-160
|
|
],
|
|
"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 items = $input.all();\nreturn items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));"
|
|
}
|
|
},
|
|
{
|
|
"id": "code-montar-sql-contexto",
|
|
"name": "Code - Montar SQL Contexto",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
-400,
|
|
-160
|
|
],
|
|
"parameters": {
|
|
"jsCode": "const candidatos = $input.all().map((item) => item.json);\nconst ids = candidatos.map((c) => Number(c.id)).filter((id) => Number.isInteger(id));\nconst idsList = ids.length ? ids.join(',') : '0';\n\nconst queryTemplate = `-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query\n-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.\n-- IDs interpolados pelo node como lista de inteiros (nunca string crua do GLPI).\n-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a\n-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.\n\nSELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status\nFROM glpi_itilfollowups\nWHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})\n\nUNION ALL\n\nSELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status\nFROM glpi_tickettasks\nWHERE tickets_id IN ({IDS})\n\nORDER BY tickets_id, date ASC;\n`;\nconst query = queryTemplate.replaceAll('{IDS}', idsList);\n\nreturn [{ json: { query, candidateIds: ids } }];"
|
|
}
|
|
},
|
|
{
|
|
"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": {}
|
|
}
|
|
},
|
|
{
|
|
"id": "code-mesclar-contexto-hash",
|
|
"name": "Code - Mesclar Contexto e Calcular Hash",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
80,
|
|
-160
|
|
],
|
|
"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\nfunction stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];\n\n// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint\n// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma\n// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca\n// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\nconst candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);\nconst contextRows = $input.all().map((item) => item.json);\n\nfunction groupBy(rows, key) {\n const map = {};\n for (const row of rows) {\n const k = String(row[key]);\n if (!map[k]) map[k] = [];\n map[k].push(row);\n }\n return map;\n}\n\nconst followupRows = contextRows.filter((row) => row.tipo === 'followup');\nconst taskRows = contextRows.filter((row) => row.tipo === 'task');\n\nconst followupsByTicket = groupBy(followupRows, 'tickets_id');\nconst tasksByTicket = groupBy(taskRows, 'tickets_id');\n\nreturn candidatosBase.map((base) => {\n const idKey = String(base.id);\n const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));\n const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));\n const ticket = normalizeTicket(base, { followups, tasks });\n const contextHash = buildContextFingerprint(ticket);\n return { json: { ticket, contextHash } };\n});"
|
|
}
|
|
},
|
|
{
|
|
"id": "datatable-buscar-memoria",
|
|
"name": "Data Table - Buscar Memoria",
|
|
"type": "n8n-nodes-base.dataTable",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
-1120,
|
|
300
|
|
],
|
|
"parameters": {
|
|
"resource": "row",
|
|
"operation": "get",
|
|
"dataTableId": {
|
|
"mode": "name",
|
|
"value": "coordenador_glpi_memoria"
|
|
},
|
|
"filters": {
|
|
"conditions": []
|
|
},
|
|
"returnAll": true
|
|
}
|
|
},
|
|
{
|
|
"id": "merge-aguardar-memoria",
|
|
"name": "Merge - Aguardar Memoria",
|
|
"type": "n8n-nodes-base.merge",
|
|
"typeVersion": 3.2,
|
|
"position": [
|
|
320,
|
|
-160
|
|
],
|
|
"parameters": {
|
|
"mode": "append",
|
|
"numberInputs": 2
|
|
}
|
|
},
|
|
{
|
|
"id": "code-decidir-necessidade-ia",
|
|
"name": "Code - Decidir Necessidade de IA",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
560,
|
|
-160
|
|
],
|
|
"parameters": {
|
|
"jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\n// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO\n// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe\n// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao\n// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem\n// precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\nfunction buildCacheKey(contextHash, classifierVersion) {\n return `${contextHash}:v${classifierVersion}`;\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\nconst AI_CLASSIFIER_VERSION = 3;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\n}\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst now = Date.now();\n\n// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a\n// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.\nconst items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n // Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de\n // AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no\n // GLPI -- sem precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\n const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);\n const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });\n return {\n ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,\n };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n cacheKey: item.cacheKey,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});"
|
|
}
|
|
},
|
|
{
|
|
"id": "if-precisa-ia",
|
|
"name": "IF - Precisa IA?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
800,
|
|
-160
|
|
],
|
|
"parameters": {
|
|
"conditions": {
|
|
"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
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
}
|
|
},
|
|
{
|
|
"id": "if-ia-ligada",
|
|
"name": "IF - IA Ligada?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
1040,
|
|
-320
|
|
],
|
|
"parameters": {
|
|
"conditions": {
|
|
"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
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
}
|
|
},
|
|
{
|
|
"id": "code-preparar-prompt-ia",
|
|
"name": "Code - Preparar Prompt IA",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1280,
|
|
-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 '',\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});"
|
|
}
|
|
},
|
|
{
|
|
"id": "http-openai-classificar",
|
|
"name": "HTTP - OpenAI Classificar Chamado",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
1520,
|
|
-400
|
|
],
|
|
"continueOnFail": true,
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://api.openai.com/v1/chat/completions",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "openAiApi",
|
|
"sendBody": true,
|
|
"contentType": "json",
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify($json.requestBody) }}",
|
|
"options": {
|
|
"timeout": 30000
|
|
}
|
|
},
|
|
"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": "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});"
|
|
}
|
|
},
|
|
{
|
|
"id": "code-fallback-ia-desligada",
|
|
"name": "Code - Fallback IA Desligada",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1280,
|
|
-160
|
|
],
|
|
"parameters": {
|
|
"jsCode": "// 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\nreturn $input.all().map((item) => ({ json: { ...item.json, aiClassification: buildFallbackClassification('ai_disabled') } }));"
|
|
}
|
|
},
|
|
{
|
|
"id": "code-reusar-classificacao-cache",
|
|
"name": "Code - Reusar Classificacao em Cache",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1040,
|
|
80
|
|
],
|
|
"parameters": {
|
|
"jsCode": "// 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\nreturn $input.all().map((item) => {\n const data = item.json;\n if (data.resolucao === 'cache' && data.memory) {\n const m = data.memory;\n const isFallback = m.ai_classification === 'AI_FALLBACK';\n return {\n json: {\n ...data,\n aiClassification: {\n classification: m.ai_classification,\n actionable_today: m.ai_classification === 'ACTIONABLE_NOW' || m.ai_classification === 'UNASSIGNED_ACTIONABLE',\n confidence: Number(m.ai_confidence || 0),\n blocker_type: m.blocker_type || null,\n waiting_for: m.waiting_for || null,\n next_action: m.next_action || 'Revisar chamado',\n suggested_action_type: 'technical_action',\n business_risk: 'medium',\n reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.',\n evidence: [],\n // Precisa refletir de verdade o que foi cacheado -- um AI_FALLBACK cacheado\n // continua sendo fallback (senao a politica de elegibilidade em cooldown.js\n // nao aplicaria a exclusao/override certos numa segunda execucao com cache).\n is_fallback: isFallback,\n fallback_reason: isFallback ? 'cached_ai_fallback' : null,\n },\n },\n };\n }\n return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } };\n});"
|
|
}
|
|
},
|
|
{
|
|
"id": "merge-consolidar-classificacoes",
|
|
"name": "Merge - Consolidar Classificacoes",
|
|
"type": "n8n-nodes-base.merge",
|
|
"typeVersion": 3.2,
|
|
"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": "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});"
|
|
}
|
|
},
|
|
{
|
|
"id": "code-preparar-registro-memoria-analise",
|
|
"name": "Code - Preparar Registro Memoria Analise",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2480,
|
|
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 // 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});"
|
|
}
|
|
},
|
|
{
|
|
"id": "datatable-upsert-memoria-analise",
|
|
"name": "Data Table - Upsert Memoria Analise",
|
|
"type": "n8n-nodes-base.dataTable",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
2720,
|
|
160
|
|
],
|
|
"parameters": {
|
|
"resource": "row",
|
|
"operation": "upsert",
|
|
"dataTableId": {
|
|
"mode": "name",
|
|
"value": "coordenador_glpi_memoria"
|
|
},
|
|
"filters": {
|
|
"conditions": [
|
|
{
|
|
"keyName": "ticket_id",
|
|
"condition": "eq",
|
|
"keyValue": "={{ $json.ticket_id }}"
|
|
}
|
|
]
|
|
},
|
|
"columns": {
|
|
"mappingMode": "defineBelow",
|
|
"matchingColumns": [
|
|
"ticket_id"
|
|
],
|
|
"value": {
|
|
"ticket_id": "={{ $json.ticket_id }}",
|
|
"last_analyzed_at": "={{ $json.last_analyzed_at }}",
|
|
"last_context_hash": "={{ $json.last_context_hash }}",
|
|
"last_meaningful_change_at": "={{ $json.last_meaningful_change_at }}",
|
|
"ai_classification": "={{ $json.ai_classification }}",
|
|
"ai_confidence": "={{ $json.ai_confidence }}",
|
|
"blocker_type": "={{ $json.blocker_type }}",
|
|
"waiting_for": "={{ $json.waiting_for }}",
|
|
"next_action": "={{ $json.next_action }}",
|
|
"next_review_at": "={{ $json.next_review_at }}",
|
|
"last_score": "={{ $json.last_score }}",
|
|
"last_ai_reason": "={{ $json.last_ai_reason }}",
|
|
"updated_at": "={{ $json.updated_at }}"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"id": "code-selecionar-top10",
|
|
"name": "Code - Selecionar Top 10",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2480,
|
|
-320
|
|
],
|
|
"parameters": {
|
|
"jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n decisionReason: data.scoreResult.decisionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n // 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir\n // Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas\n // de IA reais), senao se perde depois deste node.\n resolucao: data.resolucao,\n config: data.config,\n now: data.now,\n}));\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];"
|
|
}
|
|
},
|
|
{
|
|
"id": "code-preparar-registro-envio",
|
|
"name": "Code - Preparar Registro de Envio",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2720,
|
|
-560
|
|
],
|
|
"parameters": {
|
|
"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 });"
|
|
}
|
|
},
|
|
{
|
|
"id": "datatable-registrar-envio",
|
|
"name": "Data Table - Registrar Envio",
|
|
"type": "n8n-nodes-base.dataTable",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
2960,
|
|
-560
|
|
],
|
|
"parameters": {
|
|
"resource": "row",
|
|
"operation": "upsert",
|
|
"dataTableId": {
|
|
"mode": "name",
|
|
"value": "coordenador_glpi_memoria"
|
|
},
|
|
"filters": {
|
|
"conditions": [
|
|
{
|
|
"keyName": "ticket_id",
|
|
"condition": "eq",
|
|
"keyValue": "={{ $json.ticket_id }}"
|
|
}
|
|
]
|
|
},
|
|
"columns": {
|
|
"mappingMode": "defineBelow",
|
|
"matchingColumns": [
|
|
"ticket_id"
|
|
],
|
|
"value": {
|
|
"ticket_id": "={{ $json.ticket_id }}",
|
|
"last_sent_at": "={{ $json.last_sent_at }}",
|
|
"times_sent": "={{ $json.times_sent }}",
|
|
"times_sent_30d": "={{ $json.times_sent_30d }}",
|
|
"consecutive_days_sent": "={{ $json.consecutive_days_sent }}",
|
|
"updated_at": "={{ $json.updated_at }}"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"id": "code-montar-mensagem-curta",
|
|
"name": "Code - Montar Mensagem Curta",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2720,
|
|
-320
|
|
],
|
|
"parameters": {
|
|
"jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];"
|
|
}
|
|
},
|
|
{
|
|
"id": "code-montar-digest-coordenacao",
|
|
"name": "Code - Montar Digest Coordenacao",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2960,
|
|
-320
|
|
],
|
|
"parameters": {
|
|
"jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];"
|
|
}
|
|
},
|
|
{
|
|
"id": "merge-unir-shadow",
|
|
"name": "Merge - Unir Shadow",
|
|
"type": "n8n-nodes-base.merge",
|
|
"typeVersion": 3.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": "// Fila interna de auditoria/calibracao para chamados classificados como INSUFFICIENT_CONTEXT.\n// Nao e enviada a ninguem -- serve so pra identificar problemas de preenchimento no GLPI\n// (ver secao 10/14 da missao original). Fica gravada dentro do resumo do shadow run.\nfunction buildDataQualityReview(items) {\n return items\n .filter((item) => item.aiClassification.classification === 'INSUFFICIENT_CONTEXT')\n .map((item) => {\n const t = item.ticket;\n const followups = t.followups || [];\n const tasks = t.tasks || [];\n const contextSize = (t.descricao || '').length\n + followups.reduce((sum, f) => sum + (f.texto || '').length, 0)\n + tasks.reduce((sum, task) => sum + (task.texto || '').length, 0);\n\n return {\n ticket_id: t.id,\n title: t.titulo,\n ai_reason: item.aiClassification.reason,\n context_size: contextSize,\n has_description: Boolean(t.descricao && t.descricao.trim().length > 0),\n followup_count: followups.length,\n task_count: tasks.length,\n has_solution: Boolean(t.solucao),\n last_interaction_at: t.ultima_interacao_em,\n };\n });\n}\n\n// Este node roda depois de \"Merge - Unir Shadow\" (append: Code - Montar Digest Coordenacao +\n// MySQL - Legado Top 10 (Shadow)), que existe so para GARANTIR que os dois ja executaram --\n// os dados continuam sendo lidos por nome, nao pelo item mesclado, para nao depender de ordem.\nconst digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }\nconst legacyRows = $('MySQL - Legado Top 10 (Shadow)').all().map((item) => item.json);\n\nconst legacyIds = legacyRows.map((row) => Number(row.id));\nconst v2Ids = digestData.selected.map((item) => Number(item.ticket.id));\n\nconst added = v2Ids.filter((id) => !legacyIds.includes(id));\nconst removed = legacyIds.filter((id) => !v2Ids.includes(id));\n\nconst addedReasons = added.map((id) => {\n const item = digestData.selected.find((s) => Number(s.ticket.id) === id);\n return { id, motivo: item ? `${item.aiClassification.classification} - ${item.aiClassification.reason}` : 'desconhecido' };\n});\nconst removedReasons = removed.map((id) => {\n const excludedItem = digestData.excluded.find((e) => Number(e.ticket.id) === id);\n return { id, motivo: excludedItem ? (excludedItem.exclusionReason || excludedItem.aiClassification.classification) : 'nao estava nos candidatos V2' };\n});\n\n// --- observabilidade (secao 24 da missao) ---\nconst allItems = [...digestData.selected, ...digestData.excluded];\n\nconst cacheHits = allItems.filter((i) => i.resolucao === 'cache').length;\nconst aiRequested = allItems.filter((i) => i.resolucao === 'ia').length;\nconst aiFallbackCapCount = allItems.filter((i) => i.resolucao === 'fallback_cap').length;\nconst aiSuccess = allItems.filter((i) => i.resolucao === 'ia' && !i.aiClassification.is_fallback).length;\nconst aiErrors = allItems.filter((i) => i.resolucao === 'ia' && i.aiClassification.is_fallback).length;\nconst aiFallbackCount = allItems.filter((i) => i.aiClassification.is_fallback).length;\n\nconst aiFallbackByReason = {};\nallItems\n .filter((i) => i.aiClassification.is_fallback)\n .forEach((i) => {\n const r = i.aiClassification.fallback_reason || 'unknown';\n aiFallbackByReason[r] = (aiFallbackByReason[r] || 0) + 1;\n });\n\nconst classificationCounts = {};\nallItems.forEach((i) => {\n const c = i.aiClassification.classification;\n classificationCounts[c] = (classificationCounts[c] || 0) + 1;\n});\n\nconst actionableRealCount = allItems.filter((i) => !i.aiClassification.is_fallback\n && (i.aiClassification.classification === 'ACTIONABLE_NOW' || i.aiClassification.classification === 'UNASSIGNED_ACTIONABLE')).length;\nconst criticalOverrideCount = allItems.filter((i) => i.decisionReason && String(i.decisionReason).endsWith('_critical_override')).length;\n\nconst observability = {\n candidate_count: allItems.length,\n cache_hits: cacheHits,\n cache_misses: allItems.length - cacheHits,\n ai_requested: aiRequested,\n ai_success: aiSuccess,\n ai_errors: aiErrors,\n ai_fallback_count: aiFallbackCount,\n ai_fallback_cap_count: aiFallbackCapCount,\n ai_fallback_by_reason: aiFallbackByReason,\n classification_counts: classificationCounts,\n insufficient_context_count: classificationCounts.INSUFFICIENT_CONTEXT || 0,\n actionable_real_count: actionableRealCount,\n critical_override_count: criticalOverrideCount,\n top10_count: digestData.selected.length,\n blocked_count: digestData.excluded.length,\n coordination_count: classificationCounts.NEEDS_COORDINATION || 0,\n};\n\nconst dataQualityReview = buildDataQualityReview(allItems);\n\nreturn [{\n json: {\n run_at: Date.now(),\n legacy_ids: JSON.stringify(legacyIds),\n v2_ids: JSON.stringify(v2Ids),\n added: JSON.stringify(added),\n removed: JSON.stringify(removed),\n summary: JSON.stringify({ addedReasons, removedReasons, observability, dataQualityReview }),\n mensagem: digestData.mensagem,\n digest: digestData.digest,\n },\n}];"
|
|
}
|
|
},
|
|
{
|
|
"id": "datatable-registrar-shadow-run",
|
|
"name": "Data Table - Registrar Shadow Run",
|
|
"type": "n8n-nodes-base.dataTable",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
3680,
|
|
160
|
|
],
|
|
"parameters": {
|
|
"resource": "row",
|
|
"operation": "insert",
|
|
"dataTableId": {
|
|
"mode": "name",
|
|
"value": "coordenador_glpi_shadow_runs"
|
|
},
|
|
"columns": {
|
|
"mappingMode": "defineBelow",
|
|
"value": {
|
|
"run_at": "={{ $json.run_at }}",
|
|
"legacy_ids": "={{ $json.legacy_ids }}",
|
|
"v2_ids": "={{ $json.v2_ids }}",
|
|
"added": "={{ $json.added }}",
|
|
"removed": "={{ $json.removed }}",
|
|
"summary": "={{ $json.summary }}"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"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 }}"
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"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": {
|
|
"Manual Trigger": {
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Montar SQL Candidatos": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "MySQL - Buscar Candidatos",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Montar SQL Legado": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "MySQL - Legado Top 10 (Shadow)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"MySQL - Buscar Candidatos": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Normalizar Candidatos Base",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Normalizar Candidatos Base": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Montar SQL Contexto",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Montar SQL Contexto": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "MySQL - Buscar Contexto",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"MySQL - Buscar Contexto": {
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Data Table - Buscar Memoria": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge - Aguardar Memoria",
|
|
"type": "main",
|
|
"index": 1
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Merge - Aguardar Memoria": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Decidir Necessidade de IA",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Decidir Necessidade de IA": {
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"IF - IA Ligada?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"HTTP - OpenAI Classificar Chamado": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Validar Classificacao IA",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Validar Classificacao IA": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge - Consolidar Classificacoes",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Fallback IA Desligada": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge - Consolidar Classificacoes",
|
|
"type": "main",
|
|
"index": 1
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Reusar Classificacao em Cache": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge - Consolidar Classificacoes",
|
|
"type": "main",
|
|
"index": 2
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Merge - Consolidar Classificacoes": {
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Preparar Registro Memoria Analise": {
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Preparar Registro de Envio": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Data Table - Registrar Envio",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Montar Mensagem Curta": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Montar Digest Coordenacao",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Montar Digest Coordenacao": {
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Merge - Unir Shadow": {
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"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",
|
|
"timezone": "America/Sao_Paulo"
|
|
},
|
|
"versionId": "",
|
|
"meta": {
|
|
"templateCredsSetupCompleted": false
|
|
},
|
|
"tags": []
|
|
} |