diff --git a/logic/cooldown.js b/logic/cooldown.js index eb29bee..f944e56 100644 --- a/logic/cooldown.js +++ b/logic/cooldown.js @@ -4,8 +4,36 @@ const BLOCKING_CLASSIFICATIONS = new Set([ '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) { - 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 }) { @@ -47,4 +75,4 @@ function computeCooldown({ ticket, classification, contextChanged, memory, now, return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent }; } -module.exports = { computeCooldown, isCriticalOverride, BLOCKING_CLASSIFICATIONS }; +module.exports = { computeCooldown, isCriticalOverride, applyFallbackPolicy, BLOCKING_CLASSIFICATIONS }; diff --git a/logic/cooldown.test.js b/logic/cooldown.test.js index a855bb1..0f6af0e 100644 --- a/logic/cooldown.test.js +++ b/logic/cooldown.test.js @@ -1,6 +1,6 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { computeCooldown } = require('./cooldown'); +const { computeCooldown, applyFallbackPolicy, isCriticalOverride } = require('./cooldown'); const CONFIG = { cooldownDays: 1 }; 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.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); +}); diff --git a/logic/score.js b/logic/score.js index f05f544..cee1e89 100644 --- a/logic/score.js +++ b/logic/score.js @@ -1,4 +1,4 @@ -const { computeCooldown } = require('./cooldown'); +const { computeCooldown, applyFallbackPolicy } = require('./cooldown'); const CLASSIFICATION_BASE_POINTS = { ACTIONABLE_NOW: 200, @@ -12,11 +12,16 @@ const CLASSIFICATION_BASE_POINTS = { DUPLICATE_OR_OBSOLETE: -200, SCHEDULED: -1000, NEEDS_COORDINATION: -1000, + AI_FALLBACK: 0, }; function computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) { const classification = aiClassification.classification; 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 semTecnicoBonus = ticket.sem_tecnico ? 30 : 0; @@ -30,7 +35,7 @@ function computeScore({ ticket, aiClassification, memory, contextChanged, config } 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; @@ -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.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 { score: Math.round(total * 100) / 100, group, 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 }, cooldown, }; diff --git a/logic/score.test.js b/logic/score.test.js index 0085f21..551b3e4 100644 --- a/logic/score.test.js +++ b/logic/score.test.js @@ -60,6 +60,32 @@ test('NEEDS_COORDINATION e excluido do Top10 diario', () => { }); 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); }); + +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'); +});