Files
SLACK_COORD_RELATORIO/tools/build_v2_workflow_assemble.js
T
Automacao GLPIandClaude Sonnet 5 bdfa675d89 Adiciona V2 do relatorio GLPI: camada deterministica + IA com fallback
Evolui a automacao (V1 preservada, intocada) para uma V2 com:

- Camada deterministica (src/v2): calendario operacional (porta fiel da
  logica da V1), queries SQL somente-leitura (indicadores, ranking com
  IDs, contexto de chamados, baseline historico), deteccao de anomalia
  (spike de categoria/cliente, concentracao, tendencia de backlog),
  memoria/fingerprint de insights com transicao de estado (new ->
  persisting -> normalizing -> resolved), mensagens curtas (fallback V1 e
  V2). 97 testes automatizados (node --test).

- Camada de IA (structured output via OpenAI, schema JSON validado em
  codigo, nunca confia em numeros vindos da IA): prompt de "Analista
  Operacional", fallback automatico e seguro quando a IA falha/expira/
  retorna JSON invalido.

- Workflow n8n "[DEV] GLPI - Relatorio Operacional IA v2" (id
  3x5kb58TeBCvirm6, INATIVO) criado e validado ao vivo no DEV
  (devn8n.gtigrupo.com.br) via webhook temporario (removido apos os
  testes) com SHADOW_MODE/DEV_MODE ligados — nenhuma mensagem real
  enviada, nenhuma escrita no GLPI.

