Found running the real double-send idempotency proof the mission explicitly required: two executions seconds apart, essentially unchanged GLPI data, produced TWO DIFFERENT Top10 compositions -- and since the idempotency guard is keyed on the exact message hash, both were treated as legitimately different digests and BOTH sent real Slack messages. Root cause: logic/diversity.js's selectTop() sorted candidates by score descending using Array.sort, which is stable but has no secondary key. Many real candidates tie on score (e.g. several ACTIONABLE_NOW tickets at 260 or 247.6 points), so ties were broken purely by whatever order $input.all() happened to deliver items in -- and n8n does NOT guarantee relative item order across parallel branches that re-merge (documented elsewhere in this project's history: cache/ia/fallback classification branches converge at Merge - Consolidar Classificacoes before reaching here). Different arrival order -> different tie-break -> different Top10 -> different message -> both "different enough" to bypass idempotency, even though nothing meaningful changed in GLPI. Fixed with a deterministic secondary sort key (ticket.id, ascending) that only applies when scores are exactly equal -- doesn't change WHO wins (still purely score-driven), only makes ties reproducible run to run. This is a determinism fix, not a ranking/weight change (mission section 21 constraint respected). Added a regression test that reproduces the exact real-world scenario: the same 12 candidates (all tied at score 200), fed in two different orders, must produce the identical Top10. 171/171 tests passing. Two real Slack messages were sent during this investigation (with the user's standing test authorization) before the fix landed -- both correctly formatted, no attribution footer, real diverse next_action values, no literal backslash-n. Reverted SEND_ENABLED to false and deactivated the workflow immediately after discovering the root cause. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
61 lines
3.1 KiB
JavaScript
61 lines
3.1 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { selectTop } = require('./diversity');
|
|
|
|
function item(id, score, group, excludedFromTop10) {
|
|
return { ticket: { id }, score, group, excludedFromTop10: Boolean(excludedFromTop10), aiClassification: {} };
|
|
}
|
|
|
|
test('Top final nunca ultrapassa topN', () => {
|
|
const scored = Array.from({ length: 20 }, (_, i) => item(i, 100 - i, 'ranking_geral', false));
|
|
const { selected } = selectTop(scored, 10, {});
|
|
assert.equal(selected.length, 10);
|
|
});
|
|
|
|
test('itens excluidos do Top10 nunca aparecem selecionados', () => {
|
|
const scored = [item(1, 500, 'critico_acionavel', true), item(2, 10, 'ranking_geral', false)];
|
|
const { selected } = selectTop(scored, 10, {});
|
|
assert.ok(!selected.some((s) => s.ticket.id === 1));
|
|
});
|
|
|
|
test('10 criticos legitimos no mesmo grupo entram todos (diversidade nao bloqueia emergencia real)', () => {
|
|
const scored = Array.from({ length: 10 }, (_, i) => item(i, 500 - i, 'critico_acionavel', false));
|
|
const { selected } = selectTop(scored, 10, {});
|
|
assert.equal(selected.length, 10);
|
|
assert.deepEqual(selected.map((s) => s.ticket.id).sort(), scored.map((s) => s.ticket.id).sort());
|
|
});
|
|
|
|
test('BUG REAL comprovado em execucao real: mesmo conjunto de itens empatados em score, em ordens de chegada diferentes, produz o MESMO Top10 -- missao "correcao final" secao 14/15', () => {
|
|
// Simula o que aconteceu de verdade: 12 itens com o MESMO score, chegando em duas ordens
|
|
// diferentes (n8n nao garante ordem entre branches paralelos). Sem desempate deterministico
|
|
// por ticket.id, cada execucao escolhia um subconjunto de 10 diferente -> mensagens
|
|
// diferentes -> hash diferente -> idempotencia nunca detectava como "mesmo dia, mesmo digest".
|
|
const ids = [38413, 38476, 38477, 38509, 36925, 37691, 37389, 38657, 75, 38611, 38583, 38658];
|
|
const ordemA = ids.map((id) => item(id, 200, 'ranking_geral', false));
|
|
const ordemB = [...ordemA].reverse();
|
|
|
|
const resultA = selectTop(ordemA, 10, {});
|
|
const resultB = selectTop(ordemB, 10, {});
|
|
|
|
const idsA = resultA.selected.map((s) => s.ticket.id);
|
|
const idsB = resultB.selected.map((s) => s.ticket.id);
|
|
assert.deepEqual(idsA, idsB, 'a mesma composicao de candidatos deveria produzir o mesmo Top10, independente da ordem de chegada');
|
|
});
|
|
|
|
test('desempate por ticket.id nao muda QUEM vence quando os scores sao diferentes (so desempata igualdade)', () => {
|
|
const scored = [item(2, 100, 'ranking_geral', false), item(1, 200, 'ranking_geral', false)];
|
|
const { selected } = selectTop(scored, 10, {});
|
|
assert.equal(selected[0].ticket.id, 1, 'score maior continua vencendo, desempate so entra quando ha empate real');
|
|
});
|
|
|
|
test('com grupos variados, ordena por score respeitando diversidade leve', () => {
|
|
const scored = [
|
|
...Array.from({ length: 8 }, (_, i) => item(`c${i}`, 300 - i, 'critico_acionavel', false)),
|
|
item('t1', 250, 'sem_tecnico', false),
|
|
item('t2', 240, 'quick_win', false),
|
|
];
|
|
const { selected } = selectTop(scored, 10, {});
|
|
assert.equal(selected.length, 10);
|
|
assert.ok(selected.some((s) => s.ticket.id === 't1'));
|
|
});
|