Files
SLACK_COORD_RELATORIO/test/pipeline.test.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

319 lines
12 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildReport, computeEvidenceHash } = require('../src/v2/pipeline');
const { resolvePeriod } = require('../src/v2/period');
const { loadConfig } = require('../src/v2/config');
const config = loadConfig();
function baseIndicators(overrides) {
return {
tipo_periodo: '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',
...overrides,
};
}
function baseInput(overrides) {
const period = resolvePeriod('2026-08-20', new Date(2026, 7, 21), config);
return {
period,
indicators: baseIndicators(),
rankingCategorias: [{ id: 10, nome: 'Software / SO', total: 4 }, { id: 11, nome: 'Bug / Falha do Sistema', total: 2 }],
rankingClientes: [{ id: 20, nome: 'IMDI', total: 2 }, { id: 21, nome: 'Laboratorio da Cidade', total: 2 }],
baselineRowsCategoria: [],
baselineRowsCliente: [],
ticketsContexto: [
{ id: 1, title: 'Erro impressora', category: 'Hardware', client: 'IMDI', priority: 3, status: 5, opened_at: '2026-08-20' },
{ id: 2, title: 'Duvida acesso', category: 'Acesso', client: 'CTO', priority: 2, status: 5, opened_at: '2026-08-20' },
],
aiCall: { attempted: false, error: null, content: null },
previousMemoryRows: [],
previousPendentes: null,
altaPrioridadeAnterior: null,
todayIso: '2026-08-21',
...overrides,
};
}
function aiContentWith(insights, assessment) {
return JSON.stringify({
period_assessment: assessment || { status: 'stable', summary: 'ok' },
insights,
});
}
test('IA desligada -> fallback determinista: sem insights de IA, mas destaques deterministas (ex.: categoria lider) continuam funcionando', () => {
const cfg = loadConfig({ AI_ENABLED: false });
const r = buildReport(baseInput(), cfg);
assert.equal(r.observabilidade.fallback_ia, true);
assert.equal(r.observabilidade.fallback_motivo, 'ia_desativada_por_configuracao');
assert.equal(r.insights_aceitos.length, 0);
// destaque deterministico (nao depende de IA) continua aparecendo:
assert.match(r.mensagem_v2, /Software \/ SO liderou no dia com 4 chamados\./);
});
test('IA desligada e sem nenhum destaque deterministico -> mensagem cai na frase padrao (nao inventa observacao)', () => {
const cfg = loadConfig({ AI_ENABLED: false });
const input = baseInput({ rankingCategorias: [], rankingClientes: [] });
const r = buildReport(input, cfg);
assert.match(r.mensagem_v2, /Sem anomalias relevantes no periodo\./);
});
test('IA respondeu JSON valido com insight bem formado -> aceito e aparece na mensagem', () => {
const content = aiContentWith([
{
type: 'CATEGORY_SPIKE',
severity: 'medium',
title: 'Hardware acima do padrao',
summary: 'Volume de hardware ficou acima do usual.',
evidence_ticket_ids: [1],
confidence: 0.9,
reportable: true,
subject_key: 'categoria:hardware',
},
]);
const input = baseInput({ aiCall: { attempted: true, error: null, content } });
const r = buildReport(input, config);
assert.equal(r.observabilidade.ia_usada, true);
assert.equal(r.insights_aceitos.length, 1);
assert.match(r.mensagem_v2, /Hardware acima do padrao/);
});
test('JSON da IA invalido -> fallback, automacao nao quebra', () => {
const input = baseInput({ aiCall: { attempted: true, error: null, content: '{ json quebrado' } });
assert.doesNotThrow(() => {
const r = buildReport(input, config);
assert.equal(r.observabilidade.fallback_ia, true);
assert.match(r.observabilidade.fallback_motivo, /json_ia_invalido/);
});
});
test('timeout/erro HTTP da IA -> fallback, automacao nao quebra', () => {
const input = baseInput({ aiCall: { attempted: true, error: 'ETIMEDOUT', content: null } });
const r = buildReport(input, config);
assert.equal(r.observabilidade.fallback_ia, true);
assert.match(r.observabilidade.fallback_motivo, /falha_http_ia:ETIMEDOUT/);
});
test('insight com confianca baixa nao aparece na mensagem final', () => {
const content = aiContentWith([
{
type: 'CATEGORY_SPIKE',
severity: 'low',
title: 'Suspeita fraca',
summary: 'x',
evidence_ticket_ids: [1],
confidence: 0.1,
reportable: true,
subject_key: 'categoria:x',
},
]);
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
assert.equal(r.insights_aceitos.length, 0);
assert.doesNotMatch(r.mensagem_v2, /Suspeita fraca/);
});
test('insight sem evidencia (ticket id fora do contexto enviado) nao aparece', () => {
const content = aiContentWith([
{
type: 'CATEGORY_SPIKE',
severity: 'high',
title: 'Nao auditavel',
summary: 'x',
evidence_ticket_ids: [999], // nao esta em ticketsContexto
confidence: 0.95,
reportable: true,
subject_key: 'categoria:x',
},
]);
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
assert.equal(r.insights_aceitos.length, 0);
assert.equal(r.insights_suprimidos[0].motivo, 'evidencia_fora_do_contexto_enviado');
});
test('numeros oficiais da mensagem SEMPRE vem do SQL, nunca da IA (mesmo se a IA tentar sugerir outro numero no texto)', () => {
const content = aiContentWith(
[],
{ status: 'critical', summary: 'Na verdade foram 999 chamados abertos, nao 12 (numero fabricado pela IA).' }
);
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
assert.match(r.mensagem_v2, /12 abertos \| 11 fechados \| saldo -1/);
assert.doesNotMatch(r.mensagem_v2, /999/);
});
test('maximo de insights respeitado tambem no nivel do pipeline', () => {
const insights = Array.from({ length: 8 }, (_, i) => ({
type: 'CATEGORY_SPIKE',
severity: 'medium',
title: `Insight ${i}`,
summary: 'x',
evidence_ticket_ids: [1],
confidence: 0.9,
reportable: true,
subject_key: `categoria:tema_${i}`,
}));
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith(insights) } }), config);
assert.ok(r.insights_aceitos.length <= config.MAX_REPORT_INSIGHTS);
});
test('insight novo hoje fica com status new; no dia seguinte com o mesmo fingerprint fica persisting', () => {
const content = aiContentWith([
{
type: 'CATEGORY_SPIKE',
severity: 'medium',
title: 'Hardware acima do padrao',
summary: 'x',
evidence_ticket_ids: [1],
confidence: 0.9,
reportable: true,
subject_key: 'categoria:hardware',
},
]);
const dia1 = buildReport(
baseInput({ aiCall: { attempted: true, error: null, content }, todayIso: '2026-08-20' }),
config
);
assert.equal(dia1.insights_aceitos[0].memoryRow.current_status, 'new');
const memoriaAposDia1 = dia1.memoria_atualizacoes;
const dia2 = buildReport(
baseInput({
aiCall: { attempted: true, error: null, content },
previousMemoryRows: memoriaAposDia1,
todayIso: '2026-08-21',
}),
config
);
assert.equal(dia2.insights_aceitos[0].memoryRow.current_status, 'persisting');
assert.equal(dia2.insights_aceitos[0].memoryRow.occurrences, 2);
// as frases nao devem ser identicas entre os dois dias (evita spam)
const bullet1 = dia1.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao'));
const bullet2 = dia2.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao'));
assert.notEqual(bullet1, bullet2);
});
test('insight que existia ontem e nao aparece hoje gera aviso de normalizacao uma vez, depois fica silencioso', () => {
const memoriaAtiva = [
{
fingerprint: 'CATEGORY_SPIKE:categoria:hardware',
insight_type: 'CATEGORY_SPIKE',
subject_key: 'categoria:hardware',
first_seen_at: '2026-08-18',
last_seen_at: '2026-08-19',
occurrences: 2,
severity: 'medium',
last_reported_at: '2026-08-19',
current_status: 'persisting',
},
];
// dia sem deteccao de IA (nenhum insight novo) -> registro antigo deve normalizar
const diaSemRecorrencia = buildReport(
baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith([]) }, previousMemoryRows: memoriaAtiva, todayIso: '2026-08-20' }),
config
);
assert.match(diaSemRecorrencia.mensagem_v2, /voltou ao padrao/);
const registroNormalizando = diaSemRecorrencia.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint);
assert.equal(registroNormalizando.current_status, 'normalizing');
// no dia seguinte, com o registro ja 'normalizing', deve virar 'resolved' e SILENCIAR
const diaSeguinte = buildReport(
baseInput({
aiCall: { attempted: true, error: null, content: aiContentWith([]) },
previousMemoryRows: [registroNormalizando],
todayIso: '2026-08-21',
}),
config
);
assert.doesNotMatch(diaSeguinte.mensagem_v2, /voltou ao padrao/);
const registroResolvido = diaSeguinte.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint);
assert.equal(registroResolvido.current_status, 'resolved');
});
test('mensagem diaria e semanal permanecem curtas mesmo com varios insights aceitos', () => {
const insights = Array.from({ length: 5 }, (_, i) => ({
type: 'CATEGORY_SPIKE',
severity: 'medium',
title: `Tema relevante numero ${i} com titulo moderadamente descritivo`,
summary: 'x'.repeat(300),
evidence_ticket_ids: [1],
confidence: 0.9,
reportable: true,
subject_key: `categoria:tema_${i}`,
}));
const content = aiContentWith(insights);
const diario = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
assert.ok(diario.mensagem_v2.length < 1000, `mensagem diaria tinha ${diario.mensagem_v2.length} chars`);
const semanal = buildReport(
baseInput({
period: resolvePeriod('', new Date(2026, 7, 24), config),
indicators: baseIndicators({ tipo_periodo: 'semanal', periodo_label: '2026-08-17 a 2026-08-23', data_inicio: '2026-08-17', data_fim: '2026-08-23', abertos: 51, fechados: 52, saldo: 1, pendentes: 21 }),
aiCall: { attempted: true, error: null, content },
}),
config
);
assert.ok(semanal.mensagem_v2.length < 1000, `mensagem semanal tinha ${semanal.mensagem_v2.length} chars`);
});
test('computeEvidenceHash e estavel independente da ordem dos ids', () => {
assert.equal(computeEvidenceHash([3, 1, 2]), computeEvidenceHash([1, 2, 3]));
});
test('anomalias deterministicas (spike de categoria) sao calculadas mesmo sem IA', () => {
const input = baseInput({
rankingCategorias: [{ id: 99, nome: 'Hardware', total: 7 }],
baselineRowsCategoria: [
{ dia: '2026-08-13', id: 99, total: 2 },
{ dia: '2026-08-14', id: 99, total: 2 },
{ dia: '2026-08-17', id: 99, total: 2 },
{ dia: '2026-08-18', id: 99, total: 3 },
{ dia: '2026-08-19', id: 99, total: 1 },
],
});
const r = buildReport(input, config);
assert.ok(r.anomalias_deterministicas.spikesCategoria.some((s) => s.chave === 'Hardware'));
});
test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita bug de nomes duplicados/mudando)', () => {
const cfg = loadConfig({ HISTORICAL_BASELINE_DAYS: 5 });
const periodoComJanelaCurta = resolvePeriod('2026-08-20', new Date(2026, 7, 21), cfg);
const input = baseInput({
period: periodoComJanelaCurta,
rankingCategorias: [{ id: 5, nome: 'Nome Mudou Hoje', total: 7 }],
baselineRowsCategoria: [
{ dia: '2026-08-13', id: 5, total: 2 },
{ dia: '2026-08-14', id: 5, total: 2 },
{ dia: '2026-08-17', id: 5, total: 2 },
{ dia: '2026-08-18', id: 5, total: 3 },
{ dia: '2026-08-19', id: 5, total: 1 },
// linha de outra categoria com o MESMO nome que a atual tinha antes -
// nao deve ser confundida, pois o casamento e por id
{ dia: '2026-08-13', id: 999, total: 50 },
],
});
const r = buildReport(input, cfg);
const spike = r.anomalias_deterministicas.spikesCategoria.find((s) => s.chave === 'Nome Mudou Hoje');
assert.ok(spike);
// janela de 5 dias uteis (13 a 19/08) cobre 7 dias corridos (inclui fim de
// semana zero-preenchido): soma 10 (2+2+2+3+1) / 7 dias = 1.43. O ponto do
// teste e a media NAO ser contaminada pela linha do id=999 (que sozinha
// daria media >= 7).
assert.equal(spike.baseline_media, 1.43);
});