- tools/build_v2_workflow.js + build_v2_workflow_assemble.js: geram o
  workflow n8n a partir de src/v2/*.js (Code nodes remotos nao importam
  arquivos locais), mantendo uma unica fonte de verdade testada.

Bugs reais encontrados e corrigidos durante a validacao ao vivo:
- glpi_tickets nao tem coluna "solution" nesta versao do schema.
- Nodes MySQL/DataTable sem "Execute Once" executavam 1x por item
  recebido do node anterior; a cadeia amplificava ate ~76 mil execucoes
  na query de baseline por cliente, causando o timeout real observado.
  Corrigido com executeOnce=true nos 8 nodes afetados e reescrita da
  query de baseline para nao depender de JOIN com tabela de dimensao.
- IF "Pode Enviar" usava o operador booleano invertido (checava "e
  true" em vez de "e false"), o que quase permitia envio real ao Slack
  mesmo com SHADOW_MODE/DEV_MODE ligados.
- gpt-5.5 exige max_completion_tokens (nao max_tokens) e gasta o
  orcamento em tokens de raciocinio; ajustado para 3000 tokens +
  reasoning_effort=low.

Validado ao vivo contra dados reais do GLPI DEV (20/08/2026): IA gerou
insights reais com evidencia auditavel, memoria persistiu entre
execucoes (occurrences incrementando, normalizacao detectada),
resultado 200 confirmando envio suprimido (shadow mode).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 08:49:45 -03:00

435 lines
16 KiB
JavaScript

'use strict';
const fs = require('fs');
// Credencial real criada no n8n DEV em 2026-08-24 (nome "GLPI", tipo mySql).
// O nome "MySQL GLPI - Coordenador" citado na missao nao existia; esta e a
// que foi de fato provisionada e validada ao vivo (2026-08-25).
const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } };
const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } };
const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' };
const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' };
function setAssignment(id, name, type, value) {
return { id, name, type, value };
}
module.exports = function build(codeBodies) {
const {
CODE_PERIODO_QUERIES,
CODE_CONTEXTO_IA,
CODE_NORMALIZAR_IA,
CODE_IA_DESATIVADA,
CODE_MOTOR_V2,
CODE_PREPARAR_HISTORICO,
OUT_FILE,
} = codeBodies;
const nodes = [
{
id: 'manual-trigger-v2',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 200],
parameters: {},
},
{
id: 'schedule-trigger-v2',
name: 'Schedule Trigger - Dias uteis 08h',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 380],
parameters: {
rule: { interval: [{ field: 'cronExpression', expression: '0 8 * * 1-5' }] },
},
},
{
id: 'config-v2',
name: 'Config - V2',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [260, 290],
parameters: {
assignments: {
assignments: [
setAssignment('data_alvo', 'data_alvo', 'string', ''),
setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'),
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
setAssignment('AI_MAX_CONTEXT_CHARS', 'AI_MAX_CONTEXT_CHARS', 'number', 12000),
setAssignment('AI_MIN_CONFIDENCE', 'AI_MIN_CONFIDENCE', 'number', 0.6),
setAssignment('AI_TIMEOUT_MS', 'AI_TIMEOUT_MS', 'number', 30000),
setAssignment('AI_MAX_COMPLETION_TOKENS', 'AI_MAX_COMPLETION_TOKENS', 'number', 3000),
setAssignment('AI_REASONING_EFFORT', 'AI_REASONING_EFFORT', 'string', 'low'),
setAssignment('HISTORICAL_BASELINE_DAYS', 'HISTORICAL_BASELINE_DAYS', 'number', 20),
setAssignment('HISTORICAL_BASELINE_WEEKS', 'HISTORICAL_BASELINE_WEEKS', 'number', 4),
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true),
setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true),
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
setAssignment('CLIENT_CONCENTRATION_MIN_COUNT', 'CLIENT_CONCENTRATION_MIN_COUNT', 'number', 3),
setAssignment('CLIENT_CONCENTRATION_MIN_SHARE', 'CLIENT_CONCENTRATION_MIN_SHARE', 'number', 0.4),
setAssignment('PENDING_AGE_ATTENTION_DAYS', 'PENDING_AGE_ATTENTION_DAYS', 'number', 15),
setAssignment('TICKET_CONTEXT_SQL_LIMIT', 'TICKET_CONTEXT_SQL_LIMIT', 'number', 300),
],
},
options: {},
},
},
{
id: 'code-periodo-queries',
name: 'Code - Periodo e Queries SQL',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [520, 290],
parameters: { jsCode: CODE_PERIODO_QUERIES },
},
mysqlNode('mysql-indicadores', 'MySQL - Indicadores do Periodo', [780, 100], 'queries.indicadores'),
mysqlNode('mysql-ranking-categoria', 'MySQL - Ranking Categorias', [1040, 100], 'queries.ranking_categoria'),
mysqlNode('mysql-ranking-cliente', 'MySQL - Ranking Clientes', [1300, 100], 'queries.ranking_cliente'),
mysqlNode('mysql-contexto-tickets', 'MySQL - Contexto de Chamados', [1560, 100], 'queries.contexto_tickets'),
mysqlNode('mysql-baseline-categoria', 'MySQL - Baseline Diario Categoria', [1820, 100], 'queries.baseline_categoria'),
mysqlNode('mysql-baseline-cliente', 'MySQL - Baseline Diario Cliente', [2080, 100], 'queries.baseline_cliente'),
{
id: 'dt-buscar-memoria',
name: 'Data Table - Buscar Memoria de Insights',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2340, 100],
// executeOnce: sem isso, o n8n roda esta busca uma vez POR ITEM
// recebido do node anterior (causa raiz real do timeout em Baseline
// Diario Cliente, validada ao vivo em 2026-08-25 — ver comentario em
// buildBaselineDailyQuery). alwaysOutputData: garante que o node
// emita 1 item mesmo com a tabela vazia (1a execucao, sem memoria
// ainda) — sem isso, 0 itens aqui paravam o resto do pipeline
// (n8n pula nodes que recebem 0 itens de entrada).
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_MEMORIA,
filters: { conditions: [] },
returnAll: true,
},
},
{
id: 'dt-buscar-historico',
name: 'Data Table - Buscar Historico Anterior',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2600, 100],
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_HISTORICO,
filters: {
conditions: [
{
keyName: 'history_bucket',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
},
],
},
returnAll: false,
limit: 5,
orderBy: true,
orderByColumn: 'data_inicio',
orderByDirection: 'DESC',
},
},
{
id: 'code-contexto-ia',
name: 'Code - Preparar Contexto IA',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [2860, 100],
parameters: { jsCode: CODE_CONTEXTO_IA },
},
{
id: 'if-ia-habilitada',
name: 'IF - IA Habilitada?',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [3120, 100],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [
{
id: 'cond-ia-habilitada',
leftValue: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}",
rightValue: true,
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
combinator: 'and',
},
options: {},
},
},
{
id: 'http-openai',
name: 'HTTP Request - OpenAI Chat Completions',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [3380, -40],
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 5000,
alwaysOutputData: true,
parameters: {
method: 'POST',
url: 'https://api.openai.com/v1/chat/completions',
authentication: 'predefinedCredentialType',
nodeCredentialType: 'openAiApi',
sendBody: true,
specifyBody: 'json',
jsonBody: '={{ $json.openai_request_body }}',
options: {
timeout: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}",
response: { response: { responseFormat: 'json' } },
},
},
credentials: OPENAI_CRED,
},
{
id: 'code-normalizar-ia',
name: 'Code - Normalizar Resposta IA',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3640, -40],
parameters: { jsCode: CODE_NORMALIZAR_IA },
},
{
id: 'code-ia-desativada',
name: 'Code - IA Desativada',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3380, 240],
parameters: { jsCode: CODE_IA_DESATIVADA },
},
{
id: 'code-motor-v2',
name: 'Code - Motor V2: Relatorio Completo',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3900, 100],
parameters: { jsCode: CODE_MOTOR_V2 },
},
{
id: 'split-out-memoria',
name: 'Split Out - Memoria Atualizacoes',
type: 'n8n-nodes-base.splitOut',
typeVersion: 1,
position: [4160, -120],
parameters: { fieldToSplitOut: 'memoria_atualizacoes', options: {} },
},
{
id: 'dt-upsert-memoria',
name: 'Data Table - Upsert Memoria de Insights',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4420, -120],
parameters: {
resource: 'row',
operation: 'upsert',
dataTableId: DATATABLE_MEMORIA,
matchType: 'anyCondition',
filters: {
conditions: [{ keyName: 'fingerprint', condition: 'eq', keyValue: '={{ $json.fingerprint }}' }],
},
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: ['fingerprint'], schema: [] },
options: {},
},
},
{
id: 'code-preparar-historico',
name: 'Code - Preparar Historico',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [4160, 100],
parameters: { jsCode: CODE_PREPARAR_HISTORICO },
},
{
id: 'dt-insert-historico',
name: 'Data Table - Insert Historico do Periodo',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4420, 100],
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: DATATABLE_HISTORICO,
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
options: {},
},
},
{
id: 'if-pode-enviar',
name: 'IF - Pode Enviar (fora de Shadow/Dev)?',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [4160, 320],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [
// ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO
// 'true'. Bug real encontrado e corrigido em 2026-08-25: com
// operation:'true' a condicao checava "SHADOW_MODE e true" (o
// OPOSTO do que o nome do node promete), e quase mandou uma
// mensagem real ao Slack durante validacao em DEV — so nao
// enviou porque a URL do webhook ainda era o placeholder.
{
id: 'cond-shadow',
leftValue: '={{ $json.config.SHADOW_MODE }}',
rightValue: false,
operator: { type: 'boolean', operation: 'false', singleValue: true },
},
{
id: 'cond-dev',
leftValue: '={{ $json.config.DEV_MODE }}',
rightValue: false,
operator: { type: 'boolean', operation: 'false', singleValue: true },
},
],
combinator: 'and',
},
options: {},
},
},
{
id: 'http-slack-v2',
name: 'HTTP - Slack Enviar Mensagem V2',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [4420, 260],
parameters: {
method: 'POST',
url: "={{ $('Config - V2').first().json.slack_webhook_url }}",
sendBody: true,
contentType: 'json',
specifyBody: 'keypair',
bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] },
options: {},
},
},
{
id: 'set-envio-suprimido',
name: 'Set - Envio Suprimido (Shadow/Dev Mode)',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [4420, 400],
parameters: {
assignments: {
assignments: [
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
setAssignment(
'motivo',
'motivo',
'string',
'=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}'
),
],
},
options: { includeOtherFields: true },
},
},
];
const connections = {
'Manual Trigger': mainTo('Config - V2'),
'Schedule Trigger - Dias uteis 08h': mainTo('Config - V2'),
'Config - V2': mainTo('Code - Periodo e Queries SQL'),
'Code - Periodo e Queries SQL': mainTo('MySQL - Indicadores do Periodo'),
'MySQL - Indicadores do Periodo': mainTo('MySQL - Ranking Categorias'),
'MySQL - Ranking Categorias': mainTo('MySQL - Ranking Clientes'),
'MySQL - Ranking Clientes': mainTo('MySQL - Contexto de Chamados'),
'MySQL - Contexto de Chamados': mainTo('MySQL - Baseline Diario Categoria'),
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'),
'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'),
'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'),
'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'),
'IF - IA Habilitada?': {
main: [
[{ node: 'HTTP Request - OpenAI Chat Completions', type: 'main', index: 0 }],
[{ node: 'Code - IA Desativada', type: 'main', index: 0 }],
],
},
'HTTP Request - OpenAI Chat Completions': mainTo('Code - Normalizar Resposta IA'),
'Code - Normalizar Resposta IA': mainTo('Code - Motor V2: Relatorio Completo'),
'Code - IA Desativada': mainTo('Code - Motor V2: Relatorio Completo'),
'Code - Motor V2: Relatorio Completo': {
main: [
[
{ node: 'Split Out - Memoria Atualizacoes', type: 'main', index: 0 },
{ node: 'Code - Preparar Historico', type: 'main', index: 0 },
{ node: 'IF - Pode Enviar (fora de Shadow/Dev)?', type: 'main', index: 0 },
],
],
},
'Split Out - Memoria Atualizacoes': mainTo('Data Table - Upsert Memoria de Insights'),
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
'IF - Pode Enviar (fora de Shadow/Dev)?': {
main: [
[{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }],
[{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }],
],
},
};
const workflow = {
name: '[DEV] GLPI - Relatorio Operacional IA v2',
nodes,
connections,
pinData: {},
active: false,
settings: { executionOrder: 'v1' },
versionId: '',
meta: { templateCredsSetupCompleted: false },
tags: [],
};
fs.writeFileSync(OUT_FILE, JSON.stringify(workflow, null, 2) + '\n', 'utf8');
console.log(`Workflow gerado: ${OUT_FILE}`);
console.log(`Nodes: ${nodes.length}`);
};
function mainTo(nodeName) {
return { main: [[{ node: nodeName, type: 'main', index: 0 }]] };
}
function mysqlNode(id, name, position, queryFieldPath) {
return {
id,
name,
type: 'n8n-nodes-base.mySql',
typeVersion: 2.4,
position,
// executeOnce: obrigatorio. Sem isso, cada node MySQL roda uma vez POR
// ITEM recebido do node anterior, e como cada query aqui retorna varias
// linhas, o efeito e multiplicativo ao longo da cadeia (Ranking:7 ->
// Cliente:70 -> Contexto:840 -> Baseline Categoria:840 execucoes,
// gerando 76.440 itens -> Baseline Cliente tentou rodar 76.440 vezes e
// deu timeout). Causa raiz real, validada com execution telemetry do
// n8n em 2026-08-25 (execucao 16209 do workflow DEV).
executeOnce: true,
parameters: {
operation: 'executeQuery',
query: `={{ $('Code - Periodo e Queries SQL').first().json.${queryFieldPath} }}`,
options: {},
},
credentials: MYSQL_CRED,
};
}