Files
SLACK_COORD_RELATORIO/n8n/deployed/coordenador-glpi-v2.n8n.json
T
GustavoandClaude Sonnet 5 b11fcb8a7f fix(v2): CRITICAL -- deterministic tie-break in Top10 selection (real duplicate-send root cause)
Found running the real double-send idempotency proof the mission
explicitly required: two executions seconds apart, essentially unchanged
GLPI data, produced TWO DIFFERENT Top10 compositions -- and since the
idempotency guard is keyed on the exact message hash, both were treated
as legitimately different digests and BOTH sent real Slack messages.

Root cause: logic/diversity.js's selectTop() sorted candidates by score
descending using Array.sort, which is stable but has no secondary key.
Many real candidates tie on score (e.g. several ACTIONABLE_NOW tickets
at 260 or 247.6 points), so ties were broken purely by whatever order
$input.all() happened to deliver items in -- and n8n does NOT guarantee
relative item order across parallel branches that re-merge (documented
elsewhere in this project's history: cache/ia/fallback classification
branches converge at Merge - Consolidar Classificacoes before reaching
here). Different arrival order -> different tie-break -> different
Top10 -> different message -> both "different enough" to bypass
idempotency, even though nothing meaningful changed in GLPI.

Fixed with a deterministic secondary sort key (ticket.id, ascending)
that only applies when scores are exactly equal -- doesn't change WHO
wins (still purely score-driven), only makes ties reproducible run to
run. This is a determinism fix, not a ranking/weight change (mission
section 21 constraint respected).

Added a regression test that reproduces the exact real-world scenario:
the same 12 candidates (all tied at score 200), fed in two different
orders, must produce the identical Top10.

171/171 tests passing.

