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>
435 lines
16 KiB
JavaScript
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,
|
|
};
|
|
}
|