From a99a25cb1a1d765d0c42acada26e5fb00d152872 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 15:31:44 -0300 Subject: [PATCH] feat(v2): complete n8n workflow template (36 nodes) covering full V2 pipeline + shadow branch - 14 new Code node drivers implementing the pipeline stages: normalize candidates, build context SQL, merge context + hash, decide AI need, prepare AI prompt, validate AI response, AI-disabled fallback, reuse cached classification, compute score, select top 10, prepare memory/ envio records, build message/digest, compare legacy x V2, debug panel. - Consolidated the two context queries (followups/tasks) into a single UNION ALL query tagged by tipo, and added a "Merge - Unir Shadow" synchronization node before comparing legacy x V2: n8n does not guarantee relative execution order between sibling branches that only share an early common ancestor, so cross-branch data was only safe to read by name once a real Merge node forced both to complete first. - Reordered score calculation before the memory upsert (score is needed in the persisted row) and turned both Data Table writes into side-effect branches that fan out from the main path instead of gating it, so the Data Table node's own output shape never needs to carry the full pipeline state forward. - Fixed a duplicate-identifier bug in the build script: score.js's require('./cooldown') survived inlining and collided with cooldown.js's own computeCooldown function in the same Code node scope. build-workflow.js now strips inter-module requires when inlining, and a new test syntax-checks every generated jsCode string plus asserts no leftover require() calls. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-24-coordenador-glpi-v2.md | 67 +- n8n/deployed/coordenador-glpi-v2.n8n.json | 932 +++++++++++++++++- n8n/nodes/ai-disabled-fallback.node.js | 2 + n8n/nodes/build-coordination-digest.node.js | 14 + n8n/nodes/build-sql-context.node.js | 8 + n8n/nodes/build-top-message.node.js | 9 + n8n/nodes/compare-legacy-v2.node.js | 33 + n8n/nodes/compute-score.node.js | 14 + n8n/nodes/debug-panel.node.js | 11 + n8n/nodes/decide-needs-ai.node.js | 43 + n8n/nodes/merge-context-hash.node.js | 28 + n8n/nodes/normalize-candidates.node.js | 3 + n8n/nodes/prepare-ai-prompt.node.js | 57 ++ n8n/nodes/prepare-envio-record.node.js | 22 + .../prepare-memoria-analise-record.node.js | 27 + n8n/nodes/reuse-cached-classification.node.js | 27 + n8n/nodes/select-top10.node.js | 24 + n8n/nodes/validate-ai-response.node.js | 25 + n8n/workflow.template.json | 487 ++++++++- sql/contexto_followups_glpi.sql | 5 - sql/contexto_glpi.sql | 17 + sql/contexto_tarefas_glpi.sql | 6 - tools/build-workflow.js | 12 +- tools/build-workflow.test.js | 21 + 24 files changed, 1818 insertions(+), 76 deletions(-) create mode 100644 n8n/nodes/ai-disabled-fallback.node.js create mode 100644 n8n/nodes/build-coordination-digest.node.js create mode 100644 n8n/nodes/build-sql-context.node.js create mode 100644 n8n/nodes/build-top-message.node.js create mode 100644 n8n/nodes/compare-legacy-v2.node.js create mode 100644 n8n/nodes/compute-score.node.js create mode 100644 n8n/nodes/debug-panel.node.js create mode 100644 n8n/nodes/decide-needs-ai.node.js create mode 100644 n8n/nodes/merge-context-hash.node.js create mode 100644 n8n/nodes/normalize-candidates.node.js create mode 100644 n8n/nodes/prepare-ai-prompt.node.js create mode 100644 n8n/nodes/prepare-envio-record.node.js create mode 100644 n8n/nodes/prepare-memoria-analise-record.node.js create mode 100644 n8n/nodes/reuse-cached-classification.node.js create mode 100644 n8n/nodes/select-top10.node.js create mode 100644 n8n/nodes/validate-ai-response.node.js delete mode 100644 sql/contexto_followups_glpi.sql create mode 100644 sql/contexto_glpi.sql delete mode 100644 sql/contexto_tarefas_glpi.sql diff --git a/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md b/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md index 391e6d0..11994e2 100644 --- a/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md +++ b/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md @@ -1248,8 +1248,7 @@ git commit -m "feat(v2): add short message and coordination digest builders" **Files:** - Create: `sql/candidatos_v2_glpi.sql` -- Create: `sql/contexto_followups_glpi.sql` -- Create: `sql/contexto_tarefas_glpi.sql` +- Create: `sql/contexto_glpi.sql` - Create: `n8n/nodes/build-sql-candidatos.node.js` - Create: `n8n/nodes/build-sql-legado.node.js` - Test: `n8n/nodes/build-sql-candidatos.node.test.js` @@ -1344,26 +1343,24 @@ ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias LIMIT {CANDIDATE_POOL_SIZE}; ``` -- [ ] **Step 2: Criar `sql/contexto_followups_glpi.sql` e `sql/contexto_tarefas_glpi.sql`** - -`sql/contexto_followups_glpi.sql`: +- [ ] **Step 2: Criar `sql/contexto_glpi.sql`** — followups e tarefas numa unica query (`UNION ALL`, marcada por `tipo`), para que o node MySQL de contexto seja um unico ancestral direto de `Code - Mesclar Contexto e Calcular Hash` (evita depender da ordem relativa de dois nodes MySQL irmaos sem sincronizacao garantida pelo n8n). ```sql --- Followups publicos dos candidatos (IDs interpolados pelo node, validados como inteiros). -SELECT items_id AS tickets_id, id, date, content AS texto +-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query +-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram. +-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a +-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano. + +SELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS}) -ORDER BY items_id, date ASC; -``` -`sql/contexto_tarefas_glpi.sql`: +UNION ALL -```sql --- Tarefas dos candidatos. Coluna de status a confirmar via DESCRIBE glpi_tickettasks --- quando a credencial MySQL estiver disponivel (Task 14) -- ver nota no plano. -SELECT tickets_id, id, date, state AS status, content AS texto +SELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status FROM glpi_tickettasks WHERE tickets_id IN ({IDS}) + ORDER BY tickets_id, date ASC; ``` @@ -1439,7 +1436,7 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -git add sql/candidatos_v2_glpi.sql sql/contexto_followups_glpi.sql sql/contexto_tarefas_glpi.sql n8n/nodes/build-sql-candidatos.node.js n8n/nodes/build-sql-legado.node.js n8n/nodes/build-sql-candidatos.node.test.js +git add sql/candidatos_v2_glpi.sql sql/contexto_glpi.sql n8n/nodes/build-sql-candidatos.node.js n8n/nodes/build-sql-legado.node.js n8n/nodes/build-sql-candidatos.node.test.js git commit -m "feat(v2): candidate pool SQL (admin exclusion preserved, no hard LIMIT 10) + verbatim legacy SQL for shadow mode" ``` @@ -1536,8 +1533,7 @@ function buildDriverCode(driverRelativePath) { driver = driver.replace('/* __SQL_CANDIDATOS_V2__ */', sqlAsJsTemplateLiteral('sql/candidatos_v2_glpi.sql')); driver = driver.replace('/* __SQL_LEGADO_V1_VERBATIM__ */', sqlAsJsTemplateLiteral('sql/top_10_chamados_abertos_glpi.sql')); - driver = driver.replace('/* __SQL_CONTEXTO_FOLLOWUPS__ */', sqlAsJsTemplateLiteral('sql/contexto_followups_glpi.sql')); - driver = driver.replace('/* __SQL_CONTEXTO_TAREFAS__ */', sqlAsJsTemplateLiteral('sql/contexto_tarefas_glpi.sql')); + driver = driver.replace('/* __SQL_CONTEXTO__ */', sqlAsJsTemplateLiteral('sql/contexto_glpi.sql')); const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : ''; return `${logicBlock}${driver.trim()}`; @@ -1600,6 +1596,8 @@ git commit -m "feat(v2): add build script that inlines tested logic/*.js into n8 - Create: `n8n/nodes/build-coordination-digest.node.js` - Create: `n8n/nodes/compare-legacy-v2.node.js` - Create: `n8n/nodes/debug-panel.node.js` +- Create: `n8n/nodes/prepare-memoria-analise-record.node.js` (prepara a linha para `Data Table - Upsert Memoria Analise`, ramo efeito-colateral) +- Create: `n8n/nodes/prepare-envio-record.node.js` (prepara a linha para `Data Table - Registrar Envio`, filtra `_grupo==='selected'`, ramo efeito-colateral) **Interfaces:** - Cada driver assume que as funções de `logic/*.js` listadas em `// @requires` já estão no escopo (inlined pelo build script da Task 12). Cada um consome `$input.all()`/`$input.first()` (shape n8n padrão) e os nodes anteriores via `$('Nome do Node')`, seguindo exatamente o padrão já usado no V1 e no workflow `[DEV] GTI - GLPI x WhatsApp v7`. @@ -1612,8 +1610,7 @@ Manual Trigger -> Code - Montar SQL Candidatos -> MySQL - Buscar Candidatos -> Code - Normalizar Candidatos Base -> Code - Montar SQL Contexto - -> MySQL - Buscar Followups - -> MySQL - Buscar Tarefas + -> MySQL - Buscar Contexto (followups + tarefas numa unica query UNION ALL, marcada por `tipo` -- evita depender da ordem relativa de dois nodes MySQL irmaos) -> Code - Mesclar Contexto e Calcular Hash -> Data Table - Buscar Memoria (get, returnAll) -> Code - Decidir Necessidade de IA @@ -1622,16 +1619,20 @@ Manual Trigger [true] -> Code - Preparar Prompt IA -> HTTP - OpenAI Classificar Chamado -> Code - Validar Classificacao IA -> Merge - Consolidar Classificacoes (input 0) [false] -> Code - Fallback IA Desligada -> Merge - Consolidar Classificacoes (input 1) [false] -> Code - Reusar Classificacao em Cache -> Merge - Consolidar Classificacoes (input 2) - Merge - Consolidar Classificacoes - -> Data Table - Upsert Memoria Analise - -> Code - Calcular Score - -> Code - Selecionar Top 10 (Run Once for All Items) - -> Data Table - Registrar Envio - -> Code - Montar Mensagem Curta - -> Code - Montar Digest Coordenacao - -> Code - Comparar Legacy x V2 - -> Data Table - Registrar Shadow Run - -> Code - Montar SQL Legado -> MySQL - Legado Top 10 (Shadow) [alimenta Code - Comparar Legacy x V2 via $('MySQL - Legado Top 10 (Shadow)').all(), mesmo padrao de leitura cruzada ja usado no V1/WhatsApp workflow] + Merge - Consolidar Classificacoes (mode: append) + -> Code - Calcular Score + -> Code - Selecionar Top 10 (Run Once for All Items) [caminho principal] + -> Code - Montar Mensagem Curta + -> Code - Montar Digest Coordenacao + -> Merge - Unir Shadow (append, input 0) + -> Code - Preparar Registro Memoria Analise -> Data Table - Upsert Memoria Analise [ramo efeito-colateral, sem continuidade] + (a partir de Code - Selecionar Top 10, tambem:) + -> Code - Preparar Registro de Envio (filtra _grupo==='selected') -> Data Table - Registrar Envio [ramo efeito-colateral] + -> Code - Montar SQL Legado -> MySQL - Legado Top 10 (Shadow) -> Merge - Unir Shadow (append, input 1) + +Merge - Unir Shadow (append) + -> Code - Comparar Legacy x V2 [le os dados por nome -- $('Code - Montar Digest Coordenacao').first() e $('MySQL - Legado Top 10 (Shadow)').all() -- o Merge so existe para GARANTIR que os dois ja executaram antes deste node rodar; ler pelo item mesclado seria fragil] + -> Data Table - Registrar Shadow Run Debug - Ver Painel (Webhook GET, path "coordenador-glpi-v2-debug") -> Data Table - Buscar Shadow Runs Recentes (get, returnAll, sortBy run_at:desc, limit implicito pelos ultimos N) @@ -1641,8 +1642,8 @@ Debug - Ver Painel (Webhook GET, path "coordenador-glpi-v2-debug") - [ ] **Step 1: Para cada node de Code listado acima, criar o arquivo driver em `n8n/nodes/` seguindo o padrão dos já criados na Task 11** (comentário `// @requires ` no topo quando aplicável; corpo usa `$input`, `$('Nome do Node')`, `$env`, retorna `return [{ json: {...} }];` ou, para o de seleção final, roda uma vez para todos os itens: usar `$input.all()` e retornar múltiplos itens). Cada driver deve: - `Code - Normalizar Candidatos Base`: `@requires context.js` — mapeia `$input.all()` com `normalizeTicket(item.json, { followups: [], tasks: [] })`. - - `Code - Montar SQL Contexto`: monta as duas queries de `sql/contexto_followups_glpi.sql`/`contexto_tarefas_glpi.sql` substituindo `{IDS}` pelos ids normalizados (`Number(id)`, `join(',')`, nunca concatenando string crua do GLPI — todos os ids já vêm como inteiro da query anterior). - - `Code - Mesclar Contexto e Calcular Hash`: `@requires hash.js` — junta `$('Code - Normalizar Candidatos Base').all()` com `$('MySQL - Buscar Followups').all()` e `$input.all()` (tarefas), agrupa por `tickets_id`, chama `buildContextFingerprint`. + - `Code - Montar SQL Contexto`: monta a query única de `sql/contexto_glpi.sql` substituindo `{IDS}` pelos ids normalizados (`Number(id)`, `join(',')`, nunca concatenando string crua do GLPI — todos os ids já vêm como inteiro da query anterior). + - `Code - Mesclar Contexto e Calcular Hash`: `@requires context.js hash.js` — junta `$('Code - Normalizar Candidatos Base').all()` com `$input.all()` (linhas de `MySQL - Buscar Contexto`, separadas por `tipo==='followup'|'task'`), agrupa por `tickets_id`, chama `buildContextFingerprint`. - `Code - Decidir Necessidade de IA`: `@requires cache.js` — para cada candidato, busca a linha de memória correspondente (de `$('Data Table - Buscar Memoria').all()`) e chama `needsReanalysis`; ordena por `score_criticidade` e limita a `config.aiMaxCandidates` os que vão para IA; os excedentes recebem `precisa_ia:false` com motivo `ai_max_candidates_cap` e caem no branch de fallback determinístico (não ficam sem classificação). - `Code - Preparar Prompt IA`: `@requires context.js` — monta `system` (regras da seção 16 da missão: não inventar fatos, distinguir evidência de inferência, JSON estrito, SLA vencido sozinho não é evidência de acionabilidade, idade sozinha não é acionabilidade) e `user` = `buildAiContextText(ticket, config.aiMaxContextChars)`. - `Code - Validar Classificacao IA`: `@requires classification.js, fallback.js` — tenta `JSON.parse` da resposta; se falhar ou `validateAiClassification` retornar `valid:false`, usa `buildFallbackClassification('invalid_json')`; se o node HTTP anterior tiver `error` (timeout/HTTP≠2xx, capturado via `continueOnFail`), usa `buildFallbackClassification('ai_error')`. @@ -1656,7 +1657,7 @@ Debug - Ver Painel (Webhook GET, path "coordenador-glpi-v2-debug") - `Code - Montar Painel Debug`: monta um HTML/JSON simples com a última linha de `coordenador_glpi_shadow_runs` + contagem de memória (mesmo padrão do node `Debug - Montar Painel` do workflow `[DEV] GTI - GLPI x WhatsApp v7`). - [ ] **Step 2: Adicionar ao `n8n/workflow.template.json` todos os nodes não-Code com seus `parameters` finais**: - - `MySQL - Buscar Candidatos`, `MySQL - Buscar Followups`, `MySQL - Buscar Tarefas`, `MySQL - Legado Top 10 (Shadow)`: `type: n8n-nodes-base.mySql`, `parameters: { operation: 'executeQuery', query: "={{ $json.query }}" }`, `credentials: { mySql: { name: 'MySQL GLPI - Coordenador' } }` (sem `id` — resolvido na Task 14 quando o id real existir). + - `MySQL - Buscar Candidatos`, `MySQL - Buscar Contexto`, `MySQL - Legado Top 10 (Shadow)`: `type: n8n-nodes-base.mySql`, `parameters: { operation: 'executeQuery', query: "={{ $json.query }}" }`, `credentials: { mySql: { name: 'MySQL GLPI - Coordenador' } }` (sem `id` — resolvido na Task 14 quando o id real existir). - `Data Table - Buscar Memoria`: `type: n8n-nodes-base.dataTable`, `parameters: { resource: 'row', operation: 'get', dataTableId: { mode: 'name', value: 'coordenador_glpi_memoria' }, returnAll: true }`. - `Data Table - Upsert Memoria Analise` e `Data Table - Registrar Envio`: `operation: 'upsert'` no mesmo `dataTableId`, campos mapeados via `columns` (resourceMapper — ver Task 14 para o shape exato, resolvido com `n8n_validate_workflow`/`n8n_autofix_workflow`). - `Data Table - Registrar Shadow Run` e `Data Table - Buscar Shadow Runs Recentes`: mesma lógica sobre `coordenador_glpi_shadow_runs`. @@ -1692,7 +1693,7 @@ git commit -m "feat(v2): complete n8n workflow template (25+ nodes) covering ful - [ ] **Step 1: Confirmar a credencial MySQL** — chamar `n8n_manage_credentials` (`action: list`) e localizar `MySQL GLPI - Coordenador`. Se ainda não existir, parar aqui e avisar o usuário (não simular/hardcodar). Se existir, anotar o `id`. - [ ] **Step 2: Introspecção obrigatória do schema real (nunca assumir)** — com a credencial MySQL viva, rodar via node `mysql` temporário (`n8n_test_workflow` num workflow mínimo de 1 node, ou reaproveitando `MySQL - Buscar Candidatos` já criado) as queries: - - `SHOW COLUMNS FROM glpi_tickettasks;` — confirmar se o campo de status é `state` (assumido no Task 11) ou outro nome; ajustar `sql/contexto_tarefas_glpi.sql` se necessário. + - `SHOW COLUMNS FROM glpi_tickettasks;` — confirmar se o campo de status é `state` (assumido no Task 11) ou outro nome; ajustar `sql/contexto_glpi.sql` se necessário. - `SHOW COLUMNS FROM glpi_itilfollowups;` — confirmar `content`/`date`/`is_private` existem como assumido. - Rodar `sql/candidatos_v2_glpi.sql` (com `{CANDIDATE_POOL_SIZE}`→`5` para teste rápido) e conferir que retorna linhas plausíveis. - Registrar tudo (colunas confirmadas, ajustes feitos) em `docs/superpowers/notes/2026-08-24-schema-introspection.md`. diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json index c823769..e19cbb7 100644 --- a/n8n/deployed/coordenador-glpi-v2.n8n.json +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -7,8 +7,8 @@ "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ - 180, - 300 + -1600, + 0 ], "parameters": {} }, @@ -18,8 +18,8 @@ "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ - 180, - 640 + -1600, + 900 ], "parameters": { "rule": { @@ -38,8 +38,8 @@ "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 440, - 300 + -1360, + 0 ], "parameters": { "assignments": { @@ -61,8 +61,8 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 700, - 220 + -1120, + -160 ], "parameters": { "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nconst config = parseConfig($env);\nconst glpiBaseUrl = String($('Config - Coordenador GLPI v2').first().json.glpi_base_url || '').replace(/\\/$/, '').replace(/'/g, \"''\");\n\nconst queryTemplate = `-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.\n-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}\n\nWITH ultima_interacao AS (\n SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em\n FROM (\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n UNION ALL\n SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0\n ) followup_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n UNION ALL\n SELECT tickets_id, MAX(data_interacao) AS data_interacao\n FROM (\n SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks\n UNION ALL\n SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks\n ) task_datas\n WHERE data_interacao IS NOT NULL\n GROUP BY tickets_id\n ) interacoes\n GROUP BY tickets_id\n), tecnicos AS (\n SELECT tu.tickets_id,\n GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos\n FROM glpi_tickets_users tu\n LEFT JOIN glpi_users u ON u.id = tu.users_id\n WHERE tu.type = 2\n GROUP BY tu.tickets_id\n), chamados_base AS (\n SELECT\n t.id, t.name AS titulo, t.status,\n CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,\n t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,\n t.priority, t.urgency, t.impact,\n COALESCE(e.completename, e.name, 'Nao informado') AS entidade,\n COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,\n COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,\n GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,\n TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,\n GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,\n TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,\n CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,\n CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,\n CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi\n FROM glpi_tickets t\n LEFT JOIN glpi_entities e ON e.id = t.entities_id\n LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\n LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id\n LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id\n WHERE t.is_deleted = false\n AND t.status NOT IN (5, 6)\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'\n AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'\n), chamados_ranked AS (\n SELECT\n id, titulo, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,\n dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,\n priority AS prioridade, urgency AS urgencia, impact AS impacto,\n entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,\n (\n CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END\n + CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END\n + CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END\n + LEAST(dias_aberto, 30)\n + (priority * 8) + (urgency * 5) + (impact * 5)\n + CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END\n ) AS score_criticidade\n FROM chamados_base\n)\nSELECT * FROM chamados_ranked\nORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC\nLIMIT {CANDIDATE_POOL_SIZE};\n`;\n\nconst query = queryTemplate\n .replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))\n .replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))\n .replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)\n .replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));\n\nreturn [{ json: { query, config } }];" @@ -74,12 +74,581 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 700, - 460 + -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.4, + "position": [ + -880, + -160 + ], + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + }, + "credentials": { + "mySql": { + "name": "MySQL GLPI - Coordenador" + } + } + }, + { + "id": "mysql-legado-top10-shadow", + "name": "MySQL - Legado Top 10 (Shadow)", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + -880, + 900 + ], + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + }, + "credentials": { + "mySql": { + "name": "MySQL GLPI - Coordenador" + } + } + }, + { + "id": "code-normalizar-candidatos-base", + "name": "Code - Normalizar Candidatos Base", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -640, + -160 + ], + "parameters": { + "jsCode": "function normalizeTicket(row, extras) {\n const followups = (extras && extras.followups) || [];\n const tasks = (extras && extras.tasks) || [];\n return {\n id: Number(row.id),\n titulo: row.titulo,\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`)\n .join('\\n');\n const header = [\n `Chamado #${ticket.id} - ${ticket.titulo}`,\n `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`,\n `Tecnico: ${ticket.tecnico_atribuido}`,\n `Prioridade: ${ticket.prioridade} Urgencia: ${ticket.urgencia} Impacto: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n');\n const body = [\n 'Ultimos apontamentos:',\n followupsText || '(nenhum)',\n '',\n 'Ultimas tarefas:',\n tasksText || '(nenhuma)',\n ].join('\\n');\n return truncate(`${header}\\n\\n${body}`, budget);\n}\n\nconst items = $input.all();\nreturn items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));" + } + }, + { + "id": "code-montar-sql-contexto", + "name": "Code - Montar SQL Contexto", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -400, + -160 + ], + "parameters": { + "jsCode": "const candidatos = $input.all().map((item) => item.json);\nconst ids = candidatos.map((c) => Number(c.id)).filter((id) => Number.isInteger(id));\nconst idsList = ids.length ? ids.join(',') : '0';\n\nconst queryTemplate = `-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query\n-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.\n-- IDs interpolados pelo node como lista de inteiros (nunca string crua do GLPI).\n-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a\n-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.\n\nSELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status\nFROM glpi_itilfollowups\nWHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})\n\nUNION ALL\n\nSELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status\nFROM glpi_tickettasks\nWHERE tickets_id IN ({IDS})\n\nORDER BY tickets_id, date ASC;\n`;\nconst query = queryTemplate.replaceAll('{IDS}', idsList);\n\nreturn [{ json: { query, candidateIds: ids } }];" + } + }, + { + "id": "mysql-buscar-contexto", + "name": "MySQL - Buscar Contexto", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + -160, + -160 + ], + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + }, + "credentials": { + "mySql": { + "name": "MySQL GLPI - Coordenador" + } + } + }, + { + "id": "code-mesclar-contexto-hash", + "name": "Code - Mesclar Contexto e Calcular Hash", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + -160 + ], + "parameters": { + "jsCode": "function normalizeTicket(row, extras) {\n const followups = (extras && extras.followups) || [];\n const tasks = (extras && extras.tasks) || [];\n return {\n id: Number(row.id),\n titulo: row.titulo,\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`)\n .join('\\n');\n const header = [\n `Chamado #${ticket.id} - ${ticket.titulo}`,\n `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`,\n `Tecnico: ${ticket.tecnico_atribuido}`,\n `Prioridade: ${ticket.prioridade} Urgencia: ${ticket.urgencia} Impacto: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n');\n const body = [\n 'Ultimos apontamentos:',\n followupsText || '(nenhum)',\n '',\n 'Ultimas tarefas:',\n tasksText || '(nenhuma)',\n ].join('\\n');\n return truncate(`${header}\\n\\n${body}`, budget);\n}\n\nfunction stableStringify(value) {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;\n const keys = Object.keys(value).sort();\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;\n}\n\nfunction djb2Hash(str) {\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nconst FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade'];\n\nfunction buildContextFingerprint(ticket) {\n const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}`).sort();\n const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}`).sort();\n const picked = {};\n for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];\n picked.followups = followups;\n picked.tasks = tasks;\n return djb2Hash(stableStringify(picked));\n}\n\nconst candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);\nconst contextRows = $input.all().map((item) => item.json);\n\nfunction groupBy(rows, key) {\n const map = {};\n for (const row of rows) {\n const k = String(row[key]);\n if (!map[k]) map[k] = [];\n map[k].push(row);\n }\n return map;\n}\n\nconst followupRows = contextRows.filter((row) => row.tipo === 'followup');\nconst taskRows = contextRows.filter((row) => row.tipo === 'task');\n\nconst followupsByTicket = groupBy(followupRows, 'tickets_id');\nconst tasksByTicket = groupBy(taskRows, 'tickets_id');\n\nreturn candidatosBase.map((base) => {\n const idKey = String(base.id);\n const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));\n const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));\n const ticket = normalizeTicket(base, { followups, tasks });\n const contextHash = buildContextFingerprint(ticket);\n return { json: { ticket, contextHash } };\n});" + } + }, + { + "id": "datatable-buscar-memoria", + "name": "Data Table - Buscar Memoria", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 320, + -160 + ], + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_memoria" + }, + "filters": { + "conditions": [] + }, + "returnAll": true + } + }, + { + "id": "code-decidir-necessidade-ia", + "name": "Code - Decidir Necessidade de IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 560, + -160 + ], + "parameters": { + "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction needsReanalysis({ memory, currentHash, now }) {\n if (!memory) return { needsAi: true, reason: 'no_cache' };\n if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };\n if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };\n return { needsAi: false, reason: 'cache_valid' };\n}\n\nconst config = parseConfig($env);\nconst now = Date.now();\n\nconst items = $input.all().map((item) => item.json); // { ticket, contextHash }\nconst memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);\nconst memoryByTicket = {};\nfor (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;\n\nconst withDecision = items.map((item) => {\n const memory = memoryByTicket[String(item.ticket.id)] || null;\n const analysis = needsReanalysis({ memory, currentHash: item.contextHash, now });\n return { ticket: item.ticket, contextHash: item.contextHash, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi };\n});\n\nconst needingAi = withDecision\n .filter((item) => item.needsAiRaw)\n .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));\n\nconst allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));\n\nreturn withDecision.map((item) => {\n let resolucao;\n if (!item.needsAiRaw) {\n resolucao = 'cache';\n } else if (allowedIds.has(item.ticket.id)) {\n resolucao = 'ia';\n } else {\n resolucao = 'fallback_cap';\n }\n return {\n json: {\n ticket: item.ticket,\n contextHash: item.contextHash,\n memory: item.memory,\n needsAiReason: item.needsAiReason,\n resolucao,\n precisa_ia: resolucao === 'ia',\n config,\n now,\n },\n };\n});" + } + }, + { + "id": "if-precisa-ia", + "name": "IF - Precisa IA?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 800, + -160 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "cond-precisa-ia", + "leftValue": "={{ $json.precisa_ia }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "if-ia-ligada", + "name": "IF - IA Ligada?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 1040, + -320 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "cond-ia-ligada", + "leftValue": "={{ $json.config.aiEnabled }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "code-preparar-prompt-ia", + "name": "Code - Preparar Prompt IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1280, + -400 + ], + "parameters": { + "jsCode": "function normalizeTicket(row, extras) {\n const followups = (extras && extras.followups) || [];\n const tasks = (extras && extras.tasks) || [];\n return {\n id: Number(row.id),\n titulo: row.titulo,\n status: Number(row.status),\n status_nome: row.status_nome,\n aberto_em: row.aberto_em,\n ultima_interacao_em: row.ultima_interacao_em,\n dias_aberto: Number(row.dias_aberto),\n dias_sem_interacao: Number(row.dias_sem_interacao),\n sla_vencido: Number(row.sla_vencido) === 1,\n sla_em_risco: Number(row.sla_em_risco) === 1,\n horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),\n prioridade: Number(row.prioridade),\n urgencia: Number(row.urgencia),\n impacto: Number(row.impacto),\n entidade: row.entidade,\n categoria: row.categoria,\n tecnico_atribuido: row.tecnico_atribuido,\n sem_tecnico: Number(row.sem_tecnico) === 1,\n link_glpi: row.link_glpi,\n score_criticidade: Number(row.score_criticidade),\n followups,\n tasks,\n };\n}\n\nfunction truncate(text, maxChars) {\n const clean = String(text || '').replace(/\\s+/g, ' ').trim();\n return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;\n}\n\nfunction buildAiContextText(ticket, maxChars) {\n const budget = Math.max(200, Number(maxChars) || 4000);\n const followupsText = ticket.followups\n .slice(-5)\n .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`)\n .join('\\n');\n const tasksText = ticket.tasks\n .slice(-5)\n .map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`)\n .join('\\n');\n const header = [\n `Chamado #${ticket.id} - ${ticket.titulo}`,\n `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`,\n `Tecnico: ${ticket.tecnico_atribuido}`,\n `Prioridade: ${ticket.prioridade} Urgencia: ${ticket.urgencia} Impacto: ${ticket.impacto}`,\n `SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,\n ].join('\\n');\n const body = [\n 'Ultimos apontamentos:',\n followupsText || '(nenhum)',\n '',\n 'Ultimas tarefas:',\n tasksText || '(nenhuma)',\n ].join('\\n');\n return truncate(`${header}\\n\\n${body}`, budget);\n}\n\nconst VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nconst systemPrompt = [\n 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',\n 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',\n 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',\n 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',\n 'Idade do chamado sozinha NAO e evidencia de acionabilidade.',\n 'Identifique: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos que justifique a conclusao?',\n 'Se a informacao for insuficiente para decidir com confianca, use a classificacao INSUFFICIENT_CONTEXT e confidence baixo -- nao invente uma acao.',\n `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas).`,\n].join('\\n');\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);\n\n const requestBody = {\n model: data.config.aiModel,\n temperature: 0.1,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'ticket_classification',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n classification: { type: 'string', enum: VALID_CLASSIFICATIONS },\n actionable_today: { type: 'boolean' },\n confidence: { type: 'number' },\n blocker_type: { type: ['string', 'null'] },\n waiting_for: { type: ['string', 'null'] },\n next_action: { type: 'string' },\n suggested_action_type: { type: 'string' },\n business_risk: { type: 'string' },\n reason: { type: 'string' },\n evidence: { type: 'array', items: { type: 'string' } },\n },\n required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],\n },\n },\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n return { json: { ...data, requestBody } };\n});" + } + }, + { + "id": "http-openai-classificar", + "name": "HTTP - OpenAI Classificar Chamado", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1520, + -400 + ], + "continueOnFail": true, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.requestBody) }}", + "options": { + "timeout": 30000 + } + }, + "credentials": { + "openAiApi": { + "id": "WUGriAn5FgCiLza2", + "name": "OpenAI - Coordenador GLPI" + } + } + }, + { + "id": "code-validar-classificacao-ia", + "name": "Code - Validar Classificacao IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1760, + -400 + ], + "parameters": { + "jsCode": "const VALID_CLASSIFICATIONS = [\n 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',\n 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',\n 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',\n];\n\nfunction validateAiClassification(raw) {\n const errors = [];\n if (!raw || typeof raw !== 'object') {\n return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };\n }\n if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {\n errors.push(`classification invalida: ${raw.classification}`);\n }\n if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');\n const confidence = Number(raw.confidence);\n if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');\n if (typeof raw.next_action !== 'string' || raw.next_action.length > 200) errors.push('next_action invalido');\n if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');\n if (typeof raw.reason !== 'string') errors.push('reason deve ser string');\n\n if (errors.length) return { valid: false, data: null, errors };\n\n return {\n valid: true,\n errors: [],\n data: {\n classification: raw.classification,\n actionable_today: raw.actionable_today,\n confidence,\n blocker_type: raw.blocker_type ?? null,\n waiting_for: raw.waiting_for ?? null,\n next_action: raw.next_action,\n suggested_action_type: raw.suggested_action_type ?? 'technical_action',\n business_risk: raw.business_risk ?? 'medium',\n reason: raw.reason,\n evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],\n is_fallback: false,\n fallback_reason: null,\n },\n };\n}\n\nfunction buildFallbackClassification(reason) {\n return {\n classification: 'ACTIONABLE_NOW',\n actionable_today: true,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nconst promptItems = $('Code - Preparar Prompt IA').all();\nconst responseItems = $input.all();\n\nreturn responseItems.map((responseItem, index) => {\n const original = (promptItems[index] && promptItems[index].json) || {};\n const httpJson = responseItem.json || {};\n\n if (httpJson.error) {\n return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };\n }\n\n let raw = null;\n try {\n const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;\n raw = content ? JSON.parse(content) : null;\n } catch (e) {\n raw = null;\n }\n\n const validation = validateAiClassification(raw);\n const aiClassification = validation.valid ? validation.data : buildFallbackClassification('invalid_json');\n\n return { json: { ...original, aiClassification } };\n});" + } + }, + { + "id": "code-fallback-ia-desligada", + "name": "Code - Fallback IA Desligada", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1280, + -160 + ], + "parameters": { + "jsCode": "function buildFallbackClassification(reason) {\n return {\n classification: 'ACTIONABLE_NOW',\n actionable_today: true,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nreturn $input.all().map((item) => ({ json: { ...item.json, aiClassification: buildFallbackClassification('ai_disabled') } }));" + } + }, + { + "id": "code-reusar-classificacao-cache", + "name": "Code - Reusar Classificacao em Cache", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1040, + 80 + ], + "parameters": { + "jsCode": "function buildFallbackClassification(reason) {\n return {\n classification: 'ACTIONABLE_NOW',\n actionable_today: true,\n confidence: 0,\n blocker_type: null,\n waiting_for: null,\n next_action: 'Revisar chamado manualmente',\n suggested_action_type: 'manual_review',\n business_risk: 'medium',\n reason: `Fallback deterministico: ${reason}`,\n evidence: [],\n is_fallback: true,\n fallback_reason: reason,\n };\n}\n\nreturn $input.all().map((item) => {\n const data = item.json;\n if (data.resolucao === 'cache' && data.memory) {\n const m = data.memory;\n return {\n json: {\n ...data,\n aiClassification: {\n classification: m.ai_classification,\n actionable_today: m.ai_classification === 'ACTIONABLE_NOW' || m.ai_classification === 'UNASSIGNED_ACTIONABLE',\n confidence: Number(m.ai_confidence || 0),\n blocker_type: m.blocker_type || null,\n waiting_for: m.waiting_for || null,\n next_action: m.next_action || 'Revisar chamado',\n suggested_action_type: 'technical_action',\n business_risk: 'medium',\n reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.',\n evidence: [],\n is_fallback: false,\n fallback_reason: null,\n },\n },\n };\n }\n return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } };\n});" + } + }, + { + "id": "merge-consolidar-classificacoes", + "name": "Merge - Consolidar Classificacoes", + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 2000, + -160 + ], + "parameters": { + "mode": "append", + "numberInputs": 3 + } + }, + { + "id": "code-calcular-score", + "name": "Code - Calcular Score", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2240, + -160 + ], + "parameters": { + "jsCode": "const ONE_DAY_MS = 24 * 60 * 60 * 1000;\n\nconst BLOCKING_CLASSIFICATIONS = new Set([\n 'WAITING_REQUESTER', 'WAITING_VENDOR', 'SCHEDULED', 'DEPENDENCY_BLOCKED',\n]);\n\nfunction isCriticalOverride(ticket) {\n return Boolean(ticket.sla_vencido) && Number(ticket.prioridade) >= 4;\n}\n\nfunction computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {\n const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0;\n const lastSentAt = memory ? memory.last_sent_at : null;\n const nextReviewAt = memory ? memory.next_review_at : null;\n const critical = isCriticalOverride(ticket);\n\n let blocked = false;\n let penalty = 0;\n let reason = 'ok';\n let breakCooldown = false;\n\n if (classification === 'NEEDS_COORDINATION') {\n blocked = true;\n reason = 'needs_coordination';\n } else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) {\n blocked = true;\n reason = 'waiting_next_review';\n } else if (BLOCKING_CLASSIFICATIONS.has(classification) && !contextChanged) {\n blocked = true;\n reason = `blocked_${classification.toLowerCase()}`;\n }\n\n const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5;\n if (!contextChanged && sentRecently && consecutiveDaysSent > 0) {\n const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15;\n penalty = Math.min(90, perDayBase * consecutiveDaysSent);\n if (!blocked) reason = 'repeat_no_change';\n }\n\n if (blocked && critical && classification !== 'NEEDS_COORDINATION') {\n breakCooldown = true;\n blocked = false;\n penalty = Math.round(penalty / 2);\n reason = `${reason}_override_critical`;\n }\n\n return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };\n}\n\nconst CLASSIFICATION_BASE_POINTS = {\n ACTIONABLE_NOW: 200,\n UNASSIGNED_ACTIONABLE: 180,\n WAITING_APPROVAL: 40,\n INSUFFICIENT_CONTEXT: 0,\n WAITING_REQUESTER: -150,\n WAITING_VENDOR: -150,\n DEPENDENCY_BLOCKED: -150,\n PROJECT_LONG_TERM: -120,\n DUPLICATE_OR_OBSOLETE: -200,\n SCHEDULED: -1000,\n NEEDS_COORDINATION: -1000,\n};\n\nfunction computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {\n const classification = aiClassification.classification;\n const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });\n\n const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;\n const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;\n\n let actionabilityPoints = 0;\n if (!aiClassification.is_fallback) {\n actionabilityPoints = CLASSIFICATION_BASE_POINTS[classification] ?? 0;\n if (actionabilityPoints > 0 && Number(aiClassification.confidence) < config.aiConfidenceThreshold) {\n actionabilityPoints /= 2;\n }\n }\n\n const scheduledNotDue = classification === 'SCHEDULED';\n const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue;\n\n const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;\n\n let group = 'ranking_geral';\n const actionableIsh = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' || aiClassification.is_fallback;\n if (actionableIsh && ticket.sla_vencido && (ticket.prioridade >= 4 || ticket.urgencia >= 4)) group = 'critico_acionavel';\n else if (actionableIsh && ticket.sem_tecnico) group = 'sem_tecnico';\n else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';\n else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';\n\n return {\n score: Math.round(total * 100) / 100,\n group,\n excludedFromTop10,\n exclusionReason: excludedFromTop10 ? (scheduledNotDue ? 'scheduled_not_due' : cooldown.reason) : null,\n breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },\n cooldown,\n };\n}\n\nreturn $input.all().map((item) => {\n const data = item.json;\n const contextChanged = data.resolucao !== 'cache';\n const result = computeScore({\n ticket: data.ticket,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextChanged,\n config: data.config,\n now: data.now,\n });\n return { json: { ...data, contextChanged, scoreResult: result } };\n});" + } + }, + { + "id": "code-preparar-registro-memoria-analise", + "name": "Code - Preparar Registro Memoria Analise", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2480, + 160 + ], + "parameters": { + "jsCode": "const items = $input.all().map((item) => item.json);\nconst ONE_DAY = 24 * 60 * 60 * 1000;\n\nreturn items.map((data) => {\n const cls = data.aiClassification.classification;\n let nextReviewAt = null;\n if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY;\n else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY;\n\n return {\n json: {\n ticket_id: data.ticket.id,\n last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now),\n last_context_hash: data.contextHash,\n last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),\n ai_classification: cls,\n ai_confidence: data.aiClassification.confidence,\n blocker_type: data.aiClassification.blocker_type,\n waiting_for: data.aiClassification.waiting_for,\n next_action: data.aiClassification.next_action,\n next_review_at: nextReviewAt,\n last_score: data.scoreResult.score,\n last_ai_reason: data.aiClassification.reason,\n updated_at: data.now,\n },\n };\n});" + } + }, + { + "id": "datatable-upsert-memoria-analise", + "name": "Data Table - Upsert Memoria Analise", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2720, + 160 + ], + "parameters": { + "resource": "row", + "operation": "upsert", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_memoria" + }, + "columns": { + "mappingMode": "defineBelow", + "matchingColumns": [ + "ticket_id" + ], + "value": { + "ticket_id": "={{ $json.ticket_id }}", + "last_analyzed_at": "={{ $json.last_analyzed_at }}", + "last_context_hash": "={{ $json.last_context_hash }}", + "last_meaningful_change_at": "={{ $json.last_meaningful_change_at }}", + "ai_classification": "={{ $json.ai_classification }}", + "ai_confidence": "={{ $json.ai_confidence }}", + "blocker_type": "={{ $json.blocker_type }}", + "waiting_for": "={{ $json.waiting_for }}", + "next_action": "={{ $json.next_action }}", + "next_review_at": "={{ $json.next_review_at }}", + "last_score": "={{ $json.last_score }}", + "last_ai_reason": "={{ $json.last_ai_reason }}", + "updated_at": "={{ $json.updated_at }}" + } + } + } + }, + { + "id": "code-selecionar-top10", + "name": "Code - Selecionar Top 10", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2480, + -320 + ], + "parameters": { + "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n config: data.config,\n now: data.now,\n}));\n\nconst config = scored.length ? scored[0].config : parseConfig($env);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];" + } + }, + { + "id": "code-preparar-registro-envio", + "name": "Code - Preparar Registro de Envio", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2720, + -560 + ], + "parameters": { + "jsCode": "const ONE_DAY = 24 * 60 * 60 * 1000;\n\nreturn $input.all()\n .filter((item) => item.json._grupo === 'selected')\n .map((item) => {\n const data = item.json;\n const memory = data.memory;\n const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;\n const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;\n const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;\n const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;\n return {\n json: {\n ticket_id: data.ticket.id,\n last_sent_at: data.now,\n times_sent: timesSent,\n times_sent_30d: timesSent30d,\n consecutive_days_sent: consecutiveDaysSent,\n updated_at: data.now,\n },\n };\n });" + } + }, + { + "id": "datatable-registrar-envio", + "name": "Data Table - Registrar Envio", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2960, + -560 + ], + "parameters": { + "resource": "row", + "operation": "upsert", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_memoria" + }, + "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 return [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ].join('\\n');\n });\n return [header, '', ...blocks].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 return [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines].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 return [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ].join('\\n');\n });\n return [header, '', ...blocks].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 return [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines].join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];" + } + }, + { + "id": "merge-unir-shadow", + "name": "Merge - Unir Shadow", + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 3200, + 160 + ], + "parameters": { + "mode": "append", + "numberInputs": 2 + } + }, + { + "id": "code-comparar-legacy-v2", + "name": "Code - Comparar Legacy x V2", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3440, + 160 + ], + "parameters": { + "jsCode": "// Este node roda depois de \"Merge - Unir Shadow\" (append: Code - Montar Digest Coordenacao +\n// MySQL - Legado Top 10 (Shadow)), que existe so para GARANTIR que os dois ja executaram --\n// os dados continuam sendo lidos por nome, nao pelo item mesclado, para nao depender de ordem.\nconst digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }\nconst legacyRows = $('MySQL - Legado Top 10 (Shadow)').all().map((item) => item.json);\n\nconst legacyIds = legacyRows.map((row) => Number(row.id));\nconst v2Ids = digestData.selected.map((item) => Number(item.ticket.id));\n\nconst added = v2Ids.filter((id) => !legacyIds.includes(id));\nconst removed = legacyIds.filter((id) => !v2Ids.includes(id));\n\nconst addedReasons = added.map((id) => {\n const item = digestData.selected.find((s) => Number(s.ticket.id) === id);\n return { id, motivo: item ? `${item.aiClassification.classification} - ${item.aiClassification.reason}` : 'desconhecido' };\n});\nconst removedReasons = removed.map((id) => {\n const excludedItem = digestData.excluded.find((e) => Number(e.ticket.id) === id);\n return { id, motivo: excludedItem ? (excludedItem.exclusionReason || excludedItem.aiClassification.classification) : 'nao estava nos candidatos V2' };\n});\n\nreturn [{\n json: {\n run_at: Date.now(),\n legacy_ids: JSON.stringify(legacyIds),\n v2_ids: JSON.stringify(v2Ids),\n added: JSON.stringify(added),\n removed: JSON.stringify(removed),\n summary: JSON.stringify({ addedReasons, removedReasons }),\n mensagem: digestData.mensagem,\n digest: digestData.digest,\n },\n}];" + } + }, + { + "id": "datatable-registrar-shadow-run", + "name": "Data Table - Registrar Shadow Run", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 3680, + 160 + ], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_shadow_runs" + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "run_at": "={{ $json.run_at }}", + "legacy_ids": "={{ $json.legacy_ids }}", + "v2_ids": "={{ $json.v2_ids }}", + "added": "={{ $json.added }}", + "removed": "={{ $json.removed }}", + "summary": "={{ $json.summary }}" + } + } + } + }, + { + "id": "debug-ver-painel", + "name": "Debug - Ver Painel", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -1600, + 1200 + ], + "parameters": { + "httpMethod": "GET", + "path": "coordenador-glpi-v2-debug", + "responseMode": "responseNode", + "options": {} + } + }, + { + "id": "datatable-buscar-shadow-runs-recentes", + "name": "Data Table - Buscar Shadow Runs Recentes", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + -1360, + 1200 + ], + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "name", + "value": "coordenador_glpi_shadow_runs" + }, + "filters": { + "conditions": [] + }, + "returnAll": true + } + }, + { + "id": "code-montar-painel-debug", + "name": "Code - Montar Painel Debug", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1120, + 1200 + ], + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nrows.sort((a, b) => Number(b.run_at || 0) - Number(a.run_at || 0));\nconst latest = rows[0] || null;\n\nconst painel = {\n gerado_em: new Date().toISOString(),\n ultima_execucao_shadow: latest,\n total_execucoes_recentes: rows.length,\n};\n\nreturn [{ json: painel }];" + } + }, + { + "id": "debug-responder", + "name": "Debug - Responder", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -880, + 1200 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + } } ], "connections": { @@ -109,6 +678,349 @@ } ] ] + }, + "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": "Data Table - Buscar Memoria", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Memoria": { + "main": [ + [ + { + "node": "Code - Decidir Necessidade de IA", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Decidir Necessidade de IA": { + "main": [ + [ + { + "node": "IF - Precisa IA?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Precisa IA?": { + "main": [ + [ + { + "node": "IF - IA Ligada?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Reusar Classificacao em Cache", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - IA Ligada?": { + "main": [ + [ + { + "node": "Code - Preparar Prompt IA", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Fallback IA Desligada", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Prompt IA": { + "main": [ + [ + { + "node": "HTTP - OpenAI Classificar Chamado", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - OpenAI Classificar Chamado": { + "main": [ + [ + { + "node": "Code - Validar Classificacao IA", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Validar Classificacao IA": { + "main": [ + [ + { + "node": "Merge - Consolidar Classificacoes", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Fallback IA Desligada": { + "main": [ + [ + { + "node": "Merge - Consolidar Classificacoes", + "type": "main", + "index": 1 + } + ] + ] + }, + "Code - Reusar Classificacao em Cache": { + "main": [ + [ + { + "node": "Merge - Consolidar Classificacoes", + "type": "main", + "index": 2 + } + ] + ] + }, + "Merge - Consolidar Classificacoes": { + "main": [ + [ + { + "node": "Code - Calcular Score", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Calcular Score": { + "main": [ + [ + { + "node": "Code - Selecionar Top 10", + "type": "main", + "index": 0 + }, + { + "node": "Code - Preparar Registro Memoria Analise", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Registro Memoria Analise": { + "main": [ + [ + { + "node": "Data Table - Upsert Memoria Analise", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Selecionar Top 10": { + "main": [ + [ + { + "node": "Code - Montar Mensagem Curta", + "type": "main", + "index": 0 + }, + { + "node": "Code - Preparar Registro de Envio", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Registro de Envio": { + "main": [ + [ + { + "node": "Data Table - Registrar Envio", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Montar Mensagem Curta": { + "main": [ + [ + { + "node": "Code - Montar Digest Coordenacao", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Montar Digest Coordenacao": { + "main": [ + [ + { + "node": "Merge - Unir Shadow", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Legado Top 10 (Shadow)": { + "main": [ + [ + { + "node": "Merge - Unir Shadow", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Unir Shadow": { + "main": [ + [ + { + "node": "Code - Comparar Legacy x V2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Comparar Legacy x V2": { + "main": [ + [ + { + "node": "Data Table - Registrar Shadow Run", + "type": "main", + "index": 0 + } + ] + ] + }, + "Debug - Ver Painel": { + "main": [ + [ + { + "node": "Data Table - Buscar Shadow Runs Recentes", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Shadow Runs Recentes": { + "main": [ + [ + { + "node": "Code - Montar Painel Debug", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Montar Painel Debug": { + "main": [ + [ + { + "node": "Debug - Responder", + "type": "main", + "index": 0 + } + ] + ] } }, "pinData": {}, diff --git a/n8n/nodes/ai-disabled-fallback.node.js b/n8n/nodes/ai-disabled-fallback.node.js new file mode 100644 index 0000000..740898b --- /dev/null +++ b/n8n/nodes/ai-disabled-fallback.node.js @@ -0,0 +1,2 @@ +// @requires fallback.js +return $input.all().map((item) => ({ json: { ...item.json, aiClassification: buildFallbackClassification('ai_disabled') } })); diff --git a/n8n/nodes/build-coordination-digest.node.js b/n8n/nodes/build-coordination-digest.node.js new file mode 100644 index 0000000..3014be2 --- /dev/null +++ b/n8n/nodes/build-coordination-digest.node.js @@ -0,0 +1,14 @@ +// @requires message.js +const data = $input.first().json; // { mensagem, selected, excluded, dateStr } + +const changedBlocked = data.excluded.filter((item) => { + const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION'; + if (!isCoordination) return false; + const memory = item.memory; + if (!memory) return true; + return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason; +}); + +const digest = buildCoordinationDigest(changedBlocked, data.dateStr); + +return [{ json: { ...data, digest, changedBlocked } }]; diff --git a/n8n/nodes/build-sql-context.node.js b/n8n/nodes/build-sql-context.node.js new file mode 100644 index 0000000..577bd4c --- /dev/null +++ b/n8n/nodes/build-sql-context.node.js @@ -0,0 +1,8 @@ +const candidatos = $input.all().map((item) => item.json); +const ids = candidatos.map((c) => Number(c.id)).filter((id) => Number.isInteger(id)); +const idsList = ids.length ? ids.join(',') : '0'; + +const queryTemplate = /* __SQL_CONTEXTO__ */; +const query = queryTemplate.replaceAll('{IDS}', idsList); + +return [{ json: { query, candidateIds: ids } }]; diff --git a/n8n/nodes/build-top-message.node.js b/n8n/nodes/build-top-message.node.js new file mode 100644 index 0000000..8798fcf --- /dev/null +++ b/n8n/nodes/build-top-message.node.js @@ -0,0 +1,9 @@ +// @requires message.js +const items = $input.all().map((item) => item.json); +const selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score); +const excluded = items.filter((data) => data._grupo === 'excluded'); + +const dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' }); +const mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {}); + +return [{ json: { mensagem, selected, excluded, dateStr } }]; diff --git a/n8n/nodes/compare-legacy-v2.node.js b/n8n/nodes/compare-legacy-v2.node.js new file mode 100644 index 0000000..a07ea94 --- /dev/null +++ b/n8n/nodes/compare-legacy-v2.node.js @@ -0,0 +1,33 @@ +// Este node roda depois de "Merge - Unir Shadow" (append: Code - Montar Digest Coordenacao + +// MySQL - Legado Top 10 (Shadow)), que existe so para GARANTIR que os dois ja executaram -- +// os dados continuam sendo lidos por nome, nao pelo item mesclado, para nao depender de ordem. +const digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked } +const legacyRows = $('MySQL - Legado Top 10 (Shadow)').all().map((item) => item.json); + +const legacyIds = legacyRows.map((row) => Number(row.id)); +const v2Ids = digestData.selected.map((item) => Number(item.ticket.id)); + +const added = v2Ids.filter((id) => !legacyIds.includes(id)); +const removed = legacyIds.filter((id) => !v2Ids.includes(id)); + +const addedReasons = added.map((id) => { + const item = digestData.selected.find((s) => Number(s.ticket.id) === id); + return { id, motivo: item ? `${item.aiClassification.classification} - ${item.aiClassification.reason}` : 'desconhecido' }; +}); +const removedReasons = removed.map((id) => { + const excludedItem = digestData.excluded.find((e) => Number(e.ticket.id) === id); + return { id, motivo: excludedItem ? (excludedItem.exclusionReason || excludedItem.aiClassification.classification) : 'nao estava nos candidatos V2' }; +}); + +return [{ + json: { + run_at: Date.now(), + legacy_ids: JSON.stringify(legacyIds), + v2_ids: JSON.stringify(v2Ids), + added: JSON.stringify(added), + removed: JSON.stringify(removed), + summary: JSON.stringify({ addedReasons, removedReasons }), + mensagem: digestData.mensagem, + digest: digestData.digest, + }, +}]; diff --git a/n8n/nodes/compute-score.node.js b/n8n/nodes/compute-score.node.js new file mode 100644 index 0000000..2da1069 --- /dev/null +++ b/n8n/nodes/compute-score.node.js @@ -0,0 +1,14 @@ +// @requires cooldown.js score.js +return $input.all().map((item) => { + const data = item.json; + const contextChanged = data.resolucao !== 'cache'; + const result = computeScore({ + ticket: data.ticket, + aiClassification: data.aiClassification, + memory: data.memory, + contextChanged, + config: data.config, + now: data.now, + }); + return { json: { ...data, contextChanged, scoreResult: result } }; +}); diff --git a/n8n/nodes/debug-panel.node.js b/n8n/nodes/debug-panel.node.js new file mode 100644 index 0000000..b9f42ca --- /dev/null +++ b/n8n/nodes/debug-panel.node.js @@ -0,0 +1,11 @@ +const rows = $input.all().map((item) => item.json); +rows.sort((a, b) => Number(b.run_at || 0) - Number(a.run_at || 0)); +const latest = rows[0] || null; + +const painel = { + gerado_em: new Date().toISOString(), + ultima_execucao_shadow: latest, + total_execucoes_recentes: rows.length, +}; + +return [{ json: painel }]; diff --git a/n8n/nodes/decide-needs-ai.node.js b/n8n/nodes/decide-needs-ai.node.js new file mode 100644 index 0000000..21f07f2 --- /dev/null +++ b/n8n/nodes/decide-needs-ai.node.js @@ -0,0 +1,43 @@ +// @requires config.js cache.js +const config = parseConfig($env); +const now = Date.now(); + +const items = $input.all().map((item) => item.json); // { ticket, contextHash } +const memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json); +const memoryByTicket = {}; +for (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row; + +const withDecision = items.map((item) => { + const memory = memoryByTicket[String(item.ticket.id)] || null; + const analysis = needsReanalysis({ memory, currentHash: item.contextHash, now }); + return { ticket: item.ticket, contextHash: item.contextHash, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi }; +}); + +const needingAi = withDecision + .filter((item) => item.needsAiRaw) + .sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0)); + +const allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id)); + +return withDecision.map((item) => { + let resolucao; + if (!item.needsAiRaw) { + resolucao = 'cache'; + } else if (allowedIds.has(item.ticket.id)) { + resolucao = 'ia'; + } else { + resolucao = 'fallback_cap'; + } + return { + json: { + ticket: item.ticket, + contextHash: item.contextHash, + memory: item.memory, + needsAiReason: item.needsAiReason, + resolucao, + precisa_ia: resolucao === 'ia', + config, + now, + }, + }; +}); diff --git a/n8n/nodes/merge-context-hash.node.js b/n8n/nodes/merge-context-hash.node.js new file mode 100644 index 0000000..3d1fed0 --- /dev/null +++ b/n8n/nodes/merge-context-hash.node.js @@ -0,0 +1,28 @@ +// @requires context.js hash.js +const candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json); +const contextRows = $input.all().map((item) => item.json); + +function groupBy(rows, key) { + const map = {}; + for (const row of rows) { + const k = String(row[key]); + if (!map[k]) map[k] = []; + map[k].push(row); + } + return map; +} + +const followupRows = contextRows.filter((row) => row.tipo === 'followup'); +const taskRows = contextRows.filter((row) => row.tipo === 'task'); + +const followupsByTicket = groupBy(followupRows, 'tickets_id'); +const tasksByTicket = groupBy(taskRows, 'tickets_id'); + +return candidatosBase.map((base) => { + const idKey = String(base.id); + const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto })); + const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto })); + const ticket = normalizeTicket(base, { followups, tasks }); + const contextHash = buildContextFingerprint(ticket); + return { json: { ticket, contextHash } }; +}); diff --git a/n8n/nodes/normalize-candidates.node.js b/n8n/nodes/normalize-candidates.node.js new file mode 100644 index 0000000..5ce5e31 --- /dev/null +++ b/n8n/nodes/normalize-candidates.node.js @@ -0,0 +1,3 @@ +// @requires context.js +const items = $input.all(); +return items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) })); diff --git a/n8n/nodes/prepare-ai-prompt.node.js b/n8n/nodes/prepare-ai-prompt.node.js new file mode 100644 index 0000000..d92b341 --- /dev/null +++ b/n8n/nodes/prepare-ai-prompt.node.js @@ -0,0 +1,57 @@ +// @requires context.js +const VALID_CLASSIFICATIONS = [ + 'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR', + 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION', + 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT', +]; + +const systemPrompt = [ + 'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.', + 'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.', + 'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).', + 'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.', + 'Idade do chamado sozinha NAO e evidencia de acionabilidade.', + 'Identifique: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos que justifique a conclusao?', + 'Se a informacao for insuficiente para decidir com confianca, use a classificacao INSUFFICIENT_CONTEXT e confidence baixo -- nao invente uma acao.', + `Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas).`, +].join('\n'); + +return $input.all().map((item) => { + const data = item.json; + const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars); + + const requestBody = { + model: data.config.aiModel, + temperature: 0.1, + response_format: { + type: 'json_schema', + json_schema: { + name: 'ticket_classification', + strict: true, + schema: { + type: 'object', + additionalProperties: false, + properties: { + classification: { type: 'string', enum: VALID_CLASSIFICATIONS }, + actionable_today: { type: 'boolean' }, + confidence: { type: 'number' }, + blocker_type: { type: ['string', 'null'] }, + waiting_for: { type: ['string', 'null'] }, + next_action: { type: 'string' }, + suggested_action_type: { type: 'string' }, + business_risk: { type: 'string' }, + reason: { type: 'string' }, + evidence: { type: 'array', items: { type: 'string' } }, + }, + required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'], + }, + }, + }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }; + + return { json: { ...data, requestBody } }; +}); diff --git a/n8n/nodes/prepare-envio-record.node.js b/n8n/nodes/prepare-envio-record.node.js new file mode 100644 index 0000000..f6492c3 --- /dev/null +++ b/n8n/nodes/prepare-envio-record.node.js @@ -0,0 +1,22 @@ +const ONE_DAY = 24 * 60 * 60 * 1000; + +return $input.all() + .filter((item) => item.json._grupo === 'selected') + .map((item) => { + const data = item.json; + const memory = data.memory; + const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5; + const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1; + const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1; + const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1; + return { + json: { + ticket_id: data.ticket.id, + last_sent_at: data.now, + times_sent: timesSent, + times_sent_30d: timesSent30d, + consecutive_days_sent: consecutiveDaysSent, + updated_at: data.now, + }, + }; + }); diff --git a/n8n/nodes/prepare-memoria-analise-record.node.js b/n8n/nodes/prepare-memoria-analise-record.node.js new file mode 100644 index 0000000..627c422 --- /dev/null +++ b/n8n/nodes/prepare-memoria-analise-record.node.js @@ -0,0 +1,27 @@ +const items = $input.all().map((item) => item.json); +const ONE_DAY = 24 * 60 * 60 * 1000; + +return items.map((data) => { + const cls = data.aiClassification.classification; + let nextReviewAt = null; + if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY; + else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY; + + return { + json: { + ticket_id: data.ticket.id, + last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now), + last_context_hash: data.contextHash, + last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now), + ai_classification: cls, + ai_confidence: data.aiClassification.confidence, + blocker_type: data.aiClassification.blocker_type, + waiting_for: data.aiClassification.waiting_for, + next_action: data.aiClassification.next_action, + next_review_at: nextReviewAt, + last_score: data.scoreResult.score, + last_ai_reason: data.aiClassification.reason, + updated_at: data.now, + }, + }; +}); diff --git a/n8n/nodes/reuse-cached-classification.node.js b/n8n/nodes/reuse-cached-classification.node.js new file mode 100644 index 0000000..172b918 --- /dev/null +++ b/n8n/nodes/reuse-cached-classification.node.js @@ -0,0 +1,27 @@ +// @requires fallback.js +return $input.all().map((item) => { + const data = item.json; + if (data.resolucao === 'cache' && data.memory) { + const m = data.memory; + return { + json: { + ...data, + aiClassification: { + classification: m.ai_classification, + actionable_today: m.ai_classification === 'ACTIONABLE_NOW' || m.ai_classification === 'UNASSIGNED_ACTIONABLE', + confidence: Number(m.ai_confidence || 0), + blocker_type: m.blocker_type || null, + waiting_for: m.waiting_for || null, + next_action: m.next_action || 'Revisar chamado', + suggested_action_type: 'technical_action', + business_risk: 'medium', + reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.', + evidence: [], + is_fallback: false, + fallback_reason: null, + }, + }, + }; + } + return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } }; +}); diff --git a/n8n/nodes/select-top10.node.js b/n8n/nodes/select-top10.node.js new file mode 100644 index 0000000..a58da88 --- /dev/null +++ b/n8n/nodes/select-top10.node.js @@ -0,0 +1,24 @@ +// @requires config.js diversity.js +const items = $input.all().map((item) => item.json); + +const scored = items.map((data) => ({ + ticket: data.ticket, + score: data.scoreResult.score, + group: data.scoreResult.group, + excludedFromTop10: data.scoreResult.excludedFromTop10, + exclusionReason: data.scoreResult.exclusionReason, + aiClassification: data.aiClassification, + memory: data.memory, + contextHash: data.contextHash, + contextChanged: data.contextChanged, + config: data.config, + now: data.now, +})); + +const config = scored.length ? scored[0].config : parseConfig($env); +const { selected, excluded } = selectTop(scored, config.topN, config); + +const selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } })); +const excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } })); + +return [...selectedOut, ...excludedOut]; diff --git a/n8n/nodes/validate-ai-response.node.js b/n8n/nodes/validate-ai-response.node.js new file mode 100644 index 0000000..45f72d5 --- /dev/null +++ b/n8n/nodes/validate-ai-response.node.js @@ -0,0 +1,25 @@ +// @requires classification.js fallback.js +const promptItems = $('Code - Preparar Prompt IA').all(); +const responseItems = $input.all(); + +return responseItems.map((responseItem, index) => { + const original = (promptItems[index] && promptItems[index].json) || {}; + const httpJson = responseItem.json || {}; + + if (httpJson.error) { + return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } }; + } + + let raw = null; + try { + const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content; + raw = content ? JSON.parse(content) : null; + } catch (e) { + raw = null; + } + + const validation = validateAiClassification(raw); + const aiClassification = validation.valid ? validation.data : buildFallbackClassification('invalid_json'); + + return { json: { ...original, aiClassification } }; +}); diff --git a/n8n/workflow.template.json b/n8n/workflow.template.json index 5c6c879..49962bc 100644 --- a/n8n/workflow.template.json +++ b/n8n/workflow.template.json @@ -6,7 +6,7 @@ "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, - "position": [180, 300], + "position": [-1600, 0], "parameters": {} }, { @@ -14,7 +14,7 @@ "name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, - "position": [180, 640], + "position": [-1600, 900], "parameters": { "rule": { "interval": [ @@ -28,7 +28,7 @@ "name": "Config - Coordenador GLPI v2", "type": "n8n-nodes-base.set", "typeVersion": 3.4, - "position": [440, 300], + "position": [-1360, 0], "parameters": { "assignments": { "assignments": [ @@ -48,37 +48,488 @@ "name": "Code - Montar SQL Candidatos", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [700, 220], - "parameters": { - "jsCode": "__BUILD__:build-sql-candidatos.node.js" - } + "position": [-1120, -160], + "parameters": { "jsCode": "__BUILD__:build-sql-candidatos.node.js" } }, { "id": "code-montar-sql-legado", "name": "Code - Montar SQL Legado", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [700, 460], + "position": [-1120, 900], + "parameters": { "jsCode": "__BUILD__:build-sql-legado.node.js" } + }, + { + "id": "mysql-buscar-candidatos", + "name": "MySQL - Buscar Candidatos", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [-880, -160], + "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} }, + "credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } } + }, + { + "id": "mysql-legado-top10-shadow", + "name": "MySQL - Legado Top 10 (Shadow)", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [-880, 900], + "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} }, + "credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } } + }, + { + "id": "code-normalizar-candidatos-base", + "name": "Code - Normalizar Candidatos Base", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-640, -160], + "parameters": { "jsCode": "__BUILD__:normalize-candidates.node.js" } + }, + { + "id": "code-montar-sql-contexto", + "name": "Code - Montar SQL Contexto", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-400, -160], + "parameters": { "jsCode": "__BUILD__:build-sql-context.node.js" } + }, + { + "id": "mysql-buscar-contexto", + "name": "MySQL - Buscar Contexto", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [-160, -160], + "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} }, + "credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } } + }, + { + "id": "code-mesclar-contexto-hash", + "name": "Code - Mesclar Contexto e Calcular Hash", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [80, -160], + "parameters": { "jsCode": "__BUILD__:merge-context-hash.node.js" } + }, + { + "id": "datatable-buscar-memoria", + "name": "Data Table - Buscar Memoria", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [320, -160], "parameters": { - "jsCode": "__BUILD__:build-sql-legado.node.js" + "resource": "row", + "operation": "get", + "dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" }, + "filters": { "conditions": [] }, + "returnAll": true } + }, + { + "id": "code-decidir-necessidade-ia", + "name": "Code - Decidir Necessidade de IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [560, -160], + "parameters": { "jsCode": "__BUILD__:decide-needs-ai.node.js" } + }, + { + "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": "__BUILD__:prepare-ai-prompt.node.js" } + }, + { + "id": "http-openai-classificar", + "name": "HTTP - OpenAI Classificar Chamado", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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": "__BUILD__:validate-ai-response.node.js" } + }, + { + "id": "code-fallback-ia-desligada", + "name": "Code - Fallback IA Desligada", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1280, -160], + "parameters": { "jsCode": "__BUILD__:ai-disabled-fallback.node.js" } + }, + { + "id": "code-reusar-classificacao-cache", + "name": "Code - Reusar Classificacao em Cache", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1040, 80], + "parameters": { "jsCode": "__BUILD__:reuse-cached-classification.node.js" } + }, + { + "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": "__BUILD__:compute-score.node.js" } + }, + { + "id": "code-preparar-registro-memoria-analise", + "name": "Code - Preparar Registro Memoria Analise", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2480, 160], + "parameters": { "jsCode": "__BUILD__:prepare-memoria-analise-record.node.js" } + }, + { + "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" }, + "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": "__BUILD__:select-top10.node.js" } + }, + { + "id": "code-preparar-registro-envio", + "name": "Code - Preparar Registro de Envio", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2720, -560], + "parameters": { "jsCode": "__BUILD__:prepare-envio-record.node.js" } + }, + { + "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" }, + "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": "__BUILD__:build-top-message.node.js" } + }, + { + "id": "code-montar-digest-coordenacao", + "name": "Code - Montar Digest Coordenacao", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2960, -320], + "parameters": { "jsCode": "__BUILD__:build-coordination-digest.node.js" } + }, + { + "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": "__BUILD__:compare-legacy-v2.node.js" } + }, + { + "id": "datatable-registrar-shadow-run", + "name": "Data Table - Registrar Shadow Run", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [3680, 160], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { "mode": "name", "value": "coordenador_glpi_shadow_runs" }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "run_at": "={{ $json.run_at }}", + "legacy_ids": "={{ $json.legacy_ids }}", + "v2_ids": "={{ $json.v2_ids }}", + "added": "={{ $json.added }}", + "removed": "={{ $json.removed }}", + "summary": "={{ $json.summary }}" + } + } + } + }, + { + "id": "debug-ver-painel", + "name": "Debug - Ver Painel", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [-1600, 1200], + "parameters": { + "httpMethod": "GET", + "path": "coordenador-glpi-v2-debug", + "responseMode": "responseNode", + "options": {} + } + }, + { + "id": "datatable-buscar-shadow-runs-recentes", + "name": "Data Table - Buscar Shadow Runs Recentes", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [-1360, 1200], + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { "mode": "name", "value": "coordenador_glpi_shadow_runs" }, + "filters": { "conditions": [] }, + "returnAll": true + } + }, + { + "id": "code-montar-painel-debug", + "name": "Code - Montar Painel Debug", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-1120, 1200], + "parameters": { "jsCode": "__BUILD__:debug-panel.node.js" } + }, + { + "id": "debug-responder", + "name": "Debug - Responder", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [-880, 1200], + "parameters": { "respondWith": "json", "responseBody": "={{ $json }}", "options": {} } } ], "connections": { "Manual Trigger": { - "main": [ - [ - { "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 } - ] - ] + "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 } + ]] + }, + "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": "Data Table - Buscar Memoria", "type": "main", "index": 0 }]] + }, + "Data Table - Buscar 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": "Code - Montar SQL Candidatos", "type": "main", "index": 0 }, - { "node": "Code - Montar SQL Legado", "type": "main", "index": 0 } - ] + [{ "node": "IF - IA Ligada?", "type": "main", "index": 0 }], + [{ "node": "Code - Reusar Classificacao em Cache", "type": "main", "index": 0 }] ] + }, + "IF - IA Ligada?": { + "main": [ + [{ "node": "Code - Preparar Prompt IA", "type": "main", "index": 0 }], + [{ "node": "Code - Fallback IA Desligada", "type": "main", "index": 0 }] + ] + }, + "Code - Preparar Prompt IA": { + "main": [[{ "node": "HTTP - OpenAI Classificar Chamado", "type": "main", "index": 0 }]] + }, + "HTTP - OpenAI Classificar Chamado": { + "main": [[{ "node": "Code - Validar Classificacao IA", "type": "main", "index": 0 }]] + }, + "Code - Validar Classificacao IA": { + "main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 0 }]] + }, + "Code - Fallback IA Desligada": { + "main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 1 }]] + }, + "Code - Reusar Classificacao em Cache": { + "main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 2 }]] + }, + "Merge - Consolidar Classificacoes": { + "main": [[{ "node": "Code - Calcular Score", "type": "main", "index": 0 }]] + }, + "Code - Calcular Score": { + "main": [[ + { "node": "Code - Selecionar Top 10", "type": "main", "index": 0 }, + { "node": "Code - Preparar Registro Memoria Analise", "type": "main", "index": 0 } + ]] + }, + "Code - Preparar Registro Memoria Analise": { + "main": [[{ "node": "Data Table - Upsert Memoria Analise", "type": "main", "index": 0 }]] + }, + "Code - Selecionar Top 10": { + "main": [[ + { "node": "Code - Montar Mensagem Curta", "type": "main", "index": 0 }, + { "node": "Code - Preparar Registro de Envio", "type": "main", "index": 0 } + ]] + }, + "Code - Preparar Registro de Envio": { + "main": [[{ "node": "Data Table - Registrar Envio", "type": "main", "index": 0 }]] + }, + "Code - Montar Mensagem Curta": { + "main": [[{ "node": "Code - Montar Digest Coordenacao", "type": "main", "index": 0 }]] + }, + "Code - Montar Digest Coordenacao": { + "main": [[{ "node": "Merge - Unir Shadow", "type": "main", "index": 0 }]] + }, + "MySQL - Legado Top 10 (Shadow)": { + "main": [[{ "node": "Merge - Unir Shadow", "type": "main", "index": 1 }]] + }, + "Merge - Unir Shadow": { + "main": [[{ "node": "Code - Comparar Legacy x V2", "type": "main", "index": 0 }]] + }, + "Code - Comparar Legacy x V2": { + "main": [[{ "node": "Data Table - Registrar Shadow Run", "type": "main", "index": 0 }]] + }, + "Debug - Ver Painel": { + "main": [[{ "node": "Data Table - Buscar Shadow Runs Recentes", "type": "main", "index": 0 }]] + }, + "Data Table - Buscar Shadow Runs Recentes": { + "main": [[{ "node": "Code - Montar Painel Debug", "type": "main", "index": 0 }]] + }, + "Code - Montar Painel Debug": { + "main": [[{ "node": "Debug - Responder", "type": "main", "index": 0 }]] } }, "pinData": {}, diff --git a/sql/contexto_followups_glpi.sql b/sql/contexto_followups_glpi.sql deleted file mode 100644 index e99cc12..0000000 --- a/sql/contexto_followups_glpi.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Followups publicos dos candidatos (IDs interpolados pelo node, validados como inteiros). -SELECT items_id AS tickets_id, id, date, content AS texto -FROM glpi_itilfollowups -WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS}) -ORDER BY items_id, date ASC; diff --git a/sql/contexto_glpi.sql b/sql/contexto_glpi.sql new file mode 100644 index 0000000..a04e351 --- /dev/null +++ b/sql/contexto_glpi.sql @@ -0,0 +1,17 @@ +-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query +-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram. +-- IDs interpolados pelo node como lista de inteiros (nunca string crua do GLPI). +-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a +-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano. + +SELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status +FROM glpi_itilfollowups +WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS}) + +UNION ALL + +SELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status +FROM glpi_tickettasks +WHERE tickets_id IN ({IDS}) + +ORDER BY tickets_id, date ASC; diff --git a/sql/contexto_tarefas_glpi.sql b/sql/contexto_tarefas_glpi.sql deleted file mode 100644 index cb7a4d5..0000000 --- a/sql/contexto_tarefas_glpi.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Tarefas dos candidatos. Coluna de status a confirmar via DESCRIBE glpi_tickettasks --- quando a credencial MySQL estiver disponivel (Task 14) -- ver nota no plano. -SELECT tickets_id, id, date, state AS status, content AS texto -FROM glpi_tickettasks -WHERE tickets_id IN ({IDS}) -ORDER BY tickets_id, date ASC; diff --git a/tools/build-workflow.js b/tools/build-workflow.js index 6ce5765..a503aaf 100644 --- a/tools/build-workflow.js +++ b/tools/build-workflow.js @@ -5,8 +5,13 @@ const ROOT = path.join(__dirname, '..'); function readLogicSource(fileName) { const raw = fs.readFileSync(path.join(ROOT, 'logic', fileName), 'utf8'); - // Remove a linha final "module.exports = {...}" -- as funcoes ficam soltas no escopo do node. - return raw.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '').trim(); + return raw + // Remove a linha final "module.exports = {...}" -- as funcoes ficam soltas no escopo do node. + .replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '') + // Remove requires de outros modulos de logic/*.js -- quando inlined, a funcao requerida + // ja esta no mesmo escopo (senao vira identificador duplicado: function X + const {X} = require(...)). + .replace(/^const\s*\{[^}]*\}\s*=\s*require\(['"]\.\/[a-zA-Z0-9_-]+['"]\);?\s*$/gm, '') + .trim(); } function inlinedLogic(fileNames) { @@ -29,8 +34,7 @@ function buildDriverCode(driverRelativePath) { driver = driver.replace('/* __SQL_CANDIDATOS_V2__ */', sqlAsJsTemplateLiteral('sql/candidatos_v2_glpi.sql')); driver = driver.replace('/* __SQL_LEGADO_V1_VERBATIM__ */', sqlAsJsTemplateLiteral('sql/top_10_chamados_abertos_glpi.sql')); - driver = driver.replace('/* __SQL_CONTEXTO_FOLLOWUPS__ */', sqlAsJsTemplateLiteral('sql/contexto_followups_glpi.sql')); - driver = driver.replace('/* __SQL_CONTEXTO_TAREFAS__ */', sqlAsJsTemplateLiteral('sql/contexto_tarefas_glpi.sql')); + driver = driver.replace('/* __SQL_CONTEXTO__ */', sqlAsJsTemplateLiteral('sql/contexto_glpi.sql')); const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : ''; return `${logicBlock}${driver.trim()}`; diff --git a/tools/build-workflow.test.js b/tools/build-workflow.test.js index 5ea25d6..f556d4b 100644 --- a/tools/build-workflow.test.js +++ b/tools/build-workflow.test.js @@ -26,3 +26,24 @@ test('nenhum node de Code sobra com marcador nao resolvido', () => { } } }); + +test('todo jsCode gerado e sintaticamente valido (pega identificador duplicado, chaves erradas, etc.)', () => { + const wf = buildWorkflow(); + for (const node of wf.nodes) { + if (node.type === 'n8n-nodes-base.code') { + assert.doesNotThrow(() => { + // eslint-disable-next-line no-new-func + new Function('$input', '$env', '$', node.parameters.jsCode); + }, `node ${node.name} tem jsCode com erro de sintaxe`); + } + } +}); + +test('nenhum jsCode gerado contem "require(" sobrando (modulos devem estar totalmente inlined)', () => { + const wf = buildWorkflow(); + for (const node of wf.nodes) { + if (node.type === 'n8n-nodes-base.code') { + assert.ok(!/\brequire\(/.test(node.parameters.jsCode), `node ${node.name} ainda tem require() nao resolvido`); + } + } +});