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>
46 lines
1.9 KiB
JavaScript
46 lines
1.9 KiB
JavaScript
function selectTop(scored, topN, config) {
|
|
const eligible = scored.filter((item) => !item.excludedFromTop10);
|
|
const excluded = scored.filter((item) => item.excludedFromTop10);
|
|
|
|
// BUG REAL comprovado em execucao ao vivo (missao "correcao final", secao 14): Array.sort e
|
|
// estavel, e muitos chamados empatam em score -- sem um criterio de desempate deterministico,
|
|
// a ordem de chegada de $input.all() (que o n8n NAO garante entre branches paralelos --
|
|
// cache/ia/fallback se juntam de novo antes daqui) decidia o desempate. Duas execucoes
|
|
// seguidas com os MESMOS dados produziram Top10 DIFERENTES so por causa disso, gerando duas
|
|
// mensagens reais distintas pro Slack no mesmo dia. ticket.id como criterio secundario
|
|
// garante o mesmo resultado sempre que os scores forem os mesmos -- nao muda QUEM vence
|
|
// (isso continua sendo so o score), so desempata de forma reproduzivel.
|
|
const sorted = [...eligible].sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
return String(a.ticket.id).localeCompare(String(b.ticket.id));
|
|
});
|
|
|
|
const groupCap = Math.max(1, Math.ceil(topN * 0.5));
|
|
const groupCounts = {};
|
|
const firstPass = [];
|
|
const deferred = [];
|
|
|
|
for (const item of sorted) {
|
|
const count = groupCounts[item.group] || 0;
|
|
if (count < groupCap && firstPass.length < topN) {
|
|
firstPass.push(item);
|
|
groupCounts[item.group] = count + 1;
|
|
} else {
|
|
deferred.push(item);
|
|
}
|
|
}
|
|
|
|
let selected = firstPass;
|
|
if (selected.length < topN) {
|
|
selected = selected.concat(deferred.slice(0, topN - selected.length));
|
|
}
|
|
selected = selected.slice(0, topN);
|
|
|
|
const selectedIds = new Set(selected.map((item) => item.ticket.id));
|
|
const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));
|
|
|
|
return { selected, excluded: excluded.concat(notSelected) };
|
|
}
|
|
|
|
module.exports = { selectTop };
|