Files
SLACK_COORD_RELATORIO/test/pipeline.test.js
T
Automacao GLPIandClaude Sonnet 5 9475e68cb1 Corrige bug real de idempotencia: message_hash nao deve fazer parte do send_key
Bug encontrado AO VIVO durante o teste controlado de envio (secao 14): a
IA (gpt-5.5) nao e deterministica. Duas chamadas para o MESMO periodo
(mesmo report_type+data) geraram insights com textos diferentes, portanto
message_hash diferente, portanto send_key diferente — o retry do MESMO
periodo passou pela deteccao de duplicata como se fosse outro relatorio.

Resultado real: 3 mensagens verdadeiras enviadas ao canal C0BJNBTPFT5
para o periodo 'diario 2026-08-24' antes de eu interromper e corrigir
(execucoes 16744, 16746 e uma terceira ja em voo quando a guarda foi
revertida). Estado revertido imediatamente (DEV_MODE/SHADOW_MODE=true,
workflow desativado) assim que o padrao foi identificado.

Correcao: src/v2/sendGuard.js#buildSendKey agora usa so
report_type+period_start+period_end+destination (SEM message_hash) — a
identidade de um relatorio e o PERIODO, nao o texto exato produzido pela
IA. message_hash continua calculado e armazenado, so para diagnostico.

As 3 linhas reais gravadas na Data Table dev_glpi_v2_send_log foram
normalizadas manualmente (send_key sem sufixo de hash) para que a
protecao de duplicata funcione corretamente para qualquer nova tentativa
do mesmo periodo daqui pra frente.

Testes novos: sendGuard.test.js e pipeline.test.js ganharam testes de
regressao reproduzindo exatamente este cenario (retry do mesmo periodo
com aiCall/mensagem diferente -> send_key igual -> duplicata bloqueada).

Canal Slack real confirmado nesta sessao: D06JK4GEK8E (DM) foi tentado
primeiro e falhou com channel_not_found (bot sem acesso); o usuario optou
por reaproveitar C0BJNBTPFT5 (canal do workflow irmao Coordenador GLPI,
onde o bot ja e membro).

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

401 lines
17 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);
});
// --- envio (guarda de seguranca + idempotencia, secoes 12/13 ligadas ao pipeline) ---
test('envio.pode_enviar reflete a guarda de seguranca (scheduled + producao = true)', () => {
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
assert.equal(r.envio.pode_enviar, true);
assert.equal(r.envio.duplicata_bloqueada, false);
});
test('envio.pode_enviar false quando SHADOW_MODE=true, mesmo scheduled+producao', () => {
const cfg = loadConfig({ SHADOW_MODE: true, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
assert.equal(r.envio.pode_enviar, false);
assert.match(r.envio.motivo_guarda, /SHADOW_MODE/);
});
test('envio.pode_enviar false em execucao manual sem FORCE_SEND, mesmo com guards de producao ok', () => {
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false, SLACK_DESTINATION: 'C0BJNBTPFT5' });
const r = buildReport(baseInput({ triggerType: 'manual', sendLogRows: [] }), cfg);
assert.equal(r.envio.pode_enviar, false);
});
test('envio.duplicata_bloqueada=true e pode_enviar=false quando ja existe log de sucesso com a MESMA chave logica', () => {
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
const input = baseInput({ triggerType: 'scheduled', sendLogRows: [] });
const primeira = buildReport(input, cfg);
assert.equal(primeira.envio.pode_enviar, true);
const segunda = buildReport(
baseInput({ triggerType: 'scheduled', sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }] }),
cfg
);
assert.equal(segunda.envio.pode_enviar, false);
assert.equal(segunda.envio.duplicata_bloqueada, true);
});
test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas chamadas distintas', () => {
const cfg = loadConfig({ SLACK_DESTINATION: 'C0BJNBTPFT5' });
const a = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
assert.equal(a.envio.send_key, b.envio.send_key);
});
test('regressao: retry do MESMO periodo com IA nao-deterministica (texto/hash diferentes) ainda e bloqueado como duplicata (bug real de 2026-08-25: 2 mensagens reais enviadas ao Slack antes desta correcao — execucoes 16744 e 16746, mesmo periodo 2026-08-24, hashes diferentes porque a IA mudou os insights entre as duas chamadas)', () => {
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
// 1a chamada: IA retorna um insight
const primeira = buildReport(
baseInput({
triggerType: 'scheduled',
sendLogRows: [],
aiCall: { attempted: true, error: null, content: aiContentWith([
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo A', summary: 'Resumo A',
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
]) },
}),
cfg
);
assert.equal(primeira.envio.pode_enviar, true);
// 2a chamada (retry do MESMO periodo): IA retorna um insight DIFERENTE
// (titulo/summary diferentes -> mensagem_v2 diferente -> message_hash
// diferente). O send_key (identidade do relatorio) tem que continuar
// sendo o MESMO, pois e o MESMO periodo/destino.
const segunda = buildReport(
baseInput({
triggerType: 'scheduled',
sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }],
aiCall: { attempted: true, error: null, content: aiContentWith([
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo B totalmente diferente', summary: 'Resumo B',
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
]) },
}),
cfg
);
assert.notEqual(primeira.envio.message_hash, segunda.envio.message_hash, 'pre-condicao do teste: os textos precisam ser diferentes');
assert.equal(segunda.envio.send_key, primeira.envio.send_key, 'send_key deve ser o mesmo apesar do texto diferente');
assert.equal(segunda.envio.pode_enviar, false);
assert.equal(segunda.envio.duplicata_bloqueada, true);
});