From b11fcb8a7ff51bf2218a76d7ba98ec609e05726a Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 21:59:29 -0300 Subject: [PATCH] fix(v2): CRITICAL -- deterministic tie-break in Top10 selection (real duplicate-send root cause) 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 --- logic/diversity.js | 13 ++++++++++++- logic/diversity.test.js | 23 +++++++++++++++++++++++ n8n/deployed/coordenador-glpi-v2.n8n.json | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/logic/diversity.js b/logic/diversity.js index 7d209b5..8dc7c23 100644 --- a/logic/diversity.js +++ b/logic/diversity.js @@ -2,7 +2,18 @@ function selectTop(scored, topN, config) { const eligible = scored.filter((item) => !item.excludedFromTop10); const excluded = scored.filter((item) => item.excludedFromTop10); - const sorted = [...eligible].sort((a, b) => b.score - a.score); + // 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 = {}; diff --git a/logic/diversity.test.js b/logic/diversity.test.js index 635f132..9977c84 100644 --- a/logic/diversity.test.js +++ b/logic/diversity.test.js @@ -25,6 +25,29 @@ test('10 criticos legitimos no mesmo grupo entram todos (diversidade nao bloquei 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)), diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json index d31ec49..d5cde7f 100644 --- a/n8n/deployed/coordenador-glpi-v2.n8n.json +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -558,7 +558,7 @@ -320 ], "parameters": { - "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n const sorted = [...eligible].sort((a, b) => b.score - a.score);\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n decisionReason: data.scoreResult.decisionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n // 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir\n // Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas\n // de IA reais), senao se perde depois deste node.\n resolucao: data.resolucao,\n config: data.config,\n now: data.now,\n}));\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];" + "jsCode": "function toBool(value, defaultValue) {\n if (value === undefined || value === null || value === '') return defaultValue;\n return String(value).toLowerCase() !== 'false';\n}\n\nfunction toNumber(value, defaultValue) {\n const n = Number(value);\n return Number.isFinite(n) ? n : defaultValue;\n}\n\nfunction parseConfig(env) {\n const e = env || {};\n return {\n aiEnabled: toBool(e.AI_ENABLED, true),\n aiProvider: e.AI_PROVIDER || 'openai',\n aiModel: e.AI_MODEL || 'gpt-4o-mini',\n candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),\n // Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem\n // analise semantica por um cap artificial (missao \"finalizacao para producao\", secao\n // 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para\n // economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.\n aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),\n topN: Math.min(toNumber(e.TOP_N, 10), 10),\n cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),\n aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),\n aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),\n devMode: toBool(e.DEV_MODE, true),\n shadowMode: toBool(e.SHADOW_MODE, true),\n diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),\n horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),\n // Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar\n // (missao \"finalizacao para producao\", secao 22). Default false: so quem liga\n // explicitamente no Config node manda mensagem de verdade.\n environment: e.ENVIRONMENT || 'dev',\n sendEnabled: toBool(e.SEND_ENABLED, false),\n // So importa pra execucoes 'manual' -- protege contra alguem clicar \"Execute Workflow\" em\n // producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca\n // olham esse campo. Default false: manual nunca envia de verdade sem override explicito\n // (missao \"correcao final\", secao 16/17).\n forceSend: toBool(e.FORCE_SEND, false),\n };\n}\n\nfunction selectTop(scored, topN, config) {\n const eligible = scored.filter((item) => !item.excludedFromTop10);\n const excluded = scored.filter((item) => item.excludedFromTop10);\n\n // BUG REAL comprovado em execucao ao vivo (missao \"correcao final\", secao 14): Array.sort e\n // estavel, e muitos chamados empatam em score -- sem um criterio de desempate deterministico,\n // a ordem de chegada de $input.all() (que o n8n NAO garante entre branches paralelos --\n // cache/ia/fallback se juntam de novo antes daqui) decidia o desempate. Duas execucoes\n // seguidas com os MESMOS dados produziram Top10 DIFERENTES so por causa disso, gerando duas\n // mensagens reais distintas pro Slack no mesmo dia. ticket.id como criterio secundario\n // garante o mesmo resultado sempre que os scores forem os mesmos -- nao muda QUEM vence\n // (isso continua sendo so o score), so desempata de forma reproduzivel.\n const sorted = [...eligible].sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n return String(a.ticket.id).localeCompare(String(b.ticket.id));\n });\n\n const groupCap = Math.max(1, Math.ceil(topN * 0.5));\n const groupCounts = {};\n const firstPass = [];\n const deferred = [];\n\n for (const item of sorted) {\n const count = groupCounts[item.group] || 0;\n if (count < groupCap && firstPass.length < topN) {\n firstPass.push(item);\n groupCounts[item.group] = count + 1;\n } else {\n deferred.push(item);\n }\n }\n\n let selected = firstPass;\n if (selected.length < topN) {\n selected = selected.concat(deferred.slice(0, topN - selected.length));\n }\n selected = selected.slice(0, topN);\n\n const selectedIds = new Set(selected.map((item) => item.ticket.id));\n const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));\n\n return { selected, excluded: excluded.concat(notSelected) };\n}\n\nconst items = $input.all().map((item) => item.json);\n\nconst scored = items.map((data) => ({\n ticket: data.ticket,\n score: data.scoreResult.score,\n group: data.scoreResult.group,\n excludedFromTop10: data.scoreResult.excludedFromTop10,\n exclusionReason: data.scoreResult.exclusionReason,\n decisionReason: data.scoreResult.decisionReason,\n aiClassification: data.aiClassification,\n memory: data.memory,\n contextHash: data.contextHash,\n contextChanged: data.contextChanged,\n // 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir\n // Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas\n // de IA reais), senao se perde depois deste node.\n resolucao: data.resolucao,\n config: data.config,\n now: data.now,\n}));\n\n// Instancia bloqueia $env em Code nodes -- config vem do node \"Config - Coordenador GLPI v2\".\nconst config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);\nconst { selected, excluded } = selectTop(scored, config.topN, config);\n\nconst selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));\nconst excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));\n\nreturn [...selectedOut, ...excludedOut];" } }, {