Two real Slack messages were sent during this investigation (with the
user's standing test authorization) before the fix landed -- both
correctly formatted, no attribution footer, real diverse next_action
values, no literal backslash-n. Reverted SEND_ENABLED to false and
deactivated the workflow immediately after discovering the root cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:59:29 -03:00

1398 lines
140 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
},
{
"id": "cfg-force-send",
"name": "FORCE_SEND",
"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 // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\n };\n}\n\n// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),\n// entao a configuracao vem do node \"Config - Coordenador GLPI v2\" (mesmo padrao do V1),\n// nao de variaveis de ambiente reais.\nconst configSource = $('Config - Coordenador GLPI v2').first().json;\nconst config = parseConfig(configSource);\nconst glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.content AS descricao, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, descricao, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];"
}
},
{
"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 '&nbsp;': ' ',\n '&amp;': '&',\n '&lt;': '<',\n '&gt;': '>',\n '&quot;': '\"',\n '&#39;': \"'\",\n '&apos;': \"'\",\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 '&nbsp;': ' ',\n '&amp;': '&',\n '&lt;': '<',\n '&gt;': '>',\n '&quot;': '\"',\n '&#39;': \"'\",\n '&apos;': \"'\",\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 // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\n// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO\n// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe\n// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao\n// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem\n// precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\nfunction buildCacheKey(contextHash, classifierVersion) {\n return `${contextHash}:v${classifierVersion}`;\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\n// v4: lista de frases genericas rejeitadas expandida (\"Validar e executar\" e variantes --\n// missao \"correcao final\", secao 5). Bump forca reclassificacao de qualquer candidato\n// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases.\nconst AI_CLASSIFIER_VERSION = 4;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\n// Lista expandida na missao \"correcao final\" (secao 5) -- \"Validar e executar\" era o rotulo\n// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js).\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n 'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\n}\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst now = Date.now();\n\n// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a\n// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.\nconst items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n // Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de\n // AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no\n // GLPI -- sem precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\n const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);\n const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });\n return {\n ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,\n };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n cacheKey: item.cacheKey,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});"
}
},
{
"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 '&nbsp;': ' ',\n '&amp;': '&',\n '&lt;': '<',\n '&gt;': '>',\n '&quot;': '\"',\n '&#39;': \"'\",\n '&apos;': \"'\",\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.\n// v4: lista de frases genericas rejeitadas expandida (\"Validar e executar\" e variantes --\n// missao \"correcao final\", secao 5). Bump forca reclassificacao de qualquer candidato\n// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases.\nconst AI_CLASSIFIER_VERSION = 4;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\n// Lista expandida na missao \"correcao final\" (secao 5) -- \"Validar e executar\" era o rotulo\n// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js).\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n 'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\n}\n\n// AI_FALLBACK e um estado tecnico: significa \"a IA nunca analisou este chamado\" (erro,\n// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e\n// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar\n// (ver logic/classification.js). Politica de elegibilidade fica centralizada em\n// logic/cooldown.js (applyFallbackPolicy), nao aqui.\nconst AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: AI_FALLBACK_CLASSIFICATION,\n actionable_today: false,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente (nao analisado pela IA)',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nconst promptItems = $('Code - Preparar Prompt IA').all();\nconst responseItems = $input.all();\n\nreturn responseItems.map((responseItem, index) => {\n const original = (promptItems[index] && promptItems[index].json) || {};\n const httpJson = responseItem.json || {};\n\n if (httpJson.error) {\n return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };\n }\n\n let raw = null;\n try {\n const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;\n raw = content ? JSON.parse(content) : null;\n } catch (e) {\n raw = null;\n }\n\n // Passa o ticket pra validacao poder aplicar a guarda deterministica \"tecnico interno nunca\n // vira WAITING_VENDOR\" (logic/classification.js -> isWaitingForInternalTechnician). Sem\n // isso a validacao so olha o JSON cru, nunca os fatos do proprio chamado.\n const validation = validateAiClassification(raw, original.ticket);\n let aiClassification;\n if (validation.valid) {\n aiClassification = validation.data;\n } else {\n // Motivo especifico e auditavel por que a IA caiu pra fallback -- nao joga tudo em\n // \"invalid_json\" (missao \"correcao final\", secao 9: decision_reason distinguivel).\n const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno'));\n const isGenericAction = validation.errors.some((e) => e.includes('generico demais'));\n let reason = 'invalid_json';\n if (isSemanticConflict) reason = 'ai_semantic_conflict';\n else if (isGenericAction) reason = 'generic_or_unsupported_next_action';\n aiClassification = buildFallbackClassification(reason);\n }\n\n return { json: { ...original, aiClassification } };\n});"
}
},
{
"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 // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n // BUG REAL comprovado em execucao ao vivo (missao \"correcao final\", secao 14): Array.sort e\n // estavel, e muitos chamados empatam em score -- sem um criterio de desempate deterministico,\n // a ordem de chegada de $input.all() (que o n8n NAO garante entre branches paralelos --\n // cache/ia/fallback se juntam de novo antes daqui) decidia o desempate. Duas execucoes\n // seguidas com os MESMOS dados produziram Top10 DIFERENTES so por causa disso, gerando duas\n // mensagens reais distintas pro Slack no mesmo dia. ticket.id como criterio secundario\n // garante o mesmo resultado sempre que os scores forem os mesmos -- nao muda QUEM vence\n // (isso continua sendo so o score), so desempata de forma reproduzivel.\n const sorted = [...eligible].sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n return String(a.ticket.id).localeCompare(String(b.ticket.id));\n });\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": "// BUG REAL (missao \"correcao final\", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui\n// (\"Validar e executar\") que sempre vencia o next_action de verdade da IA -- por isso TODA\n// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel,\n// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js)\n// um next_action especifico como \"Realizar inventario das maquinas\". Removido daqui: agora\n// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado).\n// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso \"atribuir\n// tecnico\" E a acao real, independente do que o next_action interno da IA descreva.\nconst ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];"
}
},
{
"id": "code-montar-digest-coordenacao",
"name": "Code - Montar Digest Coordenacao",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
-320
],
"parameters": {
"jsCode": "// BUG REAL (missao \"correcao final\", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui\n// (\"Validar e executar\") que sempre vencia o next_action de verdade da IA -- por isso TODA\n// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel,\n// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js)\n// um next_action especifico como \"Realizar inventario das maquinas\". Removido daqui: agora\n// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado).\n// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso \"atribuir\n// tecnico\" E a acao real, independente do que o next_action interno da IA descreva.\nconst ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];"
}
},
{
"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 // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\n };\n}\n\nfunction stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];\n\n// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint\n// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma\n// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca\n// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o gate principal de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22). Runs 'manual' tem uma segunda\n// trava: mesmo com SEND_ENABLED=true (producao), clicar \"Execute Workflow\" NAO envia de\n// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por\n// alguem testando/depurando em producao (missao \"correcao final\", secao 16/17).\nfunction shouldSendByConfig(config, runType) {\n if (!config || !config.sendEnabled) return false;\n if (runType === 'manual' && !config.forceSend) return false;\n return true;\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config, runType)) {\n const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';\n return { shouldSend: false, reason };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// Roda depois de \"Data Table - Buscar Envios Recentes\" (fetch por report_date=hoje), que existe\n// so pra trazer os registros de envio ja existentes; os dados do digest sao lidos por nome\n// (mesmo padrao ja usado em Code - Comparar Legacy x V2).\nconst digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }\nconst config = digestData.selected.length ? digestData.selected[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst existingRecords = $input.all().map((item) => item.json);\n\n// $execution.mode: 'manual' quando disparado pelo Manual Trigger, senao ('trigger' no\n// Schedule Trigger) tratamos como execucao agendada. Nao inventa um mecanismo novo de deteccao.\nconst runType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst messageHash = computeMessageHash(digestData.mensagem);\nconst guard = evaluateSendGuard({\n config, existingRecords, reportDate: digestData.dateStr, runType, messageHash,\n});\n\nreturn [{\n json: {\n mensagem: digestData.mensagem,\n dateStr: digestData.dateStr,\n runType,\n messageHash,\n shouldSend: guard.shouldSend,\n reason: guard.reason,\n },\n}];"
}
},
{
"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": "C0BJNBTPFT5"
},
"text": "={{ $json.mensagem }}",
"otherOptions": {
"includeLinkToWorkflow": false
}
},
"credentials": {
"slackApi": {
"id": "lNqwBBrtLfkRqw2X",
"name": "Slack - Bot Coordenador (Chamados Suporte)"
}
}
},
{
"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 gate principal de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22). Runs 'manual' tem uma segunda\n// trava: mesmo com SEND_ENABLED=true (producao), clicar \"Execute Workflow\" NAO envia de\n// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por\n// alguem testando/depurando em producao (missao \"correcao final\", secao 16/17).\nfunction shouldSendByConfig(config, runType) {\n if (!config || !config.sendEnabled) return false;\n if (runType === 'manual' && !config.forceSend) return false;\n return true;\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config, runType)) {\n const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';\n return { shouldSend: false, reason };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de sucesso do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registrar sent_at/status so DEPOIS da confirmacao real do Slack -- nunca antes (missao\n// \"finalizacao para producao\", secao 19).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'success',\n});\n\nreturn [{ json: record }];"
}
},
{
"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 gate principal de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22). Runs 'manual' tem uma segunda\n// trava: mesmo com SEND_ENABLED=true (producao), clicar \"Execute Workflow\" NAO envia de\n// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por\n// alguem testando/depurando em producao (missao \"correcao final\", secao 16/17).\nfunction shouldSendByConfig(config, runType) {\n if (!config || !config.sendEnabled) return false;\n if (runType === 'manual' && !config.forceSend) return false;\n return true;\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config, runType)) {\n const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';\n return { shouldSend: false, reason };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de erro do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registra status=failed com o erro real ANTES de propagar a falha (proximo node lanca o\n// erro de verdade, pra execucao ficar visivelmente vermelha no n8n -- nao silenciar).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst errorItem = $input.first().json || {};\nconst errorMessage = String(errorItem.error || errorItem.message || 'Erro desconhecido ao enviar mensagem ao Slack').slice(0, 500);\n\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'failed',\n errorDetail: errorMessage,\n});\n\nreturn [{ json: record }];"
}
},
{
"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"
},
"resource": "row",
"columns": {
"mappingMode": "defineBelow",
"value": {
"report_date": "={{ $json.report_date }}",
"run_type": "={{ $json.run_type }}",
"message_hash": "={{ $json.message_hash }}",
"sent_at": "={{ $json.sent_at }}",
"status": "={{ $json.status }}",
"error_detail": "={{ $json.error_detail }}"
}
}
}
},
{
"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"
},
"resource": "row",
"columns": {
"mappingMode": "defineBelow",
"value": {
"report_date": "={{ $json.report_date }}",
"run_type": "={{ $json.run_type }}",
"message_hash": "={{ $json.message_hash }}",
"sent_at": "={{ $json.sent_at }}",
"status": "={{ $json.status }}",
"error_detail": "={{ $json.error_detail }}"
}
}
}
},
{
"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": []
}