fix(v2): centralize AI_FALLBACK/INSUFFICIENT_CONTEXT eligibility policy in cooldown.js
applyFallbackPolicy (new, in logic/cooldown.js alongside the isCriticalOverride it reuses) is now the single place that decides whether an AI_FALLBACK or INSUFFICIENT_CONTEXT ticket can enter the Top10: blocked by default, let through only via an explicit critical override (SLA vencido + prioridade/ urgencia alta, OR sem_tecnico + prioridade/urgencia alta -- isCriticalOverride extended to cover both, previously only covered SLA+priority). score.js calls this policy and sets excludedFromTop10/exclusionReason/decisionReason accordingly, with an auditable *_critical_override or *_no_override reason. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
00a42db269
commit
34f61abc3b
+30
-2
@@ -4,8 +4,36 @@ const BLOCKING_CLASSIFICATIONS = new Set([
|
|||||||
'WAITING_REQUESTER', 'WAITING_VENDOR', 'SCHEDULED', 'DEPENDENCY_BLOCKED',
|
'WAITING_REQUESTER', 'WAITING_VENDOR', 'SCHEDULED', 'DEPENDENCY_BLOCKED',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Regra centralizada de "isso e critico o bastante pra furar uma exclusao normal" --
|
||||||
|
// usada tanto pelo cooldown de reenvio quanto pela politica de AI_FALLBACK/
|
||||||
|
// INSUFFICIENT_CONTEXT (applyFallbackPolicy abaixo). Unico lugar que define "critico".
|
||||||
function isCriticalOverride(ticket) {
|
function isCriticalOverride(ticket) {
|
||||||
return Boolean(ticket.sla_vencido) && Number(ticket.prioridade) >= 4;
|
const prioridadeAlta = Number(ticket.prioridade) >= 4 || Number(ticket.urgencia) >= 4;
|
||||||
|
const slaVencidoCritico = Boolean(ticket.sla_vencido) && prioridadeAlta;
|
||||||
|
const semTecnicoCritico = Boolean(ticket.sem_tecnico) && prioridadeAlta;
|
||||||
|
return slaVencidoCritico || semTecnicoCritico;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI_FALLBACK (chamado nunca analisado pela IA de verdade) e INSUFFICIENT_CONTEXT
|
||||||
|
// (analisado, mas sem evidencia suficiente) NAO disputam o Top10 normalmente -- so entram
|
||||||
|
// via excecao deterministica forte (isCriticalOverride). Centraliza a regra pedida:
|
||||||
|
// "AI_FALLBACK nao pode competir como se fosse acionavel confirmado".
|
||||||
|
function applyFallbackPolicy({ aiClassification, ticket }) {
|
||||||
|
const critical = isCriticalOverride(ticket);
|
||||||
|
|
||||||
|
if (aiClassification.is_fallback) {
|
||||||
|
return critical
|
||||||
|
? { blocked: false, decisionReason: 'ai_fallback_critical_override' }
|
||||||
|
: { blocked: true, decisionReason: 'ai_fallback_no_override' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiClassification.classification === 'INSUFFICIENT_CONTEXT') {
|
||||||
|
return critical
|
||||||
|
? { blocked: false, decisionReason: 'insufficient_context_critical_override' }
|
||||||
|
: { blocked: true, decisionReason: 'insufficient_context_no_override' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocked: false, decisionReason: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {
|
function computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {
|
||||||
@@ -47,4 +75,4 @@ function computeCooldown({ ticket, classification, contextChanged, memory, now,
|
|||||||
return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };
|
return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { computeCooldown, isCriticalOverride, BLOCKING_CLASSIFICATIONS };
|
module.exports = { computeCooldown, isCriticalOverride, applyFallbackPolicy, BLOCKING_CLASSIFICATIONS };
|
||||||
|
|||||||
+42
-1
@@ -1,6 +1,6 @@
|
|||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { computeCooldown } = require('./cooldown');
|
const { computeCooldown, applyFallbackPolicy, isCriticalOverride } = require('./cooldown');
|
||||||
|
|
||||||
const CONFIG = { cooldownDays: 1 };
|
const CONFIG = { cooldownDays: 1 };
|
||||||
const NOW = new Date('2026-08-24T08:00:00Z').getTime();
|
const NOW = new Date('2026-08-24T08:00:00Z').getTime();
|
||||||
@@ -48,3 +48,44 @@ test('sem memoria previa nunca bloqueia por cooldown', () => {
|
|||||||
assert.equal(result.blocked, false);
|
assert.equal(result.blocked, false);
|
||||||
assert.equal(result.penalty, 0);
|
assert.equal(result.penalty, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sem tecnico + prioridade alta tambem conta como critico (mesmo sem SLA vencido)', () => {
|
||||||
|
assert.equal(isCriticalOverride(ticket({ sem_tecnico: true, prioridade: 5, sla_vencido: false })), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chamado comum (nao critico) NAO conta como override', () => {
|
||||||
|
assert.equal(isCriticalOverride(ticket({ sla_vencido: true, prioridade: 2 })), false);
|
||||||
|
assert.equal(isCriticalOverride(ticket({ sem_tecnico: true, prioridade: 2 })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI_FALLBACK comum (nao critico) fica bloqueado por politica -- nao compete como acionavel confirmado', () => {
|
||||||
|
const result = applyFallbackPolicy({ aiClassification: { is_fallback: true, classification: 'AI_FALLBACK' }, ticket: ticket({}) });
|
||||||
|
assert.equal(result.blocked, true);
|
||||||
|
assert.equal(result.decisionReason, 'ai_fallback_no_override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI_FALLBACK critico (SLA vencido + prioridade alta) entra por excecao explicita', () => {
|
||||||
|
const critico = ticket({ sla_vencido: true, prioridade: 5 });
|
||||||
|
const result = applyFallbackPolicy({ aiClassification: { is_fallback: true, classification: 'AI_FALLBACK' }, ticket: critico });
|
||||||
|
assert.equal(result.blocked, false);
|
||||||
|
assert.equal(result.decisionReason, 'ai_fallback_critical_override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('INSUFFICIENT_CONTEXT comum fica fora da competicao operacional normal', () => {
|
||||||
|
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'INSUFFICIENT_CONTEXT' }, ticket: ticket({}) });
|
||||||
|
assert.equal(result.blocked, true);
|
||||||
|
assert.equal(result.decisionReason, 'insufficient_context_no_override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('INSUFFICIENT_CONTEXT critico entra somente pela excecao definida', () => {
|
||||||
|
const critico = ticket({ sem_tecnico: true, prioridade: 4 });
|
||||||
|
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'INSUFFICIENT_CONTEXT' }, ticket: critico });
|
||||||
|
assert.equal(result.blocked, false);
|
||||||
|
assert.equal(result.decisionReason, 'insufficient_context_critical_override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classificacao real da IA (nao fallback, nao INSUFFICIENT_CONTEXT) nunca e bloqueada por esta politica', () => {
|
||||||
|
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'ACTIONABLE_NOW' }, ticket: ticket({}) });
|
||||||
|
assert.equal(result.blocked, false);
|
||||||
|
assert.equal(result.decisionReason, null);
|
||||||
|
});
|
||||||
|
|||||||
+19
-3
@@ -1,4 +1,4 @@
|
|||||||
const { computeCooldown } = require('./cooldown');
|
const { computeCooldown, applyFallbackPolicy } = require('./cooldown');
|
||||||
|
|
||||||
const CLASSIFICATION_BASE_POINTS = {
|
const CLASSIFICATION_BASE_POINTS = {
|
||||||
ACTIONABLE_NOW: 200,
|
ACTIONABLE_NOW: 200,
|
||||||
@@ -12,11 +12,16 @@ const CLASSIFICATION_BASE_POINTS = {
|
|||||||
DUPLICATE_OR_OBSOLETE: -200,
|
DUPLICATE_OR_OBSOLETE: -200,
|
||||||
SCHEDULED: -1000,
|
SCHEDULED: -1000,
|
||||||
NEEDS_COORDINATION: -1000,
|
NEEDS_COORDINATION: -1000,
|
||||||
|
AI_FALLBACK: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {
|
function computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {
|
||||||
const classification = aiClassification.classification;
|
const classification = aiClassification.classification;
|
||||||
const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });
|
const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });
|
||||||
|
// AI_FALLBACK (nunca analisado) e INSUFFICIENT_CONTEXT (analisado, sem evidencia) nao
|
||||||
|
// disputam o Top10 normalmente -- so entram por excecao deterministica forte, decidida em
|
||||||
|
// um so lugar (logic/cooldown.js -> applyFallbackPolicy).
|
||||||
|
const fallbackPolicy = applyFallbackPolicy({ aiClassification, ticket });
|
||||||
|
|
||||||
const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;
|
const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;
|
||||||
const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;
|
const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;
|
||||||
@@ -30,7 +35,7 @@ function computeScore({ ticket, aiClassification, memory, contextChanged, config
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scheduledNotDue = classification === 'SCHEDULED';
|
const scheduledNotDue = classification === 'SCHEDULED';
|
||||||
const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue;
|
const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue || fallbackPolicy.blocked;
|
||||||
|
|
||||||
const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;
|
const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;
|
||||||
|
|
||||||
@@ -41,11 +46,22 @@ function computeScore({ ticket, aiClassification, memory, contextChanged, config
|
|||||||
else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';
|
else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';
|
||||||
else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';
|
else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';
|
||||||
|
|
||||||
|
let exclusionReason = null;
|
||||||
|
if (excludedFromTop10) {
|
||||||
|
if (scheduledNotDue) exclusionReason = 'scheduled_not_due';
|
||||||
|
else if (classification === 'NEEDS_COORDINATION') exclusionReason = 'needs_coordination';
|
||||||
|
else if (fallbackPolicy.blocked) exclusionReason = fallbackPolicy.decisionReason;
|
||||||
|
else exclusionReason = cooldown.reason;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
score: Math.round(total * 100) / 100,
|
score: Math.round(total * 100) / 100,
|
||||||
group,
|
group,
|
||||||
excludedFromTop10,
|
excludedFromTop10,
|
||||||
exclusionReason: excludedFromTop10 ? (scheduledNotDue ? 'scheduled_not_due' : cooldown.reason) : null,
|
exclusionReason,
|
||||||
|
// decisionReason fica preenchido mesmo quando NAO excluido (ex.: *_critical_override),
|
||||||
|
// pra ficar auditavel por que um AI_FALLBACK/INSUFFICIENT_CONTEXT furou a regra.
|
||||||
|
decisionReason: fallbackPolicy.decisionReason,
|
||||||
breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },
|
breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },
|
||||||
cooldown,
|
cooldown,
|
||||||
};
|
};
|
||||||
|
|||||||
+27
-1
@@ -60,6 +60,32 @@ test('NEEDS_COORDINATION e excluido do Top10 diario', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('fallback nao recebe bonus de acionabilidade (comportamento seguro tipo V1)', () => {
|
test('fallback nao recebe bonus de acionabilidade (comportamento seguro tipo V1)', () => {
|
||||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ is_fallback: true }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||||
assert.equal(result.breakdown.actionabilityPoints, 0);
|
assert.equal(result.breakdown.actionabilityPoints, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AI_FALLBACK comum e excluido do Top10 (nao pode competir como se fosse acionavel confirmado)', () => {
|
||||||
|
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true, confidence: 0 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||||
|
assert.equal(result.excludedFromTop10, true);
|
||||||
|
assert.equal(result.exclusionReason, 'ai_fallback_no_override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI_FALLBACK em chamado critico (SLA vencido + prioridade alta) entra por excecao explicita', () => {
|
||||||
|
const critico = ticket({ sla_vencido: true, prioridade: 5, urgencia: 5 });
|
||||||
|
const result = computeScore({ ticket: critico, aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true, confidence: 0 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||||
|
assert.equal(result.excludedFromTop10, false);
|
||||||
|
assert.equal(result.decisionReason, 'ai_fallback_critical_override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('INSUFFICIENT_CONTEXT comum fica fora da competicao operacional normal (excluido do Top10)', () => {
|
||||||
|
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'INSUFFICIENT_CONTEXT', confidence: 0.3 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||||
|
assert.equal(result.excludedFromTop10, true);
|
||||||
|
assert.equal(result.exclusionReason, 'insufficient_context_no_override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('INSUFFICIENT_CONTEXT critico entra somente pela excecao definida', () => {
|
||||||
|
const critico = ticket({ sem_tecnico: true, prioridade: 4 });
|
||||||
|
const result = computeScore({ ticket: critico, aiClassification: ai({ classification: 'INSUFFICIENT_CONTEXT', confidence: 0.3 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||||
|
assert.equal(result.excludedFromTop10, false);
|
||||||
|
assert.equal(result.decisionReason, 'insufficient_context_critical_override');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user