chore: import relatorio (subtree)

git-subtree-dir: relatorio
git-subtree-mainline: e322cec92b
git-subtree-split: 34bcb132f3
This commit is contained in:
Gustavo Santos
2026-10-01 14:26:02 -03:00
35 changed files with 6220 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
'use strict';
/**
* Gera o workflow n8n da V2 a partir dos modulos testados em src/v2/*.js.
*
* Por que este script existe: os Code nodes do n8n (na instancia remota
* devn8n.gtigrupo.com.br) rodam isolados e nao conseguem fazer
* `require('./src/v2/...')`. Para nao duplicar/reescrever manualmente a
* logica ja testada (e arriscar divergencia entre o que foi testado e o
* que roda de verdade no n8n), este script LE os arquivos fonte, remove
* `'use strict'`, linhas `require(...)` e o bloco `module.exports`, e
* concatena o que cada Code node precisa.
*
* Uso:
* node tools/build_v2_workflow.js
*
* Saida:
* "Resumo Operacional GLPI v2 - IA.n8n.json" (raiz do repo)
*
* Se voce alterar algo em src/v2/*.js, rode este script de novo e reimporte
* o workflow no n8n (ou use n8n_update_full_workflow) para manter os dois
* em sincronia.
*/
const fs = require('fs');
const path = require('path');
const SRC_DIR = path.join(__dirname, '..', 'src', 'v2');
const OUT_FILE = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
function readModuleBody(filename) {
const raw = fs.readFileSync(path.join(SRC_DIR, filename), 'utf8');
const semExports = raw.split(/\nmodule\.exports\s*=/)[0];
return semExports
.replace(/^'use strict';\s*$/m, '')
// remove requires de destructuring, inclusive multilinha: const { a, b } = require('./x');
.replace(/const\s*\{[\s\S]*?\}\s*=\s*require\([^)]*\);?/g, '')
// remove requires simples: const x = require('./x');
.replace(/^const\s+\w+\s*=\s*require\([^)]*\);?.*$/gm, '')
.trim();
}
const MOD = {
config: readModuleBody('config.js'),
period: readModuleBody('period.js'),
baseline: readModuleBody('baseline.js'),
anomaly: readModuleBody('anomaly.js'),
insights: readModuleBody('insights.js'),
aiSchema: readModuleBody('aiSchema.js'),
format: readModuleBody('format.js'),
sqlGuard: readModuleBody('sqlGuard.js'),
queries: readModuleBody('queries.js'),
sendGuard: readModuleBody('sendGuard.js'),
pipeline: readModuleBody('pipeline.js'),
};
function joinBlocks(...blocks) {
return blocks.join('\n\n// ---\n\n');
}
const HEADER_AVISO =
'// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n' +
'// manualmente aqui; edite a fonte e rode o script de novo.\n';
// ---------------------------------------------------------------------
// Node A: Code - Periodo e Queries SQL
// ---------------------------------------------------------------------
const CODE_PERIODO_QUERIES = `${HEADER_AVISO}
${joinBlocks(MOD.config, MOD.period, MOD.sqlGuard, MOD.queries)}
const overrides = $json;
const config = loadConfig(overrides);
const referencia = new Date();
const period = resolvePeriod(config.data_alvo, referencia, config);
period.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';
const queries = {
indicadores: buildIndicatorsQuery(period, config),
ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),
ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),
contexto_tickets: buildTicketContextQuery(period, config),
baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),
baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),
};
return [{ json: { period, config, queries } }];
`;
// ---------------------------------------------------------------------
// Node: Code - Preparar Contexto IA
// ---------------------------------------------------------------------
const CODE_CONTEXTO_IA = `${HEADER_AVISO}
${joinBlocks(MOD.insights, MOD.aiSchema)}
const base = $('Code - Periodo e Queries SQL').first().json;
const config = base.config;
const period = base.period;
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
const ticketContext = buildTicketContext(ticketsContexto, config);
const schema = buildJsonSchema();
const userPrompt = buildUserPrompt({
resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json,
baseline: period.baseline,
anomaliasDeterministicas: null,
contexto: ticketContext,
});
return [{
json: {
ticketContext,
openai_request_body: {
model: config.AI_MODEL,
// Sem "temperature": modelos da familia gpt-5.x so aceitam o valor
// padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui)
// e rejeitado por alguns desses modelos. Preferimos nao mandar o
// parametro a arriscar outro "Bad request" nao testado.
//
// gpt-5.x exige max_completion_tokens em vez do legado max_tokens
// (validado ao vivo em 2026-08-25: "Unsupported parameter: 'max_tokens'
// is not supported with this model. Use 'max_completion_tokens' instead.")
max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS,
reasoning_effort: config.AI_REASONING_EFFORT,
response_format: { type: 'json_schema', json_schema: schema },
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
},
},
}];
`;
// ---------------------------------------------------------------------
// Node: Code - Normalizar Resposta IA
// ---------------------------------------------------------------------
const CODE_NORMALIZAR_IA = `${HEADER_AVISO}
const item = $input.first().json;
const erro = item.error || null;
let conteudo = null;
if (!erro) {
conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null;
}
return [{
json: {
aiCall: {
attempted: true,
error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'),
content: conteudo,
},
},
}];
`;
// ---------------------------------------------------------------------
// Node: Code - IA Desativada (fallback direto, ramo FALSE do IF)
// ---------------------------------------------------------------------
const CODE_IA_DESATIVADA = `${HEADER_AVISO}
return [{ json: { aiCall: { attempted: false, error: null, content: null } } }];
`;
// ---------------------------------------------------------------------
// Node: Code - Motor V2: Relatorio Completo
// ---------------------------------------------------------------------
const CODE_MOTOR_V2 = `${HEADER_AVISO}
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.sendGuard, MOD.pipeline)}
const base = $('Code - Periodo e Queries SQL').first().json;
const config = base.config;
const period = base.period;
const indicators = $('MySQL - Indicadores do Periodo').first().json;
const rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);
const rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);
const baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);
const baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
const aiCall = $input.first().json.aiCall;
// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao
// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao
// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando
// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de
// cada tabela, para nao tratar {} como um registro real.
const previousMemoryRows = $('Data Table - Buscar Memoria de Insights')
.all()
.map((i) => i.json)
.filter((r) => r && r.fingerprint);
const historicoRows = $('Data Table - Buscar Historico Anterior')
.all()
.map((i) => i.json)
.filter((r) => r && r.tipo_periodo);
const historicoAnterior = historicoRows.length ? historicoRows[0] : null;
const todayIso = new Date().toISOString().slice(0, 10);
// $execution.mode: 'manual' quando disparado pelo botao "Execute workflow";
// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule
// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com
// FORCE_SEND=true explicito.
const triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
const sendLogRows = $('Data Table - Buscar Log de Envios')
.all()
.map((i) => i.json)
.filter((r) => r && r.send_key);
const input = {
period,
indicators,
rankingCategorias,
rankingClientes,
baselineRowsCategoria,
baselineRowsCliente,
ticketsContexto,
aiCall,
previousMemoryRows,
previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,
altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,
todayIso,
triggerType,
sendLogRows,
};
const relatorio = buildReport(input, config);
return [{ json: { ...relatorio, period, config, indicators, todayIso } }];
`;
// ---------------------------------------------------------------------
// Node: Code - Preparar Historico (flatten para insert na Data Table)
// ---------------------------------------------------------------------
const CODE_PREPARAR_HISTORICO = `${HEADER_AVISO}
const item = $input.first().json;
const p = item.period;
const ind = item.indicators;
return [{
json: {
tipo_periodo: p.tipo_periodo,
history_bucket: p.history_bucket,
periodo_label: p.periodo_label,
data_inicio: p.data_inicio,
data_fim: p.data_fim,
abertos: Number(ind.abertos),
fechados: Number(ind.fechados),
saldo: Number(ind.saldo),
pendentes: Number(ind.pendentes),
alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes),
executed_at: new Date().toISOString(),
},
}];
`;
module.exports = {
CODE_PERIODO_QUERIES,
CODE_CONTEXTO_IA,
CODE_NORMALIZAR_IA,
CODE_IA_DESATIVADA,
CODE_MOTOR_V2,
CODE_PREPARAR_HISTORICO,
OUT_FILE,
};
if (require.main === module) {
// eslint-disable-next-line global-require
require('./build_v2_workflow_assemble.js')(module.exports);
}
@@ -0,0 +1,587 @@
'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' };
// Data Table de log de envios (idempotencia, secao 13) — criada em
// 2026-08-25 via n8n_manage_datatable (createTable).
const DATATABLE_SEND_LOG = { mode: 'list', value: 'SWIeAdMmpMCZ2rVE', cachedResultName: 'dev_glpi_v2_send_log' };
// Credencial Slack REAL, ja provisionada e em uso pelo projeto irmao "[DEV]
// Coordenador GLPI - Chamados Abertos v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2,
// node "Slack - Enviar Mensagem") — reaproveitada aqui em vez de criar uma
// credencial nova, conforme exigido pela missao ("nao me peca webhook se ja
// existe nas credenciais do ambiente").
const SLACK_CRED = { slackApi: { id: 'lNqwBBrtLfkRqw2X', name: 'Slack - Bot Coordenador (Chamados Suporte)' } };
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('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),
// Guarda de envio real (secoes 12/13/15) — ver src/v2/sendGuard.js.
// SEND_ENABLED/FORCE_SEND nao tem efeito sozinhos: DEV_MODE=true e
// SHADOW_MODE=true continuam bloqueando qualquer envio, absoluto,
// ate a promocao explicita deste workflow para producao.
setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true),
setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false),
// Canal Slack de destino do Resumo Diario/Semanal. Confirmado
// explicitamente pelo usuario em 2026-08-25 (ver secao 6 da
// missao — nao foi adivinhado). D06JK4GEK8E (DM) foi tentado
// primeiro e falhou ao vivo com "channel_not_found" (o bot nao
// tem acesso); o usuario optou por reaproveitar o MESMO canal
// ja usado pelo workflow irmao "Coordenador GLPI" (C0BJNBTPFT5),
// onde o bot ja e membro confirmado.
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'C0BJNBTPFT5'),
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: 'dt-buscar-log-envios',
name: 'Data Table - Buscar Log de Envios',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2860, 260],
// executeOnce + alwaysOutputData: mesmo motivo dos outros Data Table
// reads (ver Data Table - Buscar Memoria de Insights acima).
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_SEND_LOG,
filters: {
conditions: [
{
keyName: 'report_type',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
},
{
keyName: 'period_start',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}",
},
{
keyName: 'period_end',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}",
},
],
},
returnAll: true,
},
},
{
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: [
// A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED +
// FORCE_SEND por tipo de gatilho + bloqueio de duplicata por
// idempotencia) ja foi calculada em JS testado por unidade
// (src/v2/sendGuard.js#canSend, chamado dentro de
// src/v2/pipeline.js#buildReport) — este IF so LE o resultado.
// Isso evita reproduzir a logica de guarda como condicoes soltas
// no n8n (fonte do bug real de operation:'true'/'false' invertido
// encontrado e corrigido em 2026-08-25).
{
id: 'cond-pode-enviar',
leftValue: '={{ $json.envio.pode_enviar }}',
rightValue: true,
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
combinator: 'and',
},
options: {},
},
},
{
id: 'slack-enviar-mensagem-v2',
name: 'Slack - Enviar Mensagem V2',
type: 'n8n-nodes-base.slack',
typeVersion: 2.5,
position: [4420, 200],
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 3000,
// continueErrorOutput: mesmo padrao do node irmao "Slack - Enviar
// Mensagem" (workflow y6JFEnp7Z2IJEUe2) — separa sucesso/falha em duas
// saidas, para so gravar status=success no log de idempotencia DEPOIS
// que o Slack de fato confirmar o envio (secao 13 da missao).
onError: 'continueErrorOutput',
parameters: {
select: 'channel',
// channelId.value fica vazio ate o usuario confirmar o canal de
// destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver
// secao 6 da missao). Ate la, IF - Pode Enviar so libera este node
// com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste
// controlado de envio (secao 14) so roda apos essa confirmacao.
channelId: { __rl: true, mode: 'id', value: "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" },
text: '={{ $json.mensagem_v2 }}',
otherOptions: { includeLinkToWorkflow: false },
},
credentials: SLACK_CRED,
},
{
id: 'code-preparar-log-envio-sucesso',
name: 'Code - Preparar Log de Envio (Sucesso)',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [4680, 100],
parameters: {
jsCode:
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
'const envio = relatorio.envio;\n' +
'return [{ json: {\n' +
' send_key: envio.send_key,\n' +
' report_type: envio.report_type,\n' +
' period_start: envio.period_start,\n' +
' period_end: envio.period_end,\n' +
' destination: envio.destino,\n' +
' message_hash: envio.message_hash,\n' +
' trigger_type: envio.trigger_type,\n' +
" status: 'success',\n" +
' sent_at: new Date().toISOString(),\n' +
'} }];\n',
},
},
{
id: 'dt-registrar-log-envio-sucesso',
name: 'Data Table - Registrar Log de Envio (Sucesso)',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4940, 100],
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: DATATABLE_SEND_LOG,
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
options: {},
},
},
{
id: 'code-preparar-log-envio-falha',
name: 'Code - Preparar Log de Envio (Falha)',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [4680, 300],
parameters: {
jsCode:
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
'const envio = relatorio.envio;\n' +
'const erro = $input.first().json.error || null;\n' +
'// erro NAO e persistido na Data Table (schema imutavel apos criacao, sem\n' +
"// coluna 'erro' — mandar esse campo quebra o insert com \"unknown column\n" +
'// name \'erro\'\", bug real encontrado e corrigido em 2026-08-25 no teste\n' +
'// controlado de envio). Fica so no console.log, visivel no log de\n' +
'// execucao do n8n.\n' +
'if (erro) console.log(\'Falha no envio Slack:\', erro);\n' +
'return [{ json: {\n' +
' send_key: envio.send_key,\n' +
' report_type: envio.report_type,\n' +
' period_start: envio.period_start,\n' +
' period_end: envio.period_end,\n' +
' destination: envio.destino,\n' +
' message_hash: envio.message_hash,\n' +
' trigger_type: envio.trigger_type,\n' +
" status: 'failed',\n" +
' sent_at: new Date().toISOString(),\n' +
'} }];\n',
},
},
{
id: 'dt-registrar-log-envio-falha',
name: 'Data Table - Registrar Log de Envio (Falha)',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4940, 300],
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: DATATABLE_SEND_LOG,
// "erro" nao faz parte do schema fixo da Data Table (imutavel apos
// criacao) — autoMapInputData ignora campos sem coluna correspondente,
// entao o detalhe do erro fica so no log/execucao do n8n, nao na tabela.
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
options: {},
},
},
{
id: 'set-envio-suprimido',
name: 'Set - Envio Suprimido (Guarda/Duplicata)',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [4420, 440],
parameters: {
assignments: {
assignments: [
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'),
],
},
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('Data Table - Buscar Log de Envios'),
'Data Table - Buscar Log de Envios': 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: 'Slack - Enviar Mensagem V2', type: 'main', index: 0 }],
[{ node: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'main', index: 0 }],
],
},
'Slack - Enviar Mensagem V2': {
main: [
[{ node: 'Code - Preparar Log de Envio (Sucesso)', type: 'main', index: 0 }],
[{ node: 'Code - Preparar Log de Envio (Falha)', type: 'main', index: 0 }],
],
},
'Code - Preparar Log de Envio (Sucesso)': mainTo('Data Table - Registrar Log de Envio (Sucesso)'),
'Code - Preparar Log de Envio (Falha)': mainTo('Data Table - Registrar Log de Envio (Falha)'),
};
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,
};
}
+81
View File
@@ -0,0 +1,81 @@
import json
import sqlite3
import sys
def decode_flatted(table):
memo = {}
def decode(value):
if isinstance(value, str) and value.isdigit():
return decode_index(int(value))
if isinstance(value, list):
return [decode(item) for item in value]
if isinstance(value, dict):
return {key: decode(item) for key, item in value.items()}
return value
def decode_index(index):
if index in memo:
return memo[index]
raw = table[index]
if isinstance(raw, dict):
obj = {}
memo[index] = obj
obj.update({key: decode(value) for key, value in raw.items()})
return obj
if isinstance(raw, list):
obj = []
memo[index] = obj
obj.extend(decode(value) for value in raw)
return obj
memo[index] = raw
return raw
return decode_index(0)
def main():
if len(sys.argv) not in (3, 4):
raise SystemExit("usage: inspect_n8n_execution.py <database.sqlite> <execution_id> [node_name]")
db_path = sys.argv[1]
execution_id = sys.argv[2]
con = sqlite3.connect(db_path)
row = con.execute(
"select data from execution_data where executionId = ?",
(execution_id,),
).fetchone()
if not row:
raise SystemExit(f"execution {execution_id} not found")
decoded = decode_flatted(json.loads(row[0]))
result_data = decoded.get("resultData", {})
if len(sys.argv) == 4:
node_name = sys.argv[3]
run_data = result_data.get("runData", {})
node_runs = run_data.get(node_name)
if node_runs is None:
print("available nodes:")
for name in run_data:
print("-", name)
raise SystemExit(f"node {node_name!r} not found")
print(json.dumps(node_runs, ensure_ascii=False, indent=2)[:12000])
return
print("lastNodeExecuted:", result_data.get("lastNodeExecuted"))
print("error:")
print(json.dumps(result_data.get("error"), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,220 @@
'use strict';
/**
* Smoke test do workflow GERADO (nao dos modulos fonte, que ja tem testes
* de unidade em test/*.test.js). Executa, com Node puro + vm, exatamente o
* texto que sera colado em cada Code node do n8n, simulando $()/$input/$json
* com dados de exemplo (mesmos numeros de referencia da missao, 20/08/2026).
*
* Uso: node tools/smoke_test_generated_nodes.js
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const WF_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
const workflow = JSON.parse(fs.readFileSync(WF_PATH, 'utf8'));
function nodeByName(name) {
const n = workflow.nodes.find((x) => x.name === name);
if (!n) throw new Error(`node nao encontrado no workflow gerado: ${name}`);
return n;
}
/** registry simula a memoria de execucao do n8n: nome do node -> lista de items {json} */
const registry = new Map();
function makeDollar() {
return function $(nodeName) {
const items = registry.get(nodeName);
if (!items) throw new Error(`$('${nodeName}') chamado antes do node rodar (ordem errada no smoke test)`);
return {
first: () => items[0],
all: () => items,
};
};
}
function runCodeNode(nodeName, inputItems, extraGlobals) {
const node = nodeByName(nodeName);
const code = node.parameters.jsCode;
const sandbox = {
$: makeDollar(),
$json: inputItems[0] ? inputItems[0].json : {},
$input: {
first: () => inputItems[0],
all: () => inputItems,
},
// $execution.mode: 'trigger' simula o Schedule Trigger (producao real);
// o smoke test usa isso por padrao porque e o caminho mais exercitado
// pelo relatorio (execucao manual e coberta por sendGuard.test.js).
$execution: { mode: 'trigger' },
console,
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Map,
Set,
RegExp,
...extraGlobals,
};
vm.createContext(sandbox);
const wrapped = `(function(){ ${code} \n})()`;
const result = vm.runInContext(wrapped, sandbox, { filename: nodeName });
registry.set(nodeName, result);
return result;
}
// -----------------------------------------------------------------------
// Fixtures (mesmos numeros de referencia da missao: 20/08/2026)
// -----------------------------------------------------------------------
const configItem = [
{
json: {
data_alvo: '2026-08-20',
slack_webhook_url: 'https://hooks.slack.com/services/TESTE',
AI_ENABLED: true,
AI_MODEL: 'gpt-5.5',
AI_MAX_TICKETS: 40,
AI_MAX_CONTEXT_CHARS: 12000,
AI_MIN_CONFIDENCE: 0.6,
AI_TIMEOUT_MS: 30000,
HISTORICAL_BASELINE_DAYS: 20,
HISTORICAL_BASELINE_WEEKS: 4,
MAX_REPORT_INSIGHTS: 5,
DEV_MODE: true,
SHADOW_MODE: true,
SEND_ENABLED: true,
FORCE_SEND: false,
SLACK_DESTINATION: 'C0000TESTE',
EXTENDED_METRICS_ENABLED: false,
ANOMALY_MULTIPLIER: 2.0,
ANOMALY_MIN_DELTA: 3,
CLIENT_CONCENTRATION_MIN_COUNT: 3,
CLIENT_CONCENTRATION_MIN_SHARE: 0.4,
PENDING_AGE_ATTENTION_DAYS: 15,
TICKET_CONTEXT_SQL_LIMIT: 300,
},
},
];
console.log('--- 1) Code - Periodo e Queries SQL ---');
const out1 = runCodeNode('Code - Periodo e Queries SQL', configItem);
console.log('tipo_periodo:', out1[0].json.period.tipo_periodo, '| periodo_label:', out1[0].json.period.periodo_label);
console.log('query indicadores (trecho):', out1[0].json.queries.indicadores.slice(0, 80).replace(/\n/g, ' '), '...');
registry.set('MySQL - Indicadores do Periodo', [
{
json: {
tipo_periodo: 'manual_diario',
periodo_label: '2026-08-20',
data_inicio: '2026-08-20',
data_fim: '2026-08-20',
abertos: 12,
fechados: 11,
saldo: -1,
pendentes: 22,
alta_prioridade_pendentes: 3,
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
},
},
]);
registry.set('MySQL - Ranking Categorias', [
{ json: { id: 10, nome: 'Software / SO', total: 4 } },
{ json: { id: 11, nome: 'Bug / Falha do Sistema', total: 2 } },
]);
registry.set('MySQL - Ranking Clientes', [
{ json: { id: 20, nome: 'IMDI', total: 2 } },
{ json: { id: 21, nome: 'Laboratorio da Cidade', total: 2 } },
]);
registry.set(
'MySQL - Contexto de Chamados',
Array.from({ length: 5 }, (_, i) => ({
json: {
id: i + 1,
title: `Chamado de teste ${i + 1}`,
category: 'Software / SO',
client: 'IMDI',
priority: 3,
status: 5,
opened_at: '2026-08-20',
closed_at: null,
solved_at: null,
description: 'Descricao de teste para o smoke test.',
solution: null,
},
}))
);
registry.set('MySQL - Baseline Diario Categoria', [
{ dia: '2026-08-13', id: 10, total: 1 },
{ dia: '2026-08-14', id: 10, total: 1 },
].map((r) => ({ json: r })));
registry.set('MySQL - Baseline Diario Cliente', [
{ dia: '2026-08-13', id: 20, total: 1 },
].map((r) => ({ json: r })));
registry.set('Data Table - Buscar Memoria de Insights', []);
registry.set('Data Table - Buscar Historico Anterior', []);
registry.set('Data Table - Buscar Log de Envios', []);
console.log('\n--- 2) Code - Preparar Contexto IA ---');
const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]);
console.log('tickets no contexto:', out2[0].json.ticketContext.tickets.length);
console.log('modelo solicitado:', out2[0].json.openai_request_body.model);
console.log('\n--- 3) Code - Normalizar Resposta IA (simulando resposta OpenAI valida) ---');
const respostaIaSimulada = {
choices: [
{
message: {
content: JSON.stringify({
period_assessment: { status: 'stable', summary: 'Operacao dentro do padrao.' },
insights: [
{
type: 'CATEGORY_SPIKE',
severity: 'medium',
title: 'Software/SO acima do padrao',
summary: 'Volume de Software/SO ficou acima da media recente.',
evidence_ticket_ids: [1, 2],
confidence: 0.85,
reportable: true,
subject_key: 'categoria:software_so',
},
],
}),
},
},
],
};
const out3 = runCodeNode('Code - Normalizar Resposta IA', [{ json: respostaIaSimulada }]);
console.log('aiCall:', out3[0].json.aiCall.attempted, out3[0].json.aiCall.error);
console.log('\n--- 4) Code - Motor V2: Relatorio Completo ---');
const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0].json }]);
const relatorio = out4[0].json;
console.log('status_operacional:', relatorio.status_operacional);
console.log('insights_aceitos:', relatorio.insights_aceitos.length);
console.log('envio:', JSON.stringify(relatorio.envio, null, 2));
console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2));
console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1);
console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2);
console.log('\n--- 5) Code - Preparar Historico ---');
const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]);
console.log(JSON.stringify(out5[0].json, null, 2));
console.log('\n--- 6) Code - Preparar Log de Envio (Sucesso/Falha) ---');
registry.set('Code - Motor V2: Relatorio Completo', [{ json: relatorio }]);
const out6a = runCodeNode('Code - Preparar Log de Envio (Sucesso)', [{ json: {} }]);
console.log('log sucesso:', JSON.stringify(out6a[0].json));
const out6b = runCodeNode('Code - Preparar Log de Envio (Falha)', [{ json: { error: { message: 'timeout simulado' } } }]);
console.log('log falha:', JSON.stringify(out6b[0].json));
console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');