Files
SLACK_COORD_RELATORIO/n8n/deployed/coordenador-glpi-v2.n8n.json
T
GustavoandClaude Sonnet 5 ea105d525c fix(v2): three real bugs found by an actual end-to-end run against production GLPI data
Ran the deployed workflow for real (temporary webhook trigger, briefly
activated, immediately deactivated and removed after each attempt) against
the real GLPI MySQL database and a real OpenAI credential. Found and fixed:

1. $env access is blocked on this n8n instance (N8N_BLOCK_ENV_ACCESS_IN_NODE).
   parseConfig($env) threw "access to env vars denied" on the very first
   Code node. Config now comes from the "Config - Coordenador GLPI v2" Set
   node instead (same pattern the V1 workflow already used) -- expanded
   that node with all 13 tunable parameters, and pointed the 3 affected
   node drivers (build-sql-candidatos, decide-needs-ai, select-top10) at
   it instead of $env.

2. Data Table "upsert" operation requires both `columns` (values) and a
   separate `filters` (match condition) -- I had only set columns with
   matchingColumns, which isn't enough; n8n refused to activate the
   workflow ("Missing or invalid required parameters: filters"). Added
   filters.conditions matching on ticket_id to both memory-writing Data
   Table nodes.

3. Real architecture bug: "Data Table - Buscar Memoria" sat inline in the
   41-candidate chain (Mesclar Contexto -> Buscar Memoria -> Decidir
   Necessidade de IA). A bulk "get all rows" Data Table node run with 41
   input items produced 41 identical fetches and its OWN output (the empty
   memory table on a first run) replaced the 41 candidates entirely --
   "Decidir Necessidade de IA" received 0 items and the whole main branch
   silently skipped. Moved the memory fetch to a parallel branch off
   Config (fires once) and added a real Merge node ("Merge - Aguardar
   Memoria") to guarantee both the candidates and the memory rows have
   executed before "Decidir Necessidade de IA" runs -- same synchronization
   pattern already used for context and for the shadow comparison.

Also fixed a cosmetic bug found in the real message output: buildTopMessage
produced a quadruple newline between the header and the first ticket block
([header, '', ...blocks].join('\n\n') double-counts the blank line already
inserted by the '\n\n' join). Added a regression test.

Verified with a real execution (id 13599, 24/08/2026 19:25 UTC) against the
live GLPI database: 41 real candidates, 30 sent to OpenAI (capped), 11
fallback-capped, memory table populated with 41 real rows including
genuine WAITING_VENDOR/WAITING_REQUESTER classifications with real
blocker_type/waiting_for fields extracted from real ticket task content,
and a real shadow_runs row comparing Legacy x V2 (4 tickets removed by V2:
2 for INSUFFICIENT_CONTEXT, 1 WAITING_REQUESTER, 1 WAITING_VENDOR; 4 added).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:30:13 -03:00

1158 lines
78 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 (desligado em DEV)",
"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.suaempresa.com.br"
},
{
"id": "cfg-ai-enabled",
"name": "AI_ENABLED",
"type": "boolean",
"value": true
},
{
"id": "cfg-ai-provider",
"name": "AI_PROVIDER",
"type": "string",
"value": "openai"
},
{
"id": "cfg-ai-model",
"name": "AI_MODEL",
"type": "string",
"value": "gpt-4o-mini"
},
{
"id": "cfg-candidate-pool-size",
"name": "CANDIDATE_POOL_SIZE",
"type": "number",
"value": 60
},
{
"id": "cfg-ai-max-candidates",
"name": "AI_MAX_CANDIDATES",
"type": "number",
"value": 30
},
{
"id": "cfg-top-n",
"name": "TOP_N",
"type": "number",
"value": 10
},
{
"id": "cfg-cooldown-days",
"name": "COOLDOWN_DAYS",
"type": "number",
"value": 1
},
{
"id": "cfg-ai-confidence-threshold",
"name": "AI_CONFIDENCE_THRESHOLD",
"type": "number",
"value": 0.6
},
{
"id": "cfg-ai-max-context-chars",
"name": "AI_MAX_CONTEXT_CHARS",
"type": "number",
"value": 4000
},
{
"id": "cfg-dev-mode",
"name": "DEV_MODE",
"type": "boolean",
"value": true
},
{
"id": "cfg-shadow-mode",
"name": "SHADOW_MODE",
"type": "boolean",
"value": true
},
{
"id": "cfg-dias-sem-interacao-alerta",
"name": "DIAS_SEM_INTERACAO_ALERTA",
"type": "number",
"value": 2
},
{
"id": "cfg-horas-sla-risco",
"name": "HORAS_SLA_RISCO",
"type": "number",
"value": 8
}
]
},
"options": {}
}
},
{
"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 aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\n// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),\n// entao a configuracao vem do node \"Config - Coordenador GLPI v2\" (mesmo padrao do V1),\n// nao de variaveis de ambiente reais.\nconst configSource = $('Config - Coordenador GLPI v2').first().json;\nconst config = parseConfig(configSource);\nconst glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];"
}
},
{
"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": "function normalizeTicket(row, extras) {\n const followups = (extras && extras.followups) || [];\n const tasks = (extras && extras.tasks) || [];\n return {\n id: Number(row.id),\n titulo: row.titulo,\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\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`)\n .join('\\n');\n const header = [\n `Chamado #${ticket.id} - ${ticket.titulo}`,\n `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`,\n `Tecnico: ${ticket.tecnico_atribuido}`,\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 const body = [\n 'Ultimos apontamentos:',\n followupsText || '(nenhum)',\n '',\n 'Ultimas tarefas:',\n tasksText || '(nenhuma)',\n ].join('\\n');\n return truncate(`${header}\\n\\n${body}`, 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": "function normalizeTicket(row, extras) {\n const followups = (extras && extras.followups) || [];\n const tasks = (extras && extras.tasks) || [];\n return {\n id: Number(row.id),\n titulo: row.titulo,\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\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`)\n .join('\\n');\n const header = [\n `Chamado #${ticket.id} - ${ticket.titulo}`,\n `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`,\n `Tecnico: ${ticket.tecnico_atribuido}`,\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 const body = [\n 'Ultimos apontamentos:',\n followupsText || '(nenhum)',\n '',\n 'Ultimas tarefas:',\n tasksText || '(nenhuma)',\n ].join('\\n');\n return truncate(`${header}\\n\\n${body}`, 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'];\n\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}`).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 aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst now = Date.now();\n\n// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a\n// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.\nconst items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n const analysis = needsReanalysis({ memory, currentHash: item.contextHash, now });\n return { ticket: item.ticket, contextHash: item.contextHash, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});"
}
},
{
"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": "function normalizeTicket(row, extras) {\n const followups = (extras && extras.followups) || [];\n const tasks = (extras && extras.tasks) || [];\n return {\n id: Number(row.id),\n titulo: row.titulo,\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\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`)\n .join('\\n');\n const header = [\n `Chamado #${ticket.id} - ${ticket.titulo}`,\n `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`,\n `Tecnico: ${ticket.tecnico_atribuido}`,\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 const body = [\n 'Ultimos apontamentos:',\n followupsText || '(nenhum)',\n '',\n 'Ultimas tarefas:',\n tasksText || '(nenhuma)',\n ].join('\\n');\n return truncate(`${header}\\n\\n${body}`, budget);\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nconst systemPrompt = [\n 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',\n 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',\n 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',\n 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',\n 'Idade do chamado sozinha NAO e evidencia de acionabilidade.',\n 'Identifique: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos que justifique a conclusao?',\n 'Se a informacao for insuficiente para decidir com confianca, use a classificacao INSUFFICIENT_CONTEXT e confidence baixo -- nao invente uma acao.',\n `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas).`,\n].join('\\n');\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);\n\n const requestBody = {\n model: data.config.aiModel,\n temperature: 0.1,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'ticket_classification',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n classification: { type: 'string', enum: VALID_CLASSIFICATIONS },\n actionable_today: { type: 'boolean' },\n confidence: { type: 'number' },\n blocker_type: { type: ['string', 'null'] },\n waiting_for: { type: ['string', 'null'] },\n next_action: { type: 'string' },\n suggested_action_type: { type: 'string' },\n business_risk: { type: 'string' },\n reason: { type: 'string' },\n evidence: { type: 'array', items: { type: 'string' } },\n },\n required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],\n },\n },\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n return { json: { ...data, requestBody } };\n});"
}
},
{
"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\nfunction validateAiClassification(raw) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.length > 200) errors.push('next_action invalido');\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: raw.blocker_type ?? null,\n waiting_for: raw.waiting_for ?? null,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n },\n };\n}\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: 'ACTIONABLE_NOW',\n actionable_today: true,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nconst promptItems = $('Code - Preparar Prompt IA').all();\nconst responseItems = $input.all();\n\nreturn responseItems.map((responseItem, index) => {\n const original = (promptItems[index] && promptItems[index].json) || {};\n const httpJson = responseItem.json || {};\n\n if (httpJson.error) {\n return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };\n }\n\n let raw = null;\n try {\n const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;\n raw = content ? JSON.parse(content) : null;\n } catch (e) {\n raw = null;\n }\n\n const validation = validateAiClassification(raw);\n const aiClassification = validation.valid ? validation.data : buildFallbackClassification('invalid_json');\n\n return { json: { ...original, aiClassification } };\n});"
}
},
{
"id": "code-fallback-ia-desligada",
"name": "Code - Fallback IA Desligada",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1280,
-160
],
"parameters": {
"jsCode": "function buildFallbackClassification(reason) {\n return {\n classification: 'ACTIONABLE_NOW',\n actionable_today: true,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente',\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": "function buildFallbackClassification(reason) {\n return {\n classification: 'ACTIONABLE_NOW',\n actionable_today: true,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente',\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 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 is_fallback: false,\n fallback_reason: 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\nconst BLOCKING_CLASSIFICATIONS = new Set([\n 'WAITING_REQUESTER', 'WAITING_VENDOR', 'SCHEDULED', 'DEPENDENCY_BLOCKED',\n]);\n\nfunction isCriticalOverride(ticket) {\n return Boolean(ticket.sla_vencido) && Number(ticket.prioridade) >= 4;\n}\n\nfunction computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {\n const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0;\n const lastSentAt = memory ? memory.last_sent_at : null;\n const nextReviewAt = memory ? memory.next_review_at : null;\n const critical = isCriticalOverride(ticket);\n\n let blocked = false;\n let penalty = 0;\n let reason = 'ok';\n let breakCooldown = false;\n\n if (classification === 'NEEDS_COORDINATION') {\n blocked = true;\n reason = 'needs_coordination';\n } else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) {\n blocked = true;\n reason = 'waiting_next_review';\n } else if (BLOCKING_CLASSIFICATIONS.has(classification) && !contextChanged) {\n blocked = true;\n reason = `blocked_${classification.toLowerCase()}`;\n }\n\n const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5;\n if (!contextChanged && sentRecently && consecutiveDaysSent > 0) {\n const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15;\n penalty = Math.min(90, perDayBase * consecutiveDaysSent);\n if (!blocked) reason = 'repeat_no_change';\n }\n\n if (blocked && critical && classification !== 'NEEDS_COORDINATION') {\n breakCooldown = true;\n blocked = false;\n penalty = Math.round(penalty / 2);\n reason = `${reason}_override_critical`;\n }\n\n return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };\n}\n\nconst CLASSIFICATION_BASE_POINTS = {\n ACTIONABLE_NOW: 200,\n UNASSIGNED_ACTIONABLE: 180,\n WAITING_APPROVAL: 40,\n INSUFFICIENT_CONTEXT: 0,\n WAITING_REQUESTER: -150,\n WAITING_VENDOR: -150,\n DEPENDENCY_BLOCKED: -150,\n PROJECT_LONG_TERM: -120,\n DUPLICATE_OR_OBSOLETE: -200,\n SCHEDULED: -1000,\n NEEDS_COORDINATION: -1000,\n};\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\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;\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 return {\n score: Math.round(total * 100) / 100,\n group,\n excludedFromTop10,\n exclusionReason: excludedFromTop10 ? (scheduledNotDue ? 'scheduled_not_due' : cooldown.reason) : null,\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 last_context_hash: data.contextHash,\n last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),\n ai_classification: cls,\n ai_confidence: data.aiClassification.confidence,\n blocker_type: data.aiClassification.blocker_type,\n waiting_for: data.aiClassification.waiting_for,\n next_action: data.aiClassification.next_action,\n next_review_at: nextReviewAt,\n last_score: data.scoreResult.score,\n last_ai_reason: data.aiClassification.reason,\n updated_at: data.now,\n },\n };\n});"
}
},
{
"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 aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n config: data.config,\n now: data.now,\n}));\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];"
}
},
{
"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\nreturn $input.all()\n .filter((item) => item.json._grupo === 'selected')\n .map((item) => {\n const data = item.json;\n const memory = data.memory;\n const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;\n const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;\n const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;\n const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;\n return {\n json: {\n ticket_id: data.ticket.id,\n last_sent_at: data.now,\n times_sent: timesSent,\n times_sent_30d: timesSent30d,\n consecutive_days_sent: consecutiveDaysSent,\n updated_at: data.now,\n },\n };\n });"
}
},
{
"id": "datatable-registrar-envio",
"name": "Data Table - Registrar Envio",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1.1,
"position": [
2960,
-560
],
"parameters": {
"resource": "row",
"operation": "upsert",
"dataTableId": {
"mode": "name",
"value": "coordenador_glpi_memoria"
},
"filters": {
"conditions": [
{
"keyName": "ticket_id",
"condition": "eq",
"keyValue": "={{ $json.ticket_id }}"
}
]
},
"columns": {
"mappingMode": "defineBelow",
"matchingColumns": [
"ticket_id"
],
"value": {
"ticket_id": "={{ $json.ticket_id }}",
"last_sent_at": "={{ $json.last_sent_at }}",
"times_sent": "={{ $json.times_sent }}",
"times_sent_30d": "={{ $json.times_sent_30d }}",
"consecutive_days_sent": "={{ $json.consecutive_days_sent }}",
"updated_at": "={{ $json.updated_at }}"
}
}
}
},
{
"id": "code-montar-mensagem-curta",
"name": "Code - Montar Mensagem Curta",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2720,
-320
],
"parameters": {
"jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];"
}
},
{
"id": "code-montar-digest-coordenacao",
"name": "Code - Montar Digest Coordenacao",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
-320
],
"parameters": {
"jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];"
}
},
{
"id": "merge-unir-shadow",
"name": "Merge - Unir Shadow",
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3200,
160
],
"parameters": {
"mode": "append",
"numberInputs": 2
}
},
{
"id": "code-comparar-legacy-v2",
"name": "Code - Comparar Legacy x V2",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3440,
160
],
"parameters": {
"jsCode": "// 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\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 }),\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": "debug-ver-painel",
"name": "Debug - Ver Painel",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-1600,
1200
],
"parameters": {
"httpMethod": "GET",
"path": "coordenador-glpi-v2-debug",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "datatable-buscar-shadow-runs-recentes",
"name": "Data Table - Buscar Shadow Runs Recentes",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1.1,
"position": [
-1360,
1200
],
"parameters": {
"resource": "row",
"operation": "get",
"dataTableId": {
"mode": "name",
"value": "coordenador_glpi_shadow_runs"
},
"filters": {
"conditions": []
},
"returnAll": true
}
},
{
"id": "code-montar-painel-debug",
"name": "Code - Montar Painel Debug",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1120,
1200
],
"parameters": {
"jsCode": "const rows = $input.all().map((item) => item.json);\nrows.sort((a, b) => Number(b.run_at || 0) - Number(a.run_at || 0));\nconst latest = rows[0] || null;\n\nconst painel = {\n gerado_em: new Date().toISOString(),\n ultima_execucao_shadow: latest,\n total_execucoes_recentes: rows.length,\n};\n\nreturn [{ json: painel }];"
}
},
{
"id": "debug-responder",
"name": "Debug - Responder",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
-880,
1200
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Config - Coordenador GLPI v2",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger - Dias uteis 08h (desligado em DEV)": {
"main": [
[
{
"node": "Config - Coordenador GLPI v2",
"type": "main",
"index": 0
}
]
]
},
"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
},
{
"node": "Code - Preparar Registro de Envio",
"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
}
]
]
},
"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
}
]
]
},
"Debug - Ver Painel": {
"main": [
[
{
"node": "Data Table - Buscar Shadow Runs Recentes",
"type": "main",
"index": 0
}
]
]
},
"Data Table - Buscar Shadow Runs Recentes": {
"main": [
[
{
"node": "Code - Montar Painel Debug",
"type": "main",
"index": 0
}
]
]
},
"Code - Montar Painel Debug": {
"main": [
[
{
"node": "Debug - Responder",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "",
"meta": {
"templateCredsSetupCompleted": false
},
"tags": []
}