{ "name": "[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)", "nodes": [ { "id": "manual-trigger-v2", "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ -1600, 0 ], "parameters": {} }, { "id": "schedule-trigger-v2-desligado", "name": "Schedule Trigger - Dias uteis 08h", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [ -1600, 900 ], "parameters": { "rule": { "interval": [ { "field": "cronExpression", "expression": "0 8 * * 1-5" } ] } } }, { "id": "config-v2", "name": "Config - Coordenador GLPI v2", "type": "n8n-nodes-base.set", "typeVersion": 3.5, "position": [ -1360, 0 ], "parameters": { "assignments": { "assignments": [ { "id": "glpi-base-url-v2", "name": "glpi_base_url", "type": "string", "value": "https://glpi.gtigrupo.com.br" }, { "id": "cfg-ai-enabled", "name": "AI_ENABLED", "type": "boolean", "value": true }, { "id": "cfg-ai-provider", "name": "AI_PROVIDER", "type": "string", "value": "openai" }, { "id": "cfg-ai-model", "name": "AI_MODEL", "type": "string", "value": "gpt-4o-mini" }, { "id": "cfg-candidate-pool-size", "name": "CANDIDATE_POOL_SIZE", "type": "number", "value": 60 }, { "id": "cfg-ai-max-candidates", "name": "AI_MAX_CANDIDATES", "type": "number", "value": 60 }, { "id": "cfg-top-n", "name": "TOP_N", "type": "number", "value": 10 }, { "id": "cfg-cooldown-days", "name": "COOLDOWN_DAYS", "type": "number", "value": 1 }, { "id": "cfg-ai-confidence-threshold", "name": "AI_CONFIDENCE_THRESHOLD", "type": "number", "value": 0.6 }, { "id": "cfg-ai-max-context-chars", "name": "AI_MAX_CONTEXT_CHARS", "type": "number", "value": 4000 }, { "id": "cfg-dev-mode", "name": "DEV_MODE", "type": "boolean", "value": true }, { "id": "cfg-shadow-mode", "name": "SHADOW_MODE", "type": "boolean", "value": true }, { "id": "cfg-dias-sem-interacao-alerta", "name": "DIAS_SEM_INTERACAO_ALERTA", "type": "number", "value": 2 }, { "id": "cfg-horas-sla-risco", "name": "HORAS_SLA_RISCO", "type": "number", "value": 8 }, { "id": "cfg-environment", "name": "ENVIRONMENT", "type": "string", "value": "dev" }, { "id": "cfg-send-enabled", "name": "SEND_ENABLED", "type": "boolean", "value": false } ] }, "options": {} } }, { "id": "code-montar-sql-candidatos", "name": "Code - Montar SQL Candidatos", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -1120, -160 ], "parameters": { "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\n}\n\n// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),\n// entao a configuracao vem do node \"Config - Coordenador GLPI v2\" (mesmo padrao do V1),\n// nao de variaveis de ambiente reais.\nconst configSource = $('Config - Coordenador GLPI v2').first().json;\nconst config = parseConfig(configSource);\nconst glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.content AS descricao, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, descricao, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];" } }, { "id": "code-montar-sql-legado", "name": "Code - Montar SQL Legado", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -1120, 900 ], "parameters": { "jsCode": "const config = $('Config - Coordenador GLPI v2').first().json;\nconst glpiBaseUrl = String(config.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Top 10 chamados abertos por blocos operacionais.\n-- Blocos:\n-- - 3 chamados com SLA vencido mais criticos\n-- - 3 chamados com maior tempo sem interacao\n-- - 2 chamados sem tecnico atribuido\n-- - 2 chamados de prioridade alta mais recentes\n-- Se faltar item em algum bloco, o complemento vem do ranking geral.\n--\n-- Ajuste os parametros abaixo no workflow n8n:\n-- {DIAS_SEM_INTERACAO_ALERTA}\n-- {HORAS_SLA_RISCO}\n-- {GLPI_BASE_URL}\n\nWITH ultima_interacao AS (\n SELECT\n tickets_id,\n MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_mod AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n\n UNION ALL\n\n SELECT items_id AS tickets_id, date_creation AS data_interacao\n FROM glpi_itilfollowups\n WHERE itemtype = 'Ticket'\n AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n\n UNION ALL\n\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_mod AS data_interacao\n FROM glpi_tickettasks\n\n UNION ALL\n\n SELECT tickets_id, date_creation AS data_interacao\n FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT\n tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id,\n t.name AS titulo,\n t.status,\n CASE t.status\n WHEN 1 THEN 'Novo'\n WHEN 2 THEN 'Em atendimento'\n WHEN 3 THEN 'Planejado'\n WHEN 4 THEN 'Pendente'\n ELSE CONCAT('Status ', t.status)\n END AS status_nome,\n t.date AS aberto_em,\n t.date_mod AS modificado_em,\n t.time_to_resolve AS sla_resolver_ate,\n t.priority,\n t.urgency,\n t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(\n COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),\n COALESCE(t.date_mod, '1970-01-01 00:00:00'),\n COALESCE(t.date, '1970-01-01 00:00:00')\n ), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE\n WHEN t.time_to_resolve IS NOT NULL\n AND t.time_to_resolve >= NOW()\n AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1\n ELSE 0\n END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id,\n titulo,\n status_nome,\n aberto_em,\n modificado_em,\n ultima_interacao_em,\n dias_aberto,\n dias_sem_interacao,\n sla_resolver_ate,\n horas_ate_sla,\n sla_vencido,\n sla_em_risco,\n priority AS prioridade,\n urgency AS urgencia,\n impact AS impacto,\n entidade,\n categoria,\n tecnico_atribuido,\n sem_tecnico,\n link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE\n WHEN dias_sem_interacao >= 7 THEN 35\n WHEN dias_sem_interacao >= 3 THEN 25\n WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10\n ELSE 0\n END\n + LEAST(dias_aberto, 30)\n + (priority * 8)\n + (urgency * 5)\n + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n), sla_top AS (\n SELECT *, 'SLA vencido' AS bloco_origem, 1 AS bloco_ordem\n FROM chamados_ranked\n WHERE sla_vencido = 1\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 3\n), sem_interacao_top AS (\n SELECT *, 'Sem interacao' AS bloco_origem, 2 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM sla_top)\n ORDER BY dias_sem_interacao DESC, score_criticidade DESC, dias_aberto DESC\n LIMIT 3\n), sem_tecnico_top AS (\n SELECT *, 'Sem tecnico' AS bloco_origem, 3 AS bloco_ordem\n FROM chamados_ranked\n WHERE sem_tecnico = 1\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 2\n), prioridade_alta_recente_top AS (\n SELECT *, 'Prioridade alta recente' AS bloco_origem, 4 AS bloco_ordem\n FROM chamados_ranked\n WHERE prioridade >= 4\n AND id NOT IN (SELECT id FROM sla_top)\n AND id NOT IN (SELECT id FROM sem_interacao_top)\n AND id NOT IN (SELECT id FROM sem_tecnico_top)\n ORDER BY aberto_em DESC, score_criticidade DESC\n LIMIT 2\n), selecionados AS (\n SELECT * FROM sla_top\n UNION ALL\n SELECT * FROM sem_interacao_top\n UNION ALL\n SELECT * FROM sem_tecnico_top\n UNION ALL\n SELECT * FROM prioridade_alta_recente_top\n), complemento AS (\n SELECT *, 'Ranking geral' AS bloco_origem, 5 AS bloco_ordem\n FROM chamados_ranked\n WHERE id NOT IN (SELECT id FROM selecionados)\n ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\n LIMIT 10\n)\nSELECT *\nFROM (\n SELECT * FROM selecionados\n UNION ALL\n SELECT * FROM complemento\n) ranking_final\nORDER BY bloco_ordem ASC, score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT 10;\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', '2')\n .replaceAll('{HORAS_SLA_RISCO}', '8')\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl);\n\nreturn [{ json: { query } }];" } }, { "id": "mysql-buscar-candidatos", "name": "MySQL - Buscar Candidatos", "type": "n8n-nodes-base.mySql", "typeVersion": 2.5, "position": [ -880, -160 ], "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } }, { "id": "mysql-legado-top10-shadow", "name": "MySQL - Legado Top 10 (Shadow)", "type": "n8n-nodes-base.mySql", "typeVersion": 2.5, "position": [ -880, 900 ], "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } }, { "id": "code-normalizar-candidatos-base", "name": "Code - Normalizar Candidatos Base", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -640, -160 ], "parameters": { "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst items = $input.all();\nreturn items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));" } }, { "id": "code-montar-sql-contexto", "name": "Code - Montar SQL Contexto", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -400, -160 ], "parameters": { "jsCode": "const candidatos = $input.all().map((item) => item.json);\nconst ids = candidatos.map((c) => Number(c.id)).filter((id) => Number.isInteger(id));\nconst idsList = ids.length ? ids.join(',') : '0';\n\nconst queryTemplate = `-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query\n-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.\n-- IDs interpolados pelo node como lista de inteiros (nunca string crua do GLPI).\n-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a\n-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.\n\nSELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status\nFROM glpi_itilfollowups\nWHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})\n\nUNION ALL\n\nSELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status\nFROM glpi_tickettasks\nWHERE tickets_id IN ({IDS})\n\nORDER BY tickets_id, date ASC;\n`;\nconst query = queryTemplate.replaceAll('{IDS}', idsList);\n\nreturn [{ json: { query, candidateIds: ids } }];" } }, { "id": "mysql-buscar-contexto", "name": "MySQL - Buscar Contexto", "type": "n8n-nodes-base.mySql", "typeVersion": 2.5, "position": [ -160, -160 ], "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } }, { "id": "code-mesclar-contexto-hash", "name": "Code - Mesclar Contexto e Calcular Hash", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 80, -160 ], "parameters": { "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nfunction stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];\n\n// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint\n// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma\n// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca\n// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\nconst candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);\nconst contextRows = $input.all().map((item) => item.json);\n\nfunction groupBy(rows, key) {\n const map = {};\n for (const row of rows) {\n const k = String(row[key]);\n if (!map[k]) map[k] = [];\n map[k].push(row);\n }\n return map;\n}\n\nconst followupRows = contextRows.filter((row) => row.tipo === 'followup');\nconst taskRows = contextRows.filter((row) => row.tipo === 'task');\n\nconst followupsByTicket = groupBy(followupRows, 'tickets_id');\nconst tasksByTicket = groupBy(taskRows, 'tickets_id');\n\nreturn candidatosBase.map((base) => {\n const idKey = String(base.id);\n const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));\n const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));\n const ticket = normalizeTicket(base, { followups, tasks });\n const contextHash = buildContextFingerprint(ticket);\n return { json: { ticket, contextHash } };\n});" } }, { "id": "datatable-buscar-memoria", "name": "Data Table - Buscar Memoria", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ -1120, 300 ], "parameters": { "resource": "row", "operation": "get", "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, "filters": { "conditions": [] }, "returnAll": true } }, { "id": "merge-aguardar-memoria", "name": "Merge - Aguardar Memoria", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, "position": [ 320, -160 ], "parameters": { "mode": "append", "numberInputs": 2 } }, { "id": "code-decidir-necessidade-ia", "name": "Code - Decidir Necessidade de IA", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 560, -160 ], "parameters": { "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\n// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO\n// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe\n// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao\n// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem\n// precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\nfunction buildCacheKey(contextHash, classifierVersion) {\n return `${contextHash}:v${classifierVersion}`;\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\nconst AI_CLASSIFIER_VERSION = 3;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\n}\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst now = Date.now();\n\n// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a\n// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.\nconst items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n // Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de\n // AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no\n // GLPI -- sem precisar apagar a Data Table na mao (missao \"finalizacao para producao\", secao 12).\n const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);\n const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });\n return {\n ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,\n };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n cacheKey: item.cacheKey,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});" } }, { "id": "if-precisa-ia", "name": "IF - Precisa IA?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 800, -160 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "cond-precisa-ia", "leftValue": "={{ $json.precisa_ia }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "if-ia-ligada", "name": "IF - IA Ligada?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 1040, -320 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "cond-ia-ligada", "leftValue": "={{ $json.config.aiEnabled }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "code-preparar-prompt-ia", "name": "Code - Preparar Prompt IA", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1280, -400 ], "parameters": { "jsCode": "const NAMED_ENTITIES = {\n ' ': ' ',\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n};\n\nconst MAX_DECODE_ITERATIONS = 5;\n\nfunction decodeEntitiesOnce(text) {\n let result = text.replace(/&#(\\d+);/g, (match, code) => {\n const num = Number(code);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {\n const num = parseInt(hex, 16);\n return Number.isFinite(num) ? String.fromCodePoint(num) : match;\n });\n for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {\n result = result.split(entity).join(char);\n }\n return result;\n}\n\nfunction stripHtmlTags(text) {\n return text.replace(/<[^>]*>/g, ' ');\n}\n\n// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS\n// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,\n// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto\n// que nao seja literalmente uma entidade HTML ou uma tag).\nfunction normalizeGlpiText(rawText) {\n if (rawText === null || rawText === undefined) return '';\n let text = String(rawText);\n\n for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {\n const decoded = decodeEntitiesOnce(text);\n if (decoded === text) break;\n text = decoded;\n }\n\n text = stripHtmlTags(text);\n\n text = text\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\n return text;\n}\n\nfunction normalizeTicket(row, extras) {\n const followups = ((extras && extras.followups) || []).map((f) => ({\n id: f.id,\n date: f.date,\n texto: normalizeGlpiText(f.texto),\n }));\n const tasks = ((extras && extras.tasks) || []).map((t) => ({\n id: t.id,\n date: t.date,\n status: t.status,\n texto: normalizeGlpiText(t.texto),\n }));\n return {\n id: Number(row.id),\n titulo: normalizeGlpiText(row.titulo),\n descricao: normalizeGlpiText(row.descricao),\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\n// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do\n// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).\nfunction truncatePreservingLines(text, maxChars) {\n const str = String(text || '');\n return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;\n}\n\n// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /\n// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega\n// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)\n .join('\\n');\n\n const secoes = [\n `TITULO\\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,\n `DESCRICAO ORIGINAL\\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,\n [\n `STATUS: ${ticket.status_nome}`,\n `TECNICO: ${ticket.tecnico_atribuido}`,\n `GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,\n `PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n'),\n `FOLLOW-UPS\\n${followupsText || '(nenhum)'}`,\n `TAREFAS\\n${tasksText || '(nenhuma)'}`,\n ];\n\n const contextoCompleto = `Chamado #${ticket.id}\\n\\n${secoes.join('\\n\\n')}`;\n return truncatePreservingLines(contextoCompleto, budget);\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nconst systemPrompt = [\n 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',\n 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',\n 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',\n 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',\n 'Idade do chamado sozinha NAO e evidencia de acionabilidade.',\n '',\n '=== O QUE SIGNIFICA ACIONAVEL (ACTIONABLE_NOW / UNASSIGNED_ACTIONABLE) ===',\n 'ACTIONABLE_NOW significa: existe pelo menos uma acao concreta que um integrante do suporte pode executar AGORA para fazer o chamado avancar. NAO precisa significar que o chamado sera resolvido integralmente hoje -- precisa significar que o suporte consegue fazer algo util agora.',\n 'Isso INCLUI, entre outras: executar diagnostico; testar equipamento; verificar configuracao; solicitar informacao ao cliente (quando ainda NAO foi solicitada); entrar em contato com fornecedor (quando ainda NAO foi contatado); marcar/agendar visita; combinar horario; atribuir tecnico; realizar inventario; validar cenario; executar instalacao; iniciar migracao; cobrar informacao que ainda NAO foi solicitada; atualizar configuracao; reproduzir problema; levantar requisitos; ou qualquer outro proximo passo concreto sob controle do suporte.',\n '',\n '=== CONTATAR != AGUARDAR (diferenca critica) ===',\n 'Precisar contatar alguem (cliente ou fornecedor) que AINDA NAO foi contatado e ACIONAVEL AGORA: ex. \"Precisamos perguntar ao cliente qual horario pode realizar a manutencao\" -> ACTIONABLE_NOW, next_action \"Contatar cliente para combinar horario\". \"Precisamos pedir orcamento ao fornecedor\" -> ACTIONABLE_NOW.',\n 'Ja ter contatado e estar esperando resposta e WAITING_*: ex. \"Cliente ja foi contatado em 23/08 e estamos aguardando resposta\" -> WAITING_REQUESTER. \"Orcamento ja solicitado; aguardando retorno do fornecedor\" -> WAITING_VENDOR.',\n 'So classifique WAITING_REQUESTER, WAITING_VENDOR ou WAITING_APPROVAL quando existir evidencia POSITIVA no historico de que (1) a acao necessaria ja foi executada pelo suporte E (2) existe uma dependencia externa ainda aberta. Sem essa evidencia dupla, NAO assuma espera -- procure se existe uma acao ainda nao realizada primeiro.',\n '',\n '=== TECNICO INTERNO NAO E FORNECEDOR ===',\n 'O campo TECNICO do chamado lista responsaveis/tecnicos internos da equipe de suporte. Se uma pendencia depende de alguem listado nesse campo, NUNCA classifique como WAITING_VENDOR (fornecedor externo) -- normalmente e ACTIONABLE_NOW (se o proprio suporte pode agir) ou UNASSIGNED_ACTIONABLE (se depende de atribuicao). WAITING_VENDOR e reservado para fornecedores/terceiros genuinamente externos a equipe.',\n '',\n '=== STATUS \"PLANEJADO\" NAO SIGNIFICA BLOQUEADO ===',\n 'STATUS Planejado sozinho NAO significa WAITING_REQUESTER, WAITING_VENDOR nem SCHEDULED. Se existe tarefa ou apontamento descrevendo um passo operacional disponivel agora, o chamado pode ser ACTIONABLE_NOW mesmo estando \"Planejado\".',\n 'Use SCHEDULED apenas quando existir data, horario, janela ou evento futuro explicito no chamado (ou evidencia inequivoca de que nenhuma acao pode/deve ocorrer antes dessa data).',\n '',\n '=== CRONOLOGIA E OBRIGATORIA ===',\n 'Interprete os apontamentos em ordem temporal -- o mais recente relevante prevalece sobre um bloqueio antigo ja superado. Exemplo: \"20/08 aguardando fornecedor\" seguido de \"22/08 pecas chegaram\" seguido de \"23/08 falta instalar\" -> ACTIONABLE_NOW (instalar pecas), NAO WAITING_VENDOR. Nao se prenda a um estado antigo se um apontamento mais novo o superou.',\n '',\n '=== NEXT_ACTION DEVE SER CONCRETO ===',\n 'Para ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE, next_action precisa nomear a acao real: \"Realizar inventario das maquinas\", \"Contatar cliente para combinar recolha\", \"Testar nobreak com carga\", \"Atribuir tecnico para visita\", \"Iniciar migracao dos e-mails\", \"Validar configuracao da impressora\", \"Solicitar dados de acesso ao cliente\".',\n 'NUNCA aceite como next_action frases genericas que nao provam acionabilidade nenhuma: \"Revisar chamado\", \"Dar andamento\", \"Analisar situacao\", \"Verificar chamado\", \"Acompanhar\". Se voce so consegue pensar numa frase generica dessas, reconsidere a classificacao -- provavelmente falta evidencia concreta.',\n '',\n '=== QUANDO USAR INSUFFICIENT_CONTEXT ===',\n 'Antes de classificar como INSUFFICIENT_CONTEXT, pergunte-se: existe alguma acao concreta que o suporte consegue executar mesmo sem informacao completa? Falta de diagnostico nao significa falta de acao -- ex. \"nao sabemos por que o notebook nao conecta\" ainda pode ser ACTIONABLE_NOW com next_action \"Realizar diagnostico de conectividade\".',\n 'Use INSUFFICIENT_CONTEXT somente quando realmente nao for possivel determinar um proximo passo razoavelmente especifico -- nem mesmo um passo de diagnostico/investigacao.',\n '',\n 'Identifique sempre: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio (interno ou externo)? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos, em ordem cronologica, que justifique a conclusao?',\n `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo, deve ser uma acao concreta quando ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas, em ordem cronologica quando relevante).`,\n].join('\\n');\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);\n\n const requestBody = {\n model: data.config.aiModel,\n temperature: 0.1,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'ticket_classification',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n classification: { type: 'string', enum: VALID_CLASSIFICATIONS },\n actionable_today: { type: 'boolean' },\n confidence: { type: 'number' },\n blocker_type: { type: ['string', 'null'] },\n waiting_for: { type: ['string', 'null'] },\n next_action: { type: 'string' },\n suggested_action_type: { type: 'string' },\n business_risk: { type: 'string' },\n reason: { type: 'string' },\n evidence: { type: 'array', items: { type: 'string' } },\n },\n required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],\n },\n },\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n return { json: { ...data, requestBody } };\n});" } }, { "id": "http-openai-classificar", "name": "HTTP - OpenAI Classificar Chamado", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1520, -400 ], "continueOnFail": true, "parameters": { "method": "POST", "url": "https://api.openai.com/v1/chat/completions", "authentication": "predefinedCredentialType", "nodeCredentialType": "openAiApi", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.requestBody) }}", "options": { "timeout": 30000 } }, "credentials": { "openAiApi": { "id": "WUGriAn5FgCiLza2", "name": "OpenAI - Coordenador GLPI" } } }, { "id": "code-validar-classificacao-ia", "name": "Code - Validar Classificacao IA", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1760, -400 ], "parameters": { "jsCode": "const VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\n// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de\n// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO\n// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.\nconst AI_CLASSIFIER_VERSION = 3;\n\n// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes\n// devolve a STRING \"null\" (ou variantes equivalentes) em vez do valor JSON null. Normaliza\n// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.\nconst NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);\n\nfunction normalizeNullableString(value) {\n if (value === null || value === undefined) return null;\n const trimmed = String(value).trim();\n if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;\n return trimmed;\n}\n\n// next_action generico nao prova acionabilidade nenhuma -- ver missao \"finalizacao para\n// producao\", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas\n// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle\n// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma\n// frase curta (\"aguardar resposta\") e legitima ali.\nconst ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);\nconst GENERIC_NEXT_ACTION_PHRASES = new Set([\n 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',\n 'analisar situacao', 'analisar a situacao', 'verificar chamado', 'verificar o chamado',\n 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',\n]);\n\nfunction normalizeForComparison(text) {\n return String(text || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar \"situação\" == \"situacao\"\n .replace(/[.!]+$/g, '');\n}\n\nfunction isGenericNextAction(nextAction) {\n return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));\n}\n\n// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca\n// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao \"finalizacao para\n// producao\", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.\nfunction isWaitingForInternalTechnician(waitingFor, ticket) {\n if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;\n const tecnico = normalizeForComparison(ticket.tecnico_atribuido);\n const alvo = normalizeForComparison(waitingFor);\n if (!alvo || tecnico === 'sem tecnico atribuido') return false;\n return tecnico.includes(alvo);\n}\n\nfunction validateAiClassification(raw, ticket) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {\n errors.push('next_action invalido (vazio ou muito longo)');\n } else if (\n ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)\n && isGenericNextAction(raw.next_action)\n ) {\n errors.push(`next_action generico demais para ${raw.classification}: \"${raw.next_action}\"`);\n }\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n const waitingFor = normalizeNullableString(raw.waiting_for);\n if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {\n errors.push(`waiting_for (\"${waitingFor}\") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);\n }\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: normalizeNullableString(raw.blocker_type),\n waiting_for: waitingFor,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n classifier_version: AI_CLASSIFIER_VERSION,\n },\n };\n}\n\n// AI_FALLBACK e um estado tecnico: significa \"a IA nunca analisou este chamado\" (erro,\n// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e\n// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar\n// (ver logic/classification.js). Politica de elegibilidade fica centralizada em\n// logic/cooldown.js (applyFallbackPolicy), nao aqui.\nconst AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: AI_FALLBACK_CLASSIFICATION,\n actionable_today: false,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente (nao analisado pela IA)',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nconst promptItems = $('Code - Preparar Prompt IA').all();\nconst responseItems = $input.all();\n\nreturn responseItems.map((responseItem, index) => {\n const original = (promptItems[index] && promptItems[index].json) || {};\n const httpJson = responseItem.json || {};\n\n if (httpJson.error) {\n return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };\n }\n\n let raw = null;\n try {\n const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;\n raw = content ? JSON.parse(content) : null;\n } catch (e) {\n raw = null;\n }\n\n // Passa o ticket pra validacao poder aplicar a guarda deterministica \"tecnico interno nunca\n // vira WAITING_VENDOR\" (logic/classification.js -> isWaitingForInternalTechnician). Sem\n // isso a validacao so olha o JSON cru, nunca os fatos do proprio chamado.\n const validation = validateAiClassification(raw, original.ticket);\n let aiClassification;\n if (validation.valid) {\n aiClassification = validation.data;\n } else {\n const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno'));\n aiClassification = buildFallbackClassification(isSemanticConflict ? 'ai_semantic_conflict' : 'invalid_json');\n }\n\n return { json: { ...original, aiClassification } };\n});" } }, { "id": "code-fallback-ia-desligada", "name": "Code - Fallback IA Desligada", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1280, -160 ], "parameters": { "jsCode": "// AI_FALLBACK e um estado tecnico: significa \"a IA nunca analisou este chamado\" (erro,\n// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e\n// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar\n// (ver logic/classification.js). Politica de elegibilidade fica centralizada em\n// logic/cooldown.js (applyFallbackPolicy), nao aqui.\nconst AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: AI_FALLBACK_CLASSIFICATION,\n actionable_today: false,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente (nao analisado pela IA)',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nreturn $input.all().map((item) => ({ json: { ...item.json, aiClassification: buildFallbackClassification('ai_disabled') } }));" } }, { "id": "code-reusar-classificacao-cache", "name": "Code - Reusar Classificacao em Cache", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1040, 80 ], "parameters": { "jsCode": "// AI_FALLBACK e um estado tecnico: significa \"a IA nunca analisou este chamado\" (erro,\n// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e\n// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar\n// (ver logic/classification.js). Politica de elegibilidade fica centralizada em\n// logic/cooldown.js (applyFallbackPolicy), nao aqui.\nconst AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: AI_FALLBACK_CLASSIFICATION,\n actionable_today: false,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente (nao analisado pela IA)',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nreturn $input.all().map((item) => {\n const data = item.json;\n if (data.resolucao === 'cache' && data.memory) {\n const m = data.memory;\n const isFallback = m.ai_classification === 'AI_FALLBACK';\n return {\n json: {\n ...data,\n aiClassification: {\n classification: m.ai_classification,\n actionable_today: m.ai_classification === 'ACTIONABLE_NOW' || m.ai_classification === 'UNASSIGNED_ACTIONABLE',\n confidence: Number(m.ai_confidence || 0),\n blocker_type: m.blocker_type || null,\n waiting_for: m.waiting_for || null,\n next_action: m.next_action || 'Revisar chamado',\n suggested_action_type: 'technical_action',\n business_risk: 'medium',\n reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.',\n evidence: [],\n // Precisa refletir de verdade o que foi cacheado -- um AI_FALLBACK cacheado\n // continua sendo fallback (senao a politica de elegibilidade em cooldown.js\n // nao aplicaria a exclusao/override certos numa segunda execucao com cache).\n is_fallback: isFallback,\n fallback_reason: isFallback ? 'cached_ai_fallback' : null,\n },\n },\n };\n }\n return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } };\n});" } }, { "id": "merge-consolidar-classificacoes", "name": "Merge - Consolidar Classificacoes", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, "position": [ 2000, -160 ], "parameters": { "mode": "append", "numberInputs": 3 } }, { "id": "code-calcular-score", "name": "Code - Calcular Score", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2240, -160 ], "parameters": { "jsCode": "const ONE_DAY_MS = 24 * 60 * 60 * 1000;\n\n// Classes que NUNCA disputam o Top10 operacional -- bloqueadas por definicao, nao por cache.\n// Lista expandida na missao \"finalizacao para producao\" (secao 10): WAITING_APPROVAL,\n// PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE entraram aqui (antes so tinham penalidade de\n// score, o que deixava WAITING_APPROVAL -- pontuacao base positiva -- competir de verdade).\nconst BLOCKING_CLASSIFICATIONS = new Set([\n 'WAITING_REQUESTER', 'WAITING_VENDOR', 'WAITING_APPROVAL', 'SCHEDULED',\n 'DEPENDENCY_BLOCKED', 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE',\n]);\n\n// Regra centralizada de \"isso e critico o bastante pra furar uma exclusao normal\" --\n// usada tanto pelo cooldown de reenvio quanto pela politica de AI_FALLBACK/\n// INSUFFICIENT_CONTEXT (applyFallbackPolicy abaixo). Unico lugar que define \"critico\".\nfunction isCriticalOverride(ticket) {\n const prioridadeAlta = Number(ticket.prioridade) >= 4 || Number(ticket.urgencia) >= 4;\n const slaVencidoCritico = Boolean(ticket.sla_vencido) && prioridadeAlta;\n const semTecnicoCritico = Boolean(ticket.sem_tecnico) && prioridadeAlta;\n return slaVencidoCritico || semTecnicoCritico;\n}\n\n// AI_FALLBACK (chamado nunca analisado pela IA de verdade) e INSUFFICIENT_CONTEXT\n// (analisado, mas sem evidencia suficiente) NAO disputam o Top10 normalmente -- so entram\n// via excecao deterministica forte (isCriticalOverride). Centraliza a regra pedida:\n// \"AI_FALLBACK nao pode competir como se fosse acionavel confirmado\".\nfunction applyFallbackPolicy({ aiClassification, ticket }) {\n const critical = isCriticalOverride(ticket);\n\n if (aiClassification.is_fallback) {\n return critical\n ? { blocked: false, decisionReason: 'ai_fallback_critical_override' }\n : { blocked: true, decisionReason: 'ai_fallback_no_override' };\n }\n\n if (aiClassification.classification === 'INSUFFICIENT_CONTEXT') {\n return critical\n ? { blocked: false, decisionReason: 'insufficient_context_critical_override' }\n : { blocked: true, decisionReason: 'insufficient_context_no_override' };\n }\n\n return { blocked: false, decisionReason: null };\n}\n\nfunction computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {\n const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0;\n const lastSentAt = memory ? memory.last_sent_at : null;\n const nextReviewAt = memory ? memory.next_review_at : null;\n const critical = isCriticalOverride(ticket);\n\n let blocked = false;\n let penalty = 0;\n let reason = 'ok';\n let breakCooldown = false;\n\n if (classification === 'NEEDS_COORDINATION') {\n blocked = true;\n reason = 'needs_coordination';\n } else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) {\n blocked = true;\n reason = 'waiting_next_review';\n } else if (BLOCKING_CLASSIFICATIONS.has(classification)) {\n // BUG REAL comprovado na Run A anterior: WAITING_VENDOR entrou no Top10 so porque o\n // contexto tinha mudado nesta execucao (contextChanged=true). contextChanged decide SE\n // reanalisamos (logic/cache.js) -- nunca decide se um chamado que CONTINUA classificado\n // numa classe bloqueada pode competir. Por isso este bloqueio e incondicional aqui: se a\n // classificacao atual (fresca ou cacheada, tanto faz) esta em BLOCKING_CLASSIFICATIONS,\n // fica fora do Top10, ponto. Missao \"finalizacao para producao\", secao 10.\n blocked = true;\n reason = `blocked_${classification.toLowerCase()}`;\n }\n\n const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5;\n if (!contextChanged && sentRecently && consecutiveDaysSent > 0) {\n const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15;\n penalty = Math.min(90, perDayBase * consecutiveDaysSent);\n if (!blocked) reason = 'repeat_no_change';\n }\n\n if (blocked && critical && classification !== 'NEEDS_COORDINATION') {\n breakCooldown = true;\n blocked = false;\n penalty = Math.round(penalty / 2);\n reason = `${reason}_override_critical`;\n }\n\n return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };\n}\n\nconst CLASSIFICATION_BASE_POINTS = {\n ACTIONABLE_NOW: 200,\n UNASSIGNED_ACTIONABLE: 180,\n WAITING_APPROVAL: 40,\n INSUFFICIENT_CONTEXT: 0,\n WAITING_REQUESTER: -150,\n WAITING_VENDOR: -150,\n DEPENDENCY_BLOCKED: -150,\n PROJECT_LONG_TERM: -120,\n DUPLICATE_OR_OBSOLETE: -200,\n SCHEDULED: -1000,\n NEEDS_COORDINATION: -1000,\n AI_FALLBACK: 0,\n};\n\nfunction computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {\n const classification = aiClassification.classification;\n const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });\n // AI_FALLBACK (nunca analisado) e INSUFFICIENT_CONTEXT (analisado, sem evidencia) nao\n // disputam o Top10 normalmente -- so entram por excecao deterministica forte, decidida em\n // um so lugar (logic/cooldown.js -> applyFallbackPolicy).\n const fallbackPolicy = applyFallbackPolicy({ aiClassification, ticket });\n\n const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;\n const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;\n\n let actionabilityPoints = 0;\n if (!aiClassification.is_fallback) {\n actionabilityPoints = CLASSIFICATION_BASE_POINTS[classification] ?? 0;\n if (actionabilityPoints > 0 && Number(aiClassification.confidence) < config.aiConfidenceThreshold) {\n actionabilityPoints /= 2;\n }\n }\n\n const scheduledNotDue = classification === 'SCHEDULED';\n const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue || fallbackPolicy.blocked;\n\n const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;\n\n let group = 'ranking_geral';\n const actionableIsh = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' || aiClassification.is_fallback;\n if (actionableIsh && ticket.sla_vencido && (ticket.prioridade >= 4 || ticket.urgencia >= 4)) group = 'critico_acionavel';\n else if (actionableIsh && ticket.sem_tecnico) group = 'sem_tecnico';\n else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';\n else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';\n\n let exclusionReason = null;\n if (excludedFromTop10) {\n if (scheduledNotDue) exclusionReason = 'scheduled_not_due';\n else if (classification === 'NEEDS_COORDINATION') exclusionReason = 'needs_coordination';\n else if (fallbackPolicy.blocked) exclusionReason = fallbackPolicy.decisionReason;\n else exclusionReason = cooldown.reason;\n }\n\n return {\n score: Math.round(total * 100) / 100,\n group,\n excludedFromTop10,\n exclusionReason,\n // decisionReason fica preenchido mesmo quando NAO excluido (ex.: *_critical_override),\n // pra ficar auditavel por que um AI_FALLBACK/INSUFFICIENT_CONTEXT furou a regra.\n decisionReason: fallbackPolicy.decisionReason,\n breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },\n cooldown,\n };\n}\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const contextChanged = data.resolucao !== 'cache';\n const result = computeScore({\n ticket: data.ticket,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextChanged,\n config: data.config,\n now: data.now,\n });\n return { json: { ...data, contextChanged, scoreResult: result } };\n});" } }, { "id": "code-preparar-registro-memoria-analise", "name": "Code - Preparar Registro Memoria Analise", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2480, 160 ], "parameters": { "jsCode": "const items = $input.all().map((item) => item.json);\nconst ONE_DAY = 24 * 60 * 60 * 1000;\n\nreturn items.map((data) => {\n const cls = data.aiClassification.classification;\n let nextReviewAt = null;\n if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY;\n else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY;\n\n return {\n json: {\n ticket_id: data.ticket.id,\n last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now),\n // Guarda a chave composta (hash de conteudo + AI_CLASSIFIER_VERSION), nao o hash puro --\n // e o que faz o cache invalidar sozinho quando o prompt/classificador muda de versao,\n // sem precisar apagar a Data Table na mao. Ver logic/cache.js -> buildCacheKey.\n last_context_hash: data.cacheKey,\n last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),\n ai_classification: cls,\n ai_confidence: data.aiClassification.confidence,\n blocker_type: data.aiClassification.blocker_type,\n waiting_for: data.aiClassification.waiting_for,\n next_action: data.aiClassification.next_action,\n next_review_at: nextReviewAt,\n last_score: data.scoreResult.score,\n last_ai_reason: data.aiClassification.reason,\n updated_at: data.now,\n },\n };\n});" } }, { "id": "datatable-upsert-memoria-analise", "name": "Data Table - Upsert Memoria Analise", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 2720, 160 ], "parameters": { "resource": "row", "operation": "upsert", "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, "filters": { "conditions": [ { "keyName": "ticket_id", "condition": "eq", "keyValue": "={{ $json.ticket_id }}" } ] }, "columns": { "mappingMode": "defineBelow", "matchingColumns": [ "ticket_id" ], "value": { "ticket_id": "={{ $json.ticket_id }}", "last_analyzed_at": "={{ $json.last_analyzed_at }}", "last_context_hash": "={{ $json.last_context_hash }}", "last_meaningful_change_at": "={{ $json.last_meaningful_change_at }}", "ai_classification": "={{ $json.ai_classification }}", "ai_confidence": "={{ $json.ai_confidence }}", "blocker_type": "={{ $json.blocker_type }}", "waiting_for": "={{ $json.waiting_for }}", "next_action": "={{ $json.next_action }}", "next_review_at": "={{ $json.next_review_at }}", "last_score": "={{ $json.last_score }}", "last_ai_reason": "={{ $json.last_ai_reason }}", "updated_at": "={{ $json.updated_at }}" } } } }, { "id": "code-selecionar-top10", "name": "Code - Selecionar Top 10", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2480, -320 ], "parameters": { "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n decisionReason: data.scoreResult.decisionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n // 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir\n // Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas\n // de IA reais), senao se perde depois deste node.\n resolucao: data.resolucao,\n config: data.config,\n now: data.now,\n}));\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];" } }, { "id": "code-preparar-registro-envio", "name": "Code - Preparar Registro de Envio", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2720, -560 ], "parameters": { "jsCode": "const ONE_DAY = 24 * 60 * 60 * 1000;\n\n// So roda depois de \"Data Table - Registrar Envio Digest (Sucesso)\" -- ou seja, so depois do\n// Slack confirmar sucesso de verdade (missao \"finalizacao para producao\", secao 19). Os dados\n// do Top10 sao lidos por nome (nao por $input, que aqui e o registro do envio, nao os tickets).\nreturn $('Code - Selecionar Top 10').all()\n .filter((item) => item.json._grupo === 'selected')\n .map((item) => {\n const data = item.json;\n const memory = data.memory;\n const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;\n const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;\n const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;\n const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;\n return {\n json: {\n ticket_id: data.ticket.id,\n last_sent_at: data.now,\n times_sent: timesSent,\n times_sent_30d: timesSent30d,\n consecutive_days_sent: consecutiveDaysSent,\n updated_at: data.now,\n },\n };\n });" } }, { "id": "datatable-registrar-envio", "name": "Data Table - Registrar Envio", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 2960, -560 ], "parameters": { "resource": "row", "operation": "upsert", "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, "filters": { "conditions": [ { "keyName": "ticket_id", "condition": "eq", "keyValue": "={{ $json.ticket_id }}" } ] }, "columns": { "mappingMode": "defineBelow", "matchingColumns": [ "ticket_id" ], "value": { "ticket_id": "={{ $json.ticket_id }}", "last_sent_at": "={{ $json.last_sent_at }}", "times_sent": "={{ $json.times_sent }}", "times_sent_30d": "={{ $json.times_sent_30d }}", "consecutive_days_sent": "={{ $json.consecutive_days_sent }}", "updated_at": "={{ $json.updated_at }}" } } } }, { "id": "code-montar-mensagem-curta", "name": "Code - Montar Mensagem Curta", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2720, -320 ], "parameters": { "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];" } }, { "id": "code-montar-digest-coordenacao", "name": "Code - Montar Digest Coordenacao", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2960, -320 ], "parameters": { "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];" } }, { "id": "merge-unir-shadow", "name": "Merge - Unir Shadow", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, "position": [ 3200, 160 ], "parameters": { "mode": "append", "numberInputs": 2 } }, { "id": "code-comparar-legacy-v2", "name": "Code - Comparar Legacy x V2", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3440, 160 ], "parameters": { "jsCode": "// Fila interna de auditoria/calibracao para chamados classificados como INSUFFICIENT_CONTEXT.\n// Nao e enviada a ninguem -- serve so pra identificar problemas de preenchimento no GLPI\n// (ver secao 10/14 da missao original). Fica gravada dentro do resumo do shadow run.\nfunction buildDataQualityReview(items) {\n return items\n .filter((item) => item.aiClassification.classification === 'INSUFFICIENT_CONTEXT')\n .map((item) => {\n const t = item.ticket;\n const followups = t.followups || [];\n const tasks = t.tasks || [];\n const contextSize = (t.descricao || '').length\n + followups.reduce((sum, f) => sum + (f.texto || '').length, 0)\n + tasks.reduce((sum, task) => sum + (task.texto || '').length, 0);\n\n return {\n ticket_id: t.id,\n title: t.titulo,\n ai_reason: item.aiClassification.reason,\n context_size: contextSize,\n has_description: Boolean(t.descricao && t.descricao.trim().length > 0),\n followup_count: followups.length,\n task_count: tasks.length,\n has_solution: Boolean(t.solucao),\n last_interaction_at: t.ultima_interacao_em,\n };\n });\n}\n\n// Este node roda depois de \"Merge - Unir Shadow\" (append: Code - Montar Digest Coordenacao +\n// MySQL - Legado Top 10 (Shadow)), que existe so para GARANTIR que os dois ja executaram --\n// os dados continuam sendo lidos por nome, nao pelo item mesclado, para nao depender de ordem.\nconst digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }\nconst legacyRows = $('MySQL - Legado Top 10 (Shadow)').all().map((item) => item.json);\n\nconst legacyIds = legacyRows.map((row) => Number(row.id));\nconst v2Ids = digestData.selected.map((item) => Number(item.ticket.id));\n\nconst added = v2Ids.filter((id) => !legacyIds.includes(id));\nconst removed = legacyIds.filter((id) => !v2Ids.includes(id));\n\nconst addedReasons = added.map((id) => {\n const item = digestData.selected.find((s) => Number(s.ticket.id) === id);\n return { id, motivo: item ? `${item.aiClassification.classification} - ${item.aiClassification.reason}` : 'desconhecido' };\n});\nconst removedReasons = removed.map((id) => {\n const excludedItem = digestData.excluded.find((e) => Number(e.ticket.id) === id);\n return { id, motivo: excludedItem ? (excludedItem.exclusionReason || excludedItem.aiClassification.classification) : 'nao estava nos candidatos V2' };\n});\n\n// --- observabilidade (secao 24 da missao) ---\nconst allItems = [...digestData.selected, ...digestData.excluded];\n\nconst cacheHits = allItems.filter((i) => i.resolucao === 'cache').length;\nconst aiRequested = allItems.filter((i) => i.resolucao === 'ia').length;\nconst aiFallbackCapCount = allItems.filter((i) => i.resolucao === 'fallback_cap').length;\nconst aiSuccess = allItems.filter((i) => i.resolucao === 'ia' && !i.aiClassification.is_fallback).length;\nconst aiErrors = allItems.filter((i) => i.resolucao === 'ia' && i.aiClassification.is_fallback).length;\nconst aiFallbackCount = allItems.filter((i) => i.aiClassification.is_fallback).length;\n\nconst aiFallbackByReason = {};\nallItems\n .filter((i) => i.aiClassification.is_fallback)\n .forEach((i) => {\n const r = i.aiClassification.fallback_reason || 'unknown';\n aiFallbackByReason[r] = (aiFallbackByReason[r] || 0) + 1;\n });\n\nconst classificationCounts = {};\nallItems.forEach((i) => {\n const c = i.aiClassification.classification;\n classificationCounts[c] = (classificationCounts[c] || 0) + 1;\n});\n\nconst actionableRealCount = allItems.filter((i) => !i.aiClassification.is_fallback\n && (i.aiClassification.classification === 'ACTIONABLE_NOW' || i.aiClassification.classification === 'UNASSIGNED_ACTIONABLE')).length;\nconst criticalOverrideCount = allItems.filter((i) => i.decisionReason && String(i.decisionReason).endsWith('_critical_override')).length;\n\nconst observability = {\n candidate_count: allItems.length,\n cache_hits: cacheHits,\n cache_misses: allItems.length - cacheHits,\n ai_requested: aiRequested,\n ai_success: aiSuccess,\n ai_errors: aiErrors,\n ai_fallback_count: aiFallbackCount,\n ai_fallback_cap_count: aiFallbackCapCount,\n ai_fallback_by_reason: aiFallbackByReason,\n classification_counts: classificationCounts,\n insufficient_context_count: classificationCounts.INSUFFICIENT_CONTEXT || 0,\n actionable_real_count: actionableRealCount,\n critical_override_count: criticalOverrideCount,\n top10_count: digestData.selected.length,\n blocked_count: digestData.excluded.length,\n coordination_count: classificationCounts.NEEDS_COORDINATION || 0,\n};\n\nconst dataQualityReview = buildDataQualityReview(allItems);\n\nreturn [{\n json: {\n run_at: Date.now(),\n legacy_ids: JSON.stringify(legacyIds),\n v2_ids: JSON.stringify(v2Ids),\n added: JSON.stringify(added),\n removed: JSON.stringify(removed),\n summary: JSON.stringify({ addedReasons, removedReasons, observability, dataQualityReview }),\n mensagem: digestData.mensagem,\n digest: digestData.digest,\n },\n}];" } }, { "id": "datatable-registrar-shadow-run", "name": "Data Table - Registrar Shadow Run", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 3680, 160 ], "parameters": { "resource": "row", "operation": "insert", "dataTableId": { "mode": "name", "value": "coordenador_glpi_shadow_runs" }, "columns": { "mappingMode": "defineBelow", "value": { "run_at": "={{ $json.run_at }}", "legacy_ids": "={{ $json.legacy_ids }}", "v2_ids": "={{ $json.v2_ids }}", "added": "={{ $json.added }}", "removed": "={{ $json.removed }}", "summary": "={{ $json.summary }}" } } } }, { "id": "datatable-buscar-envios-recentes", "name": "Data Table - Buscar Envios Recentes", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 3200, -320 ], "parameters": { "operation": "get", "dataTableId": { "mode": "name", "value": "coordenador_glpi_envios_digest" }, "filters": { "conditions": [ { "keyName": "report_date", "condition": "eq", "keyValue": "={{ $('Code - Montar Digest Coordenacao').first().json.dateStr }}" } ] } }, "alwaysOutputData": true }, { "id": "code-guard-envio-slack", "name": "Code - Guard de Envio Slack", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3440, -320 ], "parameters": { "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n };\n}\n\nfunction stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];\n\n// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint\n// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma\n// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca\n// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o unico gate de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22).\nfunction shouldSendByConfig(config) {\n return Boolean(config && config.sendEnabled);\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config)) {\n return { shouldSend: false, reason: 'send_disabled' };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// Roda depois de \"Data Table - Buscar Envios Recentes\" (fetch por report_date=hoje), que existe\n// so pra trazer os registros de envio ja existentes; os dados do digest sao lidos por nome\n// (mesmo padrao ja usado em Code - Comparar Legacy x V2).\nconst digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }\nconst config = digestData.selected.length ? digestData.selected[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst existingRecords = $input.all().map((item) => item.json);\n\n// $execution.mode: 'manual' quando disparado pelo Manual Trigger, senao ('trigger' no\n// Schedule Trigger) tratamos como execucao agendada. Nao inventa um mecanismo novo de deteccao.\nconst runType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst messageHash = computeMessageHash(digestData.mensagem);\nconst guard = evaluateSendGuard({\n config, existingRecords, reportDate: digestData.dateStr, runType, messageHash,\n});\n\nreturn [{\n json: {\n mensagem: digestData.mensagem,\n dateStr: digestData.dateStr,\n runType,\n messageHash,\n shouldSend: guard.shouldSend,\n reason: guard.reason,\n },\n}];" } }, { "id": "if-deve-enviar-slack", "name": "IF - Deve Enviar Slack?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 3680, -320 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "cond-deve-enviar", "leftValue": "={{ $json.shouldSend }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "slack-enviar-mensagem", "name": "Slack - Enviar Mensagem", "type": "n8n-nodes-base.slack", "typeVersion": 2.5, "position": [ 3920, -420 ], "onError": "continueErrorOutput", "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 3000, "parameters": { "resource": "message", "operation": "post", "select": "channel", "channelId": { "__rl": true, "mode": "id", "value": "C0BJNBTPFT5" }, "text": "={{ $json.mensagem }}", "otherOptions": {} }, "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 unico gate de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22).\nfunction shouldSendByConfig(config) {\n return Boolean(config && config.sendEnabled);\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config)) {\n return { shouldSend: false, reason: 'send_disabled' };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de sucesso do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registrar sent_at/status so DEPOIS da confirmacao real do Slack -- nunca antes (missao\n// \"finalizacao para producao\", secao 19).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'success',\n});\n\nreturn [{ json: record }];" } }, { "id": "code-registrar-envio-falha", "name": "Code - Registrar Envio Falha", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4160, -340 ], "parameters": { "jsCode": "function stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];\n\n// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint\n// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma\n// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca\n// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\n// Hash estavel do texto exato que seria enviado -- usado como parte da chave de\n// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no\n// mesmo dia nunca devem gerar dois envios reais.\nfunction computeMessageHash(text) {\n return djb2Hash(String(text || ''));\n}\n\n// SEND_ENABLED e o unico gate de \"isso pode realmente sair pro Slack\" -- nunca depende de\n// editar codigo (missao \"finalizacao para producao\", secao 22).\nfunction shouldSendByConfig(config) {\n return Boolean(config && config.sendEnabled);\n}\n\n// Idempotencia: so conta como \"ja enviado\" um registro com status 'success' para a MESMA\n// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao\n// bloqueia um novo envio -- so um sucesso anterior bloqueia.\nfunction alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {\n return (existingRecords || []).some((record) => (\n record\n && record.status === 'success'\n && record.report_date === reportDate\n && record.run_type === runType\n && record.message_hash === messageHash\n ));\n}\n\n// Decisao unica de \"devo mandar pro Slack agora\" -- centraliza os dois motivos possiveis de\n// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.\nfunction evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {\n if (!shouldSendByConfig(config)) {\n return { shouldSend: false, reason: 'send_disabled' };\n }\n if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {\n return { shouldSend: false, reason: 'already_sent' };\n }\n return { shouldSend: true, reason: 'ok' };\n}\n\nfunction buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {\n return {\n report_date: reportDate,\n run_type: runType,\n message_hash: messageHash,\n sent_at: sentAt,\n status,\n error_detail: errorDetail || null,\n };\n}\n\n// So roda no output de erro do node \"Slack - Enviar Mensagem\" (onError: continueErrorOutput).\n// Registra status=failed com o erro real ANTES de propagar a falha (proximo node lanca o\n// erro de verdade, pra execucao ficar visivelmente vermelha no n8n -- nao silenciar).\nconst guardData = $('Code - Guard de Envio Slack').first().json;\nconst errorItem = $input.first().json || {};\nconst errorMessage = String(errorItem.error || errorItem.message || 'Erro desconhecido ao enviar mensagem ao Slack').slice(0, 500);\n\nconst record = buildEnvioDigestRecord({\n reportDate: guardData.dateStr,\n runType: guardData.runType,\n messageHash: guardData.messageHash,\n sentAt: Date.now(),\n status: 'failed',\n errorDetail: errorMessage,\n});\n\nreturn [{ json: record }];" } }, { "id": "datatable-registrar-envio-digest-sucesso", "name": "Data Table - Registrar Envio Digest (Sucesso)", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 4400, -500 ], "parameters": { "operation": "insert", "dataTableId": { "mode": "name", "value": "coordenador_glpi_envios_digest" }, "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": [] }