feat(v2): add state-based cooldown with critical override

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-24 15:16:25 -03:00
co-authored by Claude Sonnet 5
parent efba7792fa
commit 014c48d578
2 changed files with 100 additions and 0 deletions
+50
View File
@@ -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 };
+50
View File
@@ -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);
});