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];" } }, {