feat(v2): add state-based cooldown with critical override
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
efba7792fa
commit
014c48d578
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user