From 014c48d5789d65902a3d28a1541c97c6c42ab79e Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 15:16:25 -0300 Subject: [PATCH] feat(v2): add state-based cooldown with critical override Co-Authored-By: Claude Sonnet 5 --- logic/cooldown.js | 50 ++++++++++++++++++++++++++++++++++++++++++ logic/cooldown.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 logic/cooldown.js create mode 100644 logic/cooldown.test.js diff --git a/logic/cooldown.js b/logic/cooldown.js new file mode 100644 index 0000000..eb29bee --- /dev/null +++ b/logic/cooldown.js @@ -0,0 +1,50 @@ +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +const BLOCKING_CLASSIFICATIONS = new Set([ + 'WAITING_REQUESTER', 'WAITING_VENDOR', 'SCHEDULED', 'DEPENDENCY_BLOCKED', +]); + +function isCriticalOverride(ticket) { + return Boolean(ticket.sla_vencido) && Number(ticket.prioridade) >= 4; +} + +function computeCooldown({ ticket, classification, contextChanged, memory, now, config }) { + const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0; + const lastSentAt = memory ? memory.last_sent_at : null; + const nextReviewAt = memory ? memory.next_review_at : null; + const critical = isCriticalOverride(ticket); + + let blocked = false; + let penalty = 0; + let reason = 'ok'; + let breakCooldown = false; + + if (classification === 'NEEDS_COORDINATION') { + blocked = true; + reason = 'needs_coordination'; + } else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) { + blocked = true; + reason = 'waiting_next_review'; + } else if (BLOCKING_CLASSIFICATIONS.has(classification) && !contextChanged) { + blocked = true; + reason = `blocked_${classification.toLowerCase()}`; + } + + const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5; + if (!contextChanged && sentRecently && consecutiveDaysSent > 0) { + const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15; + penalty = Math.min(90, perDayBase * consecutiveDaysSent); + if (!blocked) reason = 'repeat_no_change'; + } + + if (blocked && critical && classification !== 'NEEDS_COORDINATION') { + breakCooldown = true; + blocked = false; + penalty = Math.round(penalty / 2); + reason = `${reason}_override_critical`; + } + + return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent }; +} + +module.exports = { computeCooldown, isCriticalOverride, BLOCKING_CLASSIFICATIONS }; diff --git a/logic/cooldown.test.js b/logic/cooldown.test.js new file mode 100644 index 0000000..a855bb1 --- /dev/null +++ b/logic/cooldown.test.js @@ -0,0 +1,50 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { computeCooldown } = require('./cooldown'); + +const CONFIG = { cooldownDays: 1 }; +const NOW = new Date('2026-08-24T08:00:00Z').getTime(); +const ONE_DAY = 24 * 60 * 60 * 1000; + +function ticket(overrides) { + return Object.assign({ sla_vencido: false, prioridade: 2, urgencia: 2 }, overrides); +} + +test('WAITING_VENDOR sem mudanca de contexto fica bloqueado (nao repete diariamente)', () => { + const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY }; + const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_VENDOR', contextChanged: false, memory, now: NOW, config: CONFIG }); + assert.equal(result.blocked, true); +}); + +test('enviado ontem sem mudanca recebe penalidade crescente', () => { + const memory = { consecutive_days_sent: 3, last_sent_at: NOW - ONE_DAY, next_review_at: null }; + const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: false, memory, now: NOW, config: CONFIG }); + assert.ok(result.penalty > 0); + assert.equal(result.blocked, false); +}); + +test('mudanca de contexto real desbloqueia mesmo em estado de espera', () => { + const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY }; + const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_VENDOR', contextChanged: true, memory, now: NOW, config: CONFIG }); + assert.equal(result.blocked, false); +}); + +test('NEEDS_COORDINATION sempre bloqueado para o Top 10 diario', () => { + const result = computeCooldown({ ticket: ticket({}), classification: 'NEEDS_COORDINATION', contextChanged: false, memory: null, now: NOW, config: CONFIG }); + assert.equal(result.blocked, true); + assert.equal(result.reason, 'needs_coordination'); +}); + +test('chamado critico (SLA vencido + prioridade alta) quebra o cooldown', () => { + const memory = { consecutive_days_sent: 4, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY }; + const critico = ticket({ sla_vencido: true, prioridade: 5 }); + const result = computeCooldown({ ticket: critico, classification: 'WAITING_VENDOR', contextChanged: false, memory, now: NOW, config: CONFIG }); + assert.equal(result.blocked, false); + assert.equal(result.breakCooldown, true); +}); + +test('sem memoria previa nunca bloqueia por cooldown', () => { + const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: true, memory: null, now: NOW, config: CONFIG }); + assert.equal(result.blocked, false); + assert.equal(result.penalty, 0); +});