const ONE_DAY_MS = 24 * 60 * 60 * 1000; // Classes que NUNCA disputam o Top10 operacional -- bloqueadas por definicao, nao por cache. // Lista expandida na missao "finalizacao para producao" (secao 10): WAITING_APPROVAL, // PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE entraram aqui (antes so tinham penalidade de // score, o que deixava WAITING_APPROVAL -- pontuacao base positiva -- competir de verdade). const BLOCKING_CLASSIFICATIONS = new Set([ 'WAITING_REQUESTER', 'WAITING_VENDOR', 'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', ]); // 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) { 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 }) { 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)) { // BUG REAL comprovado na Run A anterior: WAITING_VENDOR entrou no Top10 so porque o // contexto tinha mudado nesta execucao (contextChanged=true). contextChanged decide SE // reanalisamos (logic/cache.js) -- nunca decide se um chamado que CONTINUA classificado // numa classe bloqueada pode competir. Por isso este bloqueio e incondicional aqui: se a // classificacao atual (fresca ou cacheada, tanto faz) esta em BLOCKING_CLASSIFICATIONS, // fica fora do Top10, ponto. Missao "finalizacao para producao", secao 10. 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, applyFallbackPolicy, BLOCKING_CLASSIFICATIONS };