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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b2aefcd2d7
commit
bdfa675d89
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
redact,
|
||||
truncate,
|
||||
buildTicketContext,
|
||||
buildJsonSchema,
|
||||
parseAiResponse,
|
||||
} = require('../src/v2/aiSchema');
|
||||
const { loadConfig } = require('../src/v2/config');
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
test('redact remove padroes comuns de segredo antes de mandar para a IA', () => {
|
||||
const texto = 'acesso com senha: abc123 e token=xyz999';
|
||||
const limpo = redact(texto);
|
||||
assert.doesNotMatch(limpo, /abc123/);
|
||||
assert.doesNotMatch(limpo, /xyz999/);
|
||||
assert.match(limpo, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test('truncate corta texto longo e mantem texto curto intacto', () => {
|
||||
assert.equal(truncate('abc', 10), 'abc');
|
||||
assert.equal(truncate('a'.repeat(20), 5), 'aaaaa…');
|
||||
});
|
||||
|
||||
test('buildTicketContext respeita AI_MAX_TICKETS', () => {
|
||||
const cfg = loadConfig({ AI_MAX_TICKETS: 3 });
|
||||
const tickets = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Chamado ${i}`,
|
||||
priority: 3,
|
||||
opened_at: '2026-08-20',
|
||||
}));
|
||||
const ctx = buildTicketContext(tickets, cfg);
|
||||
assert.equal(ctx.tickets.length, 3);
|
||||
assert.equal(ctx.total_disponivel, 10);
|
||||
assert.equal(ctx.truncado, true);
|
||||
});
|
||||
|
||||
test('buildTicketContext prioriza prioridade mais alta quando precisa cortar', () => {
|
||||
const cfg = loadConfig({ AI_MAX_TICKETS: 1 });
|
||||
const tickets = [
|
||||
{ id: 1, title: 'baixa', priority: 1, opened_at: '2026-08-20' },
|
||||
{ id: 2, title: 'alta', priority: 5, opened_at: '2026-08-19' },
|
||||
];
|
||||
const ctx = buildTicketContext(tickets, cfg);
|
||||
assert.equal(ctx.tickets[0].id, 2);
|
||||
});
|
||||
|
||||
test('buildTicketContext nunca ultrapassa AI_MAX_CONTEXT_CHARS mesmo com muitos tickets pequenos', () => {
|
||||
const cfg = loadConfig({ AI_MAX_TICKETS: 1000, AI_MAX_CONTEXT_CHARS: 500 });
|
||||
const tickets = Array.from({ length: 200 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Chamado numero ${i} com um titulo mediano para ocupar espaco`,
|
||||
description: 'descricao razoavelmente longa para ajudar a estourar o limite de contexto configurado',
|
||||
priority: 3,
|
||||
opened_at: '2026-08-20',
|
||||
}));
|
||||
const ctx = buildTicketContext(tickets, cfg);
|
||||
assert.ok(JSON.stringify(ctx.tickets).length <= cfg.AI_MAX_CONTEXT_CHARS);
|
||||
assert.ok(ctx.tickets.length < 200);
|
||||
});
|
||||
|
||||
test('buildTicketContext nao envia campos alem do minimo necessario (minimizacao de contexto)', () => {
|
||||
const ctx = buildTicketContext(
|
||||
[{ id: 1, title: 'x', priority: 1, opened_at: '2026-08-20', extraCampoSensivel: 'nao deveria ir' }],
|
||||
config
|
||||
);
|
||||
assert.equal(ctx.tickets[0].extraCampoSensivel, undefined);
|
||||
});
|
||||
|
||||
test('buildJsonSchema define os tipos de insight da missao e exige evidencia', () => {
|
||||
const schema = buildJsonSchema();
|
||||
assert.equal(schema.strict, true);
|
||||
assert.ok(schema.schema.properties.insights.items.required.includes('evidence_ticket_ids'));
|
||||
assert.ok(schema.schema.properties.insights.items.properties.type.enum.includes('CATEGORY_SPIKE'));
|
||||
});
|
||||
|
||||
test('parseAiResponse aceita JSON valido', () => {
|
||||
const r = parseAiResponse(JSON.stringify({ period_assessment: { status: 'stable', summary: 'ok' }, insights: [] }));
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.data.period_assessment.status, 'stable');
|
||||
});
|
||||
|
||||
test('parseAiResponse tolera cercas de markdown ```json ... ```', () => {
|
||||
const bruto = '```json\n{"period_assessment":{"status":"stable","summary":"ok"},"insights":[]}\n```';
|
||||
const r = parseAiResponse(bruto);
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('parseAiResponse nunca lanca excecao com JSON quebrado, so retorna ok:false', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const r = parseAiResponse('{ isso nao é json valido');
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /json_invalido/);
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAiResponse rejeita resposta vazia/undefined sem lancar excecao', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
assert.equal(parseAiResponse(undefined).ok, false);
|
||||
assert.equal(parseAiResponse('').ok, false);
|
||||
assert.equal(parseAiResponse(null).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAiResponse rejeita objeto sem o formato esperado (falta insights[])', () => {
|
||||
const r = parseAiResponse(JSON.stringify({ foo: 'bar' }));
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.error, 'formato_inesperado');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
mean,
|
||||
median,
|
||||
isAnomaly,
|
||||
detectarSpikes,
|
||||
detectarConcentracaoCliente,
|
||||
calcularTendenciaBacklog,
|
||||
} = require('../src/v2/anomaly');
|
||||
const { loadConfig } = require('../src/v2/config');
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
test('median calcula corretamente para lista par e impar', () => {
|
||||
assert.equal(median([1, 2, 3]), 2);
|
||||
assert.equal(median([1, 2, 3, 4]), 2.5);
|
||||
assert.equal(median([]), 0);
|
||||
});
|
||||
|
||||
test('mean calcula a media simples', () => {
|
||||
assert.equal(mean([1, 2, 3]), 2);
|
||||
assert.equal(mean([]), 0);
|
||||
});
|
||||
|
||||
test('isAnomaly: exige multiplicador E delta minimo (evita falso positivo trivial 0 -> 1)', () => {
|
||||
// baseline 0, atual 1: multiplicador ok (sem historico usa MIN_DELTA), mas delta(1) < MIN_DELTA(3)
|
||||
assert.equal(isAnomaly(1, 0, config), false);
|
||||
// baseline 2.1, atual 7 (exemplo da missao: hardware hoje 7, media 20du 2.1)
|
||||
assert.equal(isAnomaly(7, 2.1, config), true);
|
||||
// baseline 5, atual 6: nao dobra o baseline
|
||||
assert.equal(isAnomaly(6, 5, config), false);
|
||||
});
|
||||
|
||||
test('detectarSpikes retorna somente categorias/clientes que estouraram o baseline, com numeros auditaveis', () => {
|
||||
const atuais = [
|
||||
{ chave: 'Hardware', atual: 7 },
|
||||
{ chave: 'Software', atual: 4 },
|
||||
];
|
||||
const baseline = new Map([
|
||||
['Hardware', [2, 2, 3, 1, 2]], // media 2.0
|
||||
['Software', [4, 3, 4, 5, 4]], // media 4.0 (estavel)
|
||||
]);
|
||||
const spikes = detectarSpikes(atuais, baseline, config);
|
||||
assert.equal(spikes.length, 1);
|
||||
assert.equal(spikes[0].chave, 'Hardware');
|
||||
assert.equal(spikes[0].baseline_media, 2);
|
||||
});
|
||||
|
||||
test('detectarConcentracaoCliente exige contagem minima E participacao minima', () => {
|
||||
const clientes = [
|
||||
{ nome: 'IMDI', total: 5 },
|
||||
{ nome: 'CTO', total: 2 },
|
||||
];
|
||||
// total periodo 10: IMDI = 50% (>=40%) e >=3 chamados -> concentracao
|
||||
const r = detectarConcentracaoCliente(clientes, 10, config);
|
||||
assert.equal(r.length, 1);
|
||||
assert.equal(r[0].cliente, 'IMDI');
|
||||
assert.equal(r[0].share, 0.5);
|
||||
});
|
||||
|
||||
test('detectarConcentracaoCliente nao aponta cliente com poucos chamados mesmo com share alto', () => {
|
||||
const clientes = [{ nome: 'Cliente Pequeno', total: 2 }];
|
||||
const r = detectarConcentracaoCliente(clientes, 2, config); // 100% mas so 2 chamados
|
||||
assert.equal(r.length, 0);
|
||||
});
|
||||
|
||||
test('calcularTendenciaBacklog classifica direcao corretamente', () => {
|
||||
assert.equal(calcularTendenciaBacklog(22, 20).direcao, 'aumentando');
|
||||
assert.equal(calcularTendenciaBacklog(18, 20).direcao, 'diminuindo');
|
||||
assert.equal(calcularTendenciaBacklog(20, 20).direcao, 'estavel');
|
||||
assert.equal(calcularTendenciaBacklog(20, null).direcao, 'indisponivel');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
listCalendarDays,
|
||||
pivotDailyRows,
|
||||
bucketIntoChunks,
|
||||
bucketMapIntoChunks,
|
||||
} = require('../src/v2/baseline');
|
||||
|
||||
test('listCalendarDays inclui inicio e fim, sem pular nenhum dia', () => {
|
||||
const dias = listCalendarDays('2026-08-01', '2026-08-05');
|
||||
assert.deepEqual(dias, ['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04', '2026-08-05']);
|
||||
});
|
||||
|
||||
test('pivotDailyRows preenche com zero os dias sem chamados (nao enviesa a media)', () => {
|
||||
const allDayKeys = ['2026-08-01', '2026-08-02', '2026-08-03'];
|
||||
const rows = [
|
||||
{ dia: '2026-08-01', nome: 'Hardware', total: 2 },
|
||||
{ dia: '2026-08-03', nome: 'Hardware', total: 4 },
|
||||
];
|
||||
const pivot = pivotDailyRows(rows, allDayKeys);
|
||||
assert.deepEqual(pivot.get('Hardware'), [2, 0, 4]);
|
||||
});
|
||||
|
||||
test('pivotDailyRows aceita objetos Date do driver MySQL, nao so string', () => {
|
||||
const allDayKeys = ['2026-08-01', '2026-08-02'];
|
||||
const rows = [{ dia: new Date(2026, 7, 1), nome: 'Software', total: 5 }];
|
||||
const pivot = pivotDailyRows(rows, allDayKeys);
|
||||
assert.deepEqual(pivot.get('Software'), [5, 0]);
|
||||
});
|
||||
|
||||
test('bucketIntoChunks agrupa dias em blocos de 7 (semana)', () => {
|
||||
const serie = [1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2];
|
||||
assert.deepEqual(bucketIntoChunks(serie, 7), [7, 14]);
|
||||
});
|
||||
|
||||
test('bucketMapIntoChunks aplica o bucket em todas as chaves do mapa', () => {
|
||||
const mapa = new Map([
|
||||
['A', [1, 1, 1, 1, 1, 1, 1]],
|
||||
['B', [2, 0, 0, 0, 0, 0, 0]],
|
||||
]);
|
||||
const bucketed = bucketMapIntoChunks(mapa, 7);
|
||||
assert.deepEqual(bucketed.get('A'), [7]);
|
||||
assert.deepEqual(bucketed.get('B'), [2]);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
formatFallbackMessage,
|
||||
computeOperationalStatus,
|
||||
buildDestaques,
|
||||
formatShortMessage,
|
||||
} = require('../src/v2/format');
|
||||
const { loadConfig } = require('../src/v2/config');
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
test('formatFallbackMessage reproduz o formato atual da V1 (numeros do exemplo da missao, 20/08)', () => {
|
||||
const row = {
|
||||
tipo_periodo: 'diario',
|
||||
periodo_label: '2026-08-20',
|
||||
abertos: 12,
|
||||
fechados: 11,
|
||||
pendentes: 22,
|
||||
alta_prioridade_pendentes: 3,
|
||||
saldo: -1,
|
||||
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
|
||||
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
|
||||
};
|
||||
const texto = formatFallbackMessage(row);
|
||||
assert.match(texto, /Resumo diario do suporte GLPI/);
|
||||
assert.match(texto, /Data do relatorio: 2026-08-20/);
|
||||
assert.match(texto, /Chamados abertos no dia: 12/);
|
||||
assert.match(texto, /Chamados fechados no dia: 11/);
|
||||
assert.match(texto, /Pendentes atuais: 22/);
|
||||
assert.match(texto, /Alta prioridade ainda pendentes: 3/);
|
||||
assert.match(texto, /Saldo fechados - abertos: -1/);
|
||||
assert.match(texto, /Software \/ SO: 4/);
|
||||
assert.match(texto, /IMDI: 2 chamados/);
|
||||
});
|
||||
|
||||
test('formatFallbackMessage nunca lanca excecao com dados vazios (fallback tem que ser sempre seguro)', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
formatFallbackMessage({
|
||||
tipo_periodo: 'diario',
|
||||
periodo_label: '2026-08-20',
|
||||
abertos: 0,
|
||||
fechados: 0,
|
||||
pendentes: 0,
|
||||
alta_prioridade_pendentes: 0,
|
||||
saldo: 0,
|
||||
categorias_recorrentes: '',
|
||||
clientes_volume: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('computeOperationalStatus: sem insights e backlog estavel -> NORMAL', () => {
|
||||
const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'estavel' } });
|
||||
assert.equal(s, 'NORMAL');
|
||||
});
|
||||
|
||||
test('computeOperationalStatus: insight severity high -> CRITICAL mesmo com backlog estavel', () => {
|
||||
const s = computeOperationalStatus({
|
||||
insightsAceitos: [{ severity: 'high' }],
|
||||
tendenciaBacklog: { direcao: 'estavel' },
|
||||
});
|
||||
assert.equal(s, 'CRITICAL');
|
||||
});
|
||||
|
||||
test('computeOperationalStatus: backlog aumentando sem insights -> ATTENTION', () => {
|
||||
const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'aumentando' } });
|
||||
assert.equal(s, 'ATTENTION');
|
||||
});
|
||||
|
||||
test('buildDestaques nao produz mais itens que MAX_REPORT_INSIGHTS', () => {
|
||||
const insightsAceitos = Array.from({ length: 10 }, (_, i) => ({ title: `Insight ${i}`, occurrenceText: null }));
|
||||
const destaques = buildDestaques(
|
||||
{ topCategoria: null, concentracoesCliente: [], altaPrioridadeDelta: null, insightsAceitos, unidade: 'no dia' },
|
||||
config
|
||||
);
|
||||
assert.ok(destaques.length <= config.MAX_REPORT_INSIGHTS);
|
||||
});
|
||||
|
||||
test('buildDestaques nao inventa destaque de alta prioridade quando o valor nao mudou', () => {
|
||||
const destaques = buildDestaques(
|
||||
{
|
||||
topCategoria: null,
|
||||
concentracoesCliente: [],
|
||||
altaPrioridadeDelta: { anterior: 3, atual: 3 },
|
||||
insightsAceitos: [],
|
||||
unidade: 'no dia',
|
||||
},
|
||||
config
|
||||
);
|
||||
assert.equal(destaques.length, 0);
|
||||
});
|
||||
|
||||
test('formatShortMessage: mensagem diaria contem os numeros oficiais e fica curta (sem paragrafos longos)', () => {
|
||||
const { texto, status } = formatShortMessage(
|
||||
{
|
||||
tipo_periodo: 'diario',
|
||||
data_inicio: '2026-08-20',
|
||||
data_fim: '2026-08-20',
|
||||
abertos: 12,
|
||||
fechados: 11,
|
||||
saldo: -1,
|
||||
pendentes: 22,
|
||||
alta_prioridade_pendentes: 3,
|
||||
topCategoria: { nome: 'Software / SO', total: 4 },
|
||||
concentracoesCliente: [],
|
||||
altaPrioridadeDelta: { anterior: 2, atual: 3 },
|
||||
insightsAceitos: [],
|
||||
tendenciaBacklog: { direcao: 'aumentando', delta: 2 },
|
||||
},
|
||||
config
|
||||
);
|
||||
assert.match(texto, /Resumo do suporte — 20\/08/);
|
||||
assert.match(texto, /12 abertos \| 11 fechados \| saldo -1/);
|
||||
assert.match(texto, /22 pendentes \| 3 alta prioridade/);
|
||||
assert.match(texto, /Software \/ SO liderou no dia com 4 chamados\./);
|
||||
assert.match(texto, /subiram de 2 para 3/);
|
||||
assert.equal(status, 'ATTENTION');
|
||||
assert.ok(texto.length < 700, `mensagem deveria ser curta, tinha ${texto.length} chars`);
|
||||
});
|
||||
|
||||
test('formatShortMessage: sem nenhum destaque real, mostra a frase padrao (nao inventa observacao)', () => {
|
||||
const { texto } = formatShortMessage(
|
||||
{
|
||||
tipo_periodo: 'diario',
|
||||
data_inicio: '2026-08-20',
|
||||
data_fim: '2026-08-20',
|
||||
abertos: 5,
|
||||
fechados: 5,
|
||||
saldo: 0,
|
||||
pendentes: 10,
|
||||
alta_prioridade_pendentes: 1,
|
||||
topCategoria: null,
|
||||
concentracoesCliente: [],
|
||||
altaPrioridadeDelta: null,
|
||||
insightsAceitos: [],
|
||||
tendenciaBacklog: { direcao: 'estavel', delta: 0 },
|
||||
},
|
||||
config
|
||||
);
|
||||
assert.match(texto, /Sem anomalias relevantes no periodo\./);
|
||||
});
|
||||
|
||||
test('formatShortMessage: mensagem semanal usa titulo e secao "Leitura"', () => {
|
||||
const { texto } = formatShortMessage(
|
||||
{
|
||||
tipo_periodo: 'semanal',
|
||||
data_inicio: '2026-08-17',
|
||||
data_fim: '2026-08-23',
|
||||
abertos: 51,
|
||||
fechados: 52,
|
||||
saldo: 1,
|
||||
pendentes: 21,
|
||||
alta_prioridade_pendentes: 3,
|
||||
topCategoria: { nome: 'Bug / Falha do Sistema', total: 9 },
|
||||
concentracoesCliente: [],
|
||||
altaPrioridadeDelta: null,
|
||||
insightsAceitos: [],
|
||||
tendenciaBacklog: { direcao: 'estavel', delta: 0 },
|
||||
},
|
||||
config
|
||||
);
|
||||
assert.match(texto, /Resumo semanal do suporte — 17\/08 a 23\/08/);
|
||||
assert.match(texto, /51 abertos \| 52 fechados \| saldo \+1/);
|
||||
assert.match(texto, /Leitura/);
|
||||
assert.match(texto, /→ Semana equilibrada/);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
slugify,
|
||||
normalizeSubjectKey,
|
||||
computeFingerprint,
|
||||
validateInsightShape,
|
||||
sanitizeInsights,
|
||||
nextMemoryState,
|
||||
nextStateForAbsentRecord,
|
||||
describeOccurrence,
|
||||
} = require('../src/v2/insights');
|
||||
const { loadConfig } = require('../src/v2/config');
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
test('slugify remove acentos e caracteres especiais', () => {
|
||||
assert.equal(slugify('Emissão de NF-e'), 'emissao_de_nf_e');
|
||||
assert.equal(slugify(''), 'geral');
|
||||
assert.equal(slugify(undefined), 'geral');
|
||||
});
|
||||
|
||||
test('normalizeSubjectKey aceita namespace explicito da IA', () => {
|
||||
assert.equal(normalizeSubjectKey('cliente:IMDI', 'CLIENT_CONCENTRATION'), 'cliente:imdi');
|
||||
});
|
||||
|
||||
test('normalizeSubjectKey infere namespace pelo type quando IA nao manda um valido', () => {
|
||||
assert.equal(normalizeSubjectKey('Impressao', 'CATEGORY_SPIKE'), 'categoria:impressao');
|
||||
assert.equal(normalizeSubjectKey('erro nota fiscal', 'RECURRENCE'), 'tema:erro_nota_fiscal');
|
||||
});
|
||||
|
||||
test('computeFingerprint e deterministico e estavel', () => {
|
||||
const f1 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
|
||||
const f2 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
|
||||
assert.equal(f1, f2);
|
||||
assert.equal(f1, 'CATEGORY_SPIKE:categoria:impressao');
|
||||
});
|
||||
|
||||
function insightValido(overrides) {
|
||||
return {
|
||||
type: 'CATEGORY_SPIKE',
|
||||
severity: 'medium',
|
||||
title: 'Hardware acima do padrao',
|
||||
summary: 'Volume de hardware ficou acima da referencia recente.',
|
||||
evidence_ticket_ids: [1, 2, 3],
|
||||
confidence: 0.9,
|
||||
reportable: true,
|
||||
subject_key: 'categoria:hardware',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('validateInsightShape aceita um insight bem formado', () => {
|
||||
const r = validateInsightShape(insightValido());
|
||||
assert.equal(r.valido, true);
|
||||
});
|
||||
|
||||
test('validateInsightShape rejeita type fora do enum', () => {
|
||||
const r = validateInsightShape(insightValido({ type: 'INVENTADO' }));
|
||||
assert.equal(r.valido, false);
|
||||
assert.ok(r.erros.includes('type_invalido'));
|
||||
});
|
||||
|
||||
test('validateInsightShape rejeita evidence_ticket_ids vazio', () => {
|
||||
const r = validateInsightShape(insightValido({ evidence_ticket_ids: [] }));
|
||||
assert.equal(r.valido, false);
|
||||
assert.ok(r.erros.includes('evidence_ticket_ids_invalido'));
|
||||
});
|
||||
|
||||
test('validateInsightShape rejeita confidence fora de 0..1', () => {
|
||||
const r = validateInsightShape(insightValido({ confidence: 1.5 }));
|
||||
assert.equal(r.valido, false);
|
||||
});
|
||||
|
||||
test('sanitizeInsights: JSON valido passa e recebe fingerprint', () => {
|
||||
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
|
||||
assert.equal(accepted.length, 1);
|
||||
assert.equal(suppressed.length, 0);
|
||||
assert.equal(accepted[0].fingerprint, 'CATEGORY_SPIKE:categoria:hardware');
|
||||
});
|
||||
|
||||
test('sanitizeInsights: JSON invalido (schema quebrado) nunca quebra a automacao, so e suprimido', () => {
|
||||
const ctx = { validTicketIds: new Set([1]) };
|
||||
assert.doesNotThrow(() => {
|
||||
const { accepted, suppressed } = sanitizeInsights(
|
||||
[{ nonsense: true }, 'string solta', null, 42],
|
||||
ctx,
|
||||
config
|
||||
);
|
||||
assert.equal(accepted.length, 0);
|
||||
assert.equal(suppressed.length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
test('sanitizeInsights: insight com confianca abaixo do minimo nao aparece', () => {
|
||||
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||
const { accepted, suppressed } = sanitizeInsights(
|
||||
[insightValido({ confidence: 0.2 })],
|
||||
ctx,
|
||||
config
|
||||
);
|
||||
assert.equal(accepted.length, 0);
|
||||
assert.equal(suppressed[0].motivo, 'confianca_abaixo_do_minimo');
|
||||
});
|
||||
|
||||
test('sanitizeInsights: evidencia com ticket id fora do contexto enviado a IA e suprimida (numero nao auditavel)', () => {
|
||||
const ctx = { validTicketIds: new Set([1, 2]) }; // ticket 3 nao foi enviado a IA
|
||||
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
|
||||
assert.equal(accepted.length, 0);
|
||||
assert.equal(suppressed[0].motivo, 'evidencia_fora_do_contexto_enviado');
|
||||
});
|
||||
|
||||
test('sanitizeInsights: reportable=false fica suprimido mas auditavel', () => {
|
||||
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||
const { accepted, suppressed } = sanitizeInsights(
|
||||
[insightValido({ reportable: false })],
|
||||
ctx,
|
||||
config
|
||||
);
|
||||
assert.equal(accepted.length, 0);
|
||||
assert.equal(suppressed[0].motivo, 'nao_marcado_como_reportable');
|
||||
});
|
||||
|
||||
test('sanitizeInsights respeita MAX_REPORT_INSIGHTS', () => {
|
||||
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||
const cfg = loadConfig({ MAX_REPORT_INSIGHTS: 2 });
|
||||
const seis = Array.from({ length: 6 }, (_, i) =>
|
||||
insightValido({ subject_key: `categoria:tema_${i}`, confidence: 0.9 - i * 0.01 })
|
||||
);
|
||||
const { accepted, suppressed } = sanitizeInsights(seis, ctx, cfg);
|
||||
assert.equal(accepted.length, 2);
|
||||
assert.equal(suppressed.length, 4);
|
||||
assert.ok(suppressed.every((s) => s.motivo === 'limite_maximo_de_insights_atingido'));
|
||||
});
|
||||
|
||||
test('sanitizeInsights descarta duplicata de fingerprint na mesma execucao (evita spam no mesmo relatorio)', () => {
|
||||
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||
const duplicado = [insightValido(), insightValido({ title: 'Outro titulo, mesmo tema' })];
|
||||
const { accepted, suppressed } = sanitizeInsights(duplicado, ctx, config);
|
||||
assert.equal(accepted.length, 1);
|
||||
assert.equal(suppressed[0].motivo, 'fingerprint_duplicado_na_mesma_execucao');
|
||||
});
|
||||
|
||||
test('nextMemoryState: primeira ocorrencia gera status new', () => {
|
||||
const s = nextMemoryState(null, '2026-08-18');
|
||||
assert.equal(s.current_status, 'new');
|
||||
assert.equal(s.occurrences, 1);
|
||||
assert.equal(s.first_seen_at, '2026-08-18');
|
||||
});
|
||||
|
||||
test('nextMemoryState: ocorrencia repetida incrementa occurrences e vira persisting', () => {
|
||||
const anterior = { first_seen_at: '2026-08-18', occurrences: 1, current_status: 'new' };
|
||||
const s = nextMemoryState(anterior, '2026-08-19');
|
||||
assert.equal(s.current_status, 'persisting');
|
||||
assert.equal(s.occurrences, 2);
|
||||
assert.equal(s.first_seen_at, '2026-08-18');
|
||||
});
|
||||
|
||||
test('describeOccurrence varia a frase conforme o estado (nao repete a mesma frase todo dia)', () => {
|
||||
assert.equal(describeOccurrence({ current_status: 'new' }), 'identificado hoje');
|
||||
assert.equal(
|
||||
describeOccurrence({ current_status: 'persisting', occurrences: 3 }),
|
||||
'segue acima do padrao pelo 3o dia'
|
||||
);
|
||||
assert.equal(describeOccurrence({ current_status: 'normalizing' }), 'voltou ao padrao');
|
||||
assert.equal(describeOccurrence({ current_status: 'resolved' }), null);
|
||||
});
|
||||
|
||||
test('nextStateForAbsentRecord: insight que sumiu vira normalizing (reporta 1x)', () => {
|
||||
const anterior = { current_status: 'persisting', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
||||
const s = nextStateForAbsentRecord(anterior, '2026-08-20');
|
||||
assert.equal(s.current_status, 'normalizing');
|
||||
});
|
||||
|
||||
test('nextStateForAbsentRecord: normalizing que continua ausente vira resolved (silencioso)', () => {
|
||||
const anterior = { current_status: 'normalizing', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
||||
const s = nextStateForAbsentRecord(anterior, '2026-08-21');
|
||||
assert.equal(s.current_status, 'resolved');
|
||||
});
|
||||
|
||||
test('nextStateForAbsentRecord: resolved permanece resolved (nao volta a falar sozinho)', () => {
|
||||
const anterior = { current_status: 'resolved', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
||||
const s = nextStateForAbsentRecord(anterior, '2026-08-25');
|
||||
assert.equal(s.current_status, 'resolved');
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { resolvePeriod, businessDaysWindowBefore, parseDate, formatDate } = require('../src/v2/period');
|
||||
const { loadConfig } = require('../src/v2/config');
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
// Ancoras reais usadas na missao (agosto/2026):
|
||||
// 17/08 = segunda, 18/08 = terca, 19/08 = quarta, 20/08 = quinta,
|
||||
// 21/08 = sexta, 22/08 = sabado, 23/08 = domingo, 24/08 = segunda.
|
||||
function d(dia) {
|
||||
return new Date(2026, 7, dia);
|
||||
}
|
||||
|
||||
test('terca (18/08) cobre o relatorio diario de segunda (17/08)', () => {
|
||||
const r = resolvePeriod('', d(18), config);
|
||||
assert.equal(r.tipo_periodo, 'diario');
|
||||
assert.equal(r.data_inicio, '2026-08-17');
|
||||
assert.equal(r.data_fim, '2026-08-17');
|
||||
});
|
||||
|
||||
test('quarta (19/08) cobre o relatorio diario de terca (18/08)', () => {
|
||||
const r = resolvePeriod('', d(19), config);
|
||||
assert.equal(r.tipo_periodo, 'diario');
|
||||
assert.equal(r.data_inicio, '2026-08-18');
|
||||
assert.equal(r.data_fim, '2026-08-18');
|
||||
});
|
||||
|
||||
test('quinta (20/08) cobre o relatorio diario de quarta (19/08)', () => {
|
||||
const r = resolvePeriod('', d(20), config);
|
||||
assert.equal(r.tipo_periodo, 'diario');
|
||||
assert.equal(r.data_inicio, '2026-08-19');
|
||||
assert.equal(r.data_fim, '2026-08-19');
|
||||
});
|
||||
|
||||
test('sexta (21/08) cobre o relatorio diario de quinta (20/08)', () => {
|
||||
const r = resolvePeriod('', d(21), config);
|
||||
assert.equal(r.tipo_periodo, 'diario');
|
||||
assert.equal(r.data_inicio, '2026-08-20');
|
||||
assert.equal(r.data_fim, '2026-08-20');
|
||||
});
|
||||
|
||||
test('segunda (24/08) cobre o relatorio semanal consolidado de 17/08 a 23/08', () => {
|
||||
const r = resolvePeriod('', d(24), config);
|
||||
assert.equal(r.tipo_periodo, 'semanal');
|
||||
assert.equal(r.data_inicio, '2026-08-17');
|
||||
assert.equal(r.data_fim, '2026-08-23');
|
||||
assert.equal(r.periodo_label, '2026-08-17 a 2026-08-23');
|
||||
});
|
||||
|
||||
test('sabado/domingo nao fazem parte do calendario operacional (nao ha schedule nesses dias, mas a funcao deve continuar pura)', () => {
|
||||
// Sabado (22/08): getDay() = 6, cai no "senao" (diario) porque so segunda e tratada
|
||||
// como especial. Isso e esperado: o Schedule Trigger real (cron 1-5) nunca dispara
|
||||
// aos sabados/domingos, entao este caso nunca ocorre em producao/DEV.
|
||||
const r = resolvePeriod('', d(22), config);
|
||||
assert.equal(r.tipo_periodo, 'diario');
|
||||
assert.equal(r.data_inicio, '2026-08-21');
|
||||
});
|
||||
|
||||
test('data_alvo manual (YYYY-MM-DD) forca modo diario manual, independente do dia da semana atual', () => {
|
||||
const r = resolvePeriod('2026-08-20', d(24), config);
|
||||
assert.equal(r.tipo_periodo, 'manual_diario');
|
||||
assert.equal(r.data_inicio, '2026-08-20');
|
||||
assert.equal(r.data_fim, '2026-08-20');
|
||||
});
|
||||
|
||||
test('data_alvo manual aceita formato dd/mm/aa', () => {
|
||||
const r = resolvePeriod('20/08/26', d(24), config);
|
||||
assert.equal(r.data_inicio, '2026-08-20');
|
||||
});
|
||||
|
||||
test('data_alvo manual aceita formato dd/mm/aaaa', () => {
|
||||
const r = resolvePeriod('20/08/2026', d(24), config);
|
||||
assert.equal(r.data_inicio, '2026-08-20');
|
||||
});
|
||||
|
||||
test('data_alvo invalida (texto livre) cai no modo automatico', () => {
|
||||
const r = resolvePeriod('data invalida', d(19), config);
|
||||
assert.equal(r.tipo_periodo, 'diario');
|
||||
assert.equal(r.data_inicio, '2026-08-18');
|
||||
});
|
||||
|
||||
test('parseDate rejeita string vazia', () => {
|
||||
assert.equal(parseDate(''), null);
|
||||
assert.equal(parseDate(undefined), null);
|
||||
});
|
||||
|
||||
test('businessDaysWindowBefore conta somente dias uteis (pula fim de semana)', () => {
|
||||
// antes de 24/08 (segunda): 5 dias uteis = 21,20,19,18,17 (sex,qui,qua,ter,seg)
|
||||
const janela = businessDaysWindowBefore(d(24), 5);
|
||||
assert.equal(formatDate(janela.start), '2026-08-17');
|
||||
assert.equal(formatDate(janela.end), '2026-08-21');
|
||||
assert.equal(janela.businessDays, 5);
|
||||
});
|
||||
|
||||
test('baseline diario expoe janela rolling e last5BusinessDays sem usar datas incorretas', () => {
|
||||
const r = resolvePeriod('', d(19), config); // periodo = 18/08 (terca)
|
||||
assert.equal(r.baseline.immediatePrevious.start, r.baseline.immediatePrevious.end);
|
||||
assert.ok(r.baseline.rolling.start < r.baseline.rolling.end || r.baseline.rolling.start <= r.baseline.rolling.end);
|
||||
assert.ok(r.baseline.last5BusinessDays.end < r.data_inicio);
|
||||
assert.ok(r.baseline.rolling.end < r.data_inicio);
|
||||
});
|
||||
|
||||
test('baseline semanal expoe janela de 4 semanas anteriores sem sobrepor o periodo atual', () => {
|
||||
const r = resolvePeriod('', d(24), config); // periodo = 17 a 23/08
|
||||
assert.equal(r.baseline.immediatePrevious.start, '2026-08-10');
|
||||
assert.equal(r.baseline.immediatePrevious.end, '2026-08-16');
|
||||
assert.equal(r.baseline.last4Weeks.start, '2026-07-20');
|
||||
assert.equal(r.baseline.last4Weeks.end, '2026-08-16');
|
||||
assert.ok(r.baseline.last4Weeks.end < r.data_inicio);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
'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);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
buildCategoryFilterClause,
|
||||
buildIndicatorsQuery,
|
||||
buildRankingCompletoQuery,
|
||||
buildTicketContextQuery,
|
||||
buildBaselineDailyQuery,
|
||||
buildExtendedMetricsQuery,
|
||||
} = require('../src/v2/queries');
|
||||
const { assertReadOnlySql, FORBIDDEN_KEYWORDS } = require('../src/v2/sqlGuard');
|
||||
const { resolvePeriod } = require('../src/v2/period');
|
||||
const { loadConfig } = require('../src/v2/config');
|
||||
|
||||
const config = loadConfig();
|
||||
const period = resolvePeriod('2026-08-20', new Date(2026, 7, 24), config);
|
||||
|
||||
test('assertReadOnlySql aceita SELECT e devolve a mesma string', () => {
|
||||
const sql = 'SELECT * FROM glpi_tickets';
|
||||
assert.equal(assertReadOnlySql(sql), sql);
|
||||
});
|
||||
|
||||
test('assertReadOnlySql bloqueia qualquer verbo de escrita', () => {
|
||||
for (const verbo of FORBIDDEN_KEYWORDS) {
|
||||
assert.throws(() => assertReadOnlySql(`${verbo} INTO glpi_tickets ...`), /Query bloqueada/);
|
||||
}
|
||||
});
|
||||
|
||||
test('assertReadOnlySql nao gera falso positivo em nomes de coluna legitimos do GLPI (date_creation, updater...)', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertReadOnlySql('SELECT date_creation, users_id_lastupdater FROM glpi_tickets')
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCategoryFilterClause usa os IDs configurados (7,16,17,18) e nao verbo de escrita', () => {
|
||||
const clause = buildCategoryFilterClause([7, 16, 17, 18]);
|
||||
assert.match(clause, /id = 7/);
|
||||
assert.match(clause, /id = 16/);
|
||||
assert.match(clause, /id = 17/);
|
||||
assert.match(clause, /id = 18/);
|
||||
assert.doesNotThrow(() => assertReadOnlySql(clause));
|
||||
});
|
||||
|
||||
test('buildCategoryFilterClause rejeita id invalido (defesa contra injecao via config)', () => {
|
||||
assert.throws(() => buildCategoryFilterClause([7, 'DROP TABLE x']));
|
||||
});
|
||||
|
||||
test('buildIndicatorsQuery e somente leitura e preserva a logica da V1 (abertos por date, fechados por closedate)', () => {
|
||||
const sql = buildIndicatorsQuery(period, config);
|
||||
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||
assert.match(sql, /is_deleted = false/);
|
||||
assert.match(sql, /date >= '2026-08-20'/);
|
||||
assert.match(sql, /closedate >= '2026-08-20'/);
|
||||
assert.match(sql, /status NOT IN \(5, 6\)/);
|
||||
assert.match(sql, /priority >= 4/);
|
||||
});
|
||||
|
||||
test('buildIndicatorsQuery rejeita data em formato invalido (protecao contra interpolacao livre)', () => {
|
||||
const periodoRuim = { ...period, data_inicio: "2026-08-20'; DROP TABLE glpi_tickets; --", data_fim: period.data_fim };
|
||||
assert.throws(() => buildIndicatorsQuery(periodoRuim, config), /Data invalida/);
|
||||
});
|
||||
|
||||
test('buildRankingCompletoQuery gera SQL somente leitura para categoria e cliente, e expoe o id (nao so o nome)', () => {
|
||||
const sqlCategoria = buildRankingCompletoQuery(period, config, 'categoria');
|
||||
const sqlCliente = buildRankingCompletoQuery(period, config, 'cliente');
|
||||
assert.doesNotThrow(() => assertReadOnlySql(sqlCategoria));
|
||||
assert.doesNotThrow(() => assertReadOnlySql(sqlCliente));
|
||||
assert.match(sqlCategoria, /glpi_itilcategories/);
|
||||
assert.match(sqlCliente, /glpi_entities/);
|
||||
assert.match(sqlCategoria, /SELECT t\.itilcategories_id AS id,/);
|
||||
assert.match(sqlCliente, /SELECT t\.entities_id AS id,/);
|
||||
});
|
||||
|
||||
test('buildTicketContextQuery limita linhas via LIMIT e nao seleciona tabelas de escrita', () => {
|
||||
const sql = buildTicketContextQuery(period, config);
|
||||
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||
assert.match(sql, new RegExp(`LIMIT ${config.TICKET_CONTEXT_SQL_LIMIT}`));
|
||||
assert.match(sql, /LEFT\(t\.content, 600\) AS description/);
|
||||
});
|
||||
|
||||
test('buildBaselineDailyQuery agrupa por dia e por dimensao (categoria/cliente) sem verbo de escrita, e nao faz JOIN com tabela de dimensao (so glpi_tickets)', () => {
|
||||
const janela = period.baseline.rolling;
|
||||
const sql = buildBaselineDailyQuery(janela, config, 'categoria', period);
|
||||
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||
assert.match(sql, /GROUP BY dia,/);
|
||||
assert.doesNotMatch(sql, /LEFT JOIN glpi_itilcategories/);
|
||||
assert.doesNotMatch(sql, /LEFT JOIN glpi_entities/);
|
||||
});
|
||||
|
||||
test('buildBaselineDailyQuery restringe o agrupamento aos IDs ativos no periodo atual (evita agrupar a base inteira de entidades/categorias)', () => {
|
||||
const janela = period.baseline.rolling;
|
||||
const sqlCliente = buildBaselineDailyQuery(janela, config, 'cliente', period);
|
||||
assert.doesNotThrow(() => assertReadOnlySql(sqlCliente));
|
||||
// a subquery de IDs ativos usa o periodo ATUAL (data_inicio do relatorio), nao a janela historica
|
||||
assert.match(sqlCliente, new RegExp(`t2\\.date >= '${period.data_inicio}'`));
|
||||
assert.match(sqlCliente, /AND t\.entities_id IN \(/);
|
||||
});
|
||||
|
||||
test('buildExtendedMetricsQuery e somente leitura mesmo usando campos ainda nao validados no schema real', () => {
|
||||
const sql = buildExtendedMetricsQuery(period, config);
|
||||
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||
assert.match(sql, /time_to_resolve/);
|
||||
});
|
||||
|
||||
test('nenhuma query gerada para nenhum tipo de periodo conhecido contem verbo de escrita (varredura ampla)', () => {
|
||||
const referencias = [new Date(2026, 7, 18), new Date(2026, 7, 19), new Date(2026, 7, 20), new Date(2026, 7, 21), new Date(2026, 7, 24)];
|
||||
for (const ref of referencias) {
|
||||
const p = resolvePeriod('', ref, config);
|
||||
assert.doesNotThrow(() => assertReadOnlySql(buildIndicatorsQuery(p, config)));
|
||||
assert.doesNotThrow(() => assertReadOnlySql(buildTicketContextQuery(p, config)));
|
||||
assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'categoria')));
|
||||
assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'cliente')));
|
||||
assert.doesNotThrow(() => assertReadOnlySql(buildExtendedMetricsQuery(p, config)));
|
||||
if (p.baseline.rolling) {
|
||||
assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'categoria', p)));
|
||||
assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'cliente', p)));
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user