diff --git a/logic/score.js b/logic/score.js new file mode 100644 index 0000000..f05f544 --- /dev/null +++ b/logic/score.js @@ -0,0 +1,54 @@ +const { computeCooldown } = require('./cooldown'); + +const CLASSIFICATION_BASE_POINTS = { + ACTIONABLE_NOW: 200, + UNASSIGNED_ACTIONABLE: 180, + WAITING_APPROVAL: 40, + INSUFFICIENT_CONTEXT: 0, + WAITING_REQUESTER: -150, + WAITING_VENDOR: -150, + DEPENDENCY_BLOCKED: -150, + PROJECT_LONG_TERM: -120, + DUPLICATE_OR_OBSOLETE: -200, + SCHEDULED: -1000, + NEEDS_COORDINATION: -1000, +}; + +function computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) { + const classification = aiClassification.classification; + const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config }); + + const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4; + const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0; + + let actionabilityPoints = 0; + if (!aiClassification.is_fallback) { + actionabilityPoints = CLASSIFICATION_BASE_POINTS[classification] ?? 0; + if (actionabilityPoints > 0 && Number(aiClassification.confidence) < config.aiConfidenceThreshold) { + actionabilityPoints /= 2; + } + } + + const scheduledNotDue = classification === 'SCHEDULED'; + const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue; + + const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty; + + let group = 'ranking_geral'; + const actionableIsh = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' || aiClassification.is_fallback; + if (actionableIsh && ticket.sla_vencido && (ticket.prioridade >= 4 || ticket.urgencia >= 4)) group = 'critico_acionavel'; + else if (actionableIsh && ticket.sem_tecnico) group = 'sem_tecnico'; + else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel'; + else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win'; + + return { + score: Math.round(total * 100) / 100, + group, + excludedFromTop10, + exclusionReason: excludedFromTop10 ? (scheduledNotDue ? 'scheduled_not_due' : cooldown.reason) : null, + breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty }, + cooldown, + }; +} + +module.exports = { computeScore, CLASSIFICATION_BASE_POINTS }; diff --git a/logic/score.test.js b/logic/score.test.js new file mode 100644 index 0000000..0085f21 --- /dev/null +++ b/logic/score.test.js @@ -0,0 +1,65 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { computeScore } = require('./score'); + +const CONFIG = { aiConfidenceThreshold: 0.6, cooldownDays: 1 }; +const NOW = new Date('2026-08-24T08:00:00Z').getTime(); + +function ticket(overrides) { + return Object.assign({ + id: 1, sla_vencido: false, sem_tecnico: false, dias_sem_interacao: 5, dias_aberto: 5, + prioridade: 3, urgencia: 3, score_criticidade: 100, + }, overrides); +} + +function ai(overrides) { + return Object.assign({ + classification: 'ACTIONABLE_NOW', actionable_today: true, confidence: 0.9, + is_fallback: false, reason: 'x', evidence: [], + }, overrides); +} + +test('chamado acionavel + SLA vencido + prioridade alta cai no grupo critico_acionavel com score alto', () => { + const t = ticket({ sla_vencido: true, prioridade: 5, urgencia: 5 }); + const result = computeScore({ ticket: t, aiClassification: ai({}), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + assert.equal(result.group, 'critico_acionavel'); + assert.ok(result.score > 200); + assert.equal(result.excludedFromTop10, false); +}); + +test('WAITING_REQUESTER sem mudanca nao monopoliza (score baixo)', () => { + const t = ticket({}); + const acionavel = computeScore({ ticket: t, aiClassification: ai({}), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + const esperando = computeScore({ ticket: t, aiClassification: ai({ classification: 'WAITING_REQUESTER' }), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + assert.ok(esperando.score < acionavel.score); +}); + +test('SCHEDULED nao entra antes da data (excluido do Top10)', () => { + const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'SCHEDULED' }), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + assert.equal(result.excludedFromTop10, true); + assert.equal(result.exclusionReason, 'scheduled_not_due'); +}); + +test('sem tecnico e acionavel recebe bonus e grupo sem_tecnico', () => { + const t = ticket({ sem_tecnico: true, sla_vencido: false }); + const result = computeScore({ ticket: t, aiClassification: ai({ classification: 'UNASSIGNED_ACTIONABLE' }), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + assert.equal(result.group, 'sem_tecnico'); + assert.ok(result.breakdown.semTecnicoBonus > 0); +}); + +test('confianca baixa reduz o peso da acionabilidade', () => { + const t = ticket({}); + const altaConfianca = computeScore({ ticket: t, aiClassification: ai({ confidence: 0.9 }), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + const baixaConfianca = computeScore({ ticket: t, aiClassification: ai({ confidence: 0.2 }), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + assert.ok(baixaConfianca.breakdown.actionabilityPoints < altaConfianca.breakdown.actionabilityPoints); +}); + +test('NEEDS_COORDINATION e excluido do Top10 diario', () => { + const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'NEEDS_COORDINATION' }), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + assert.equal(result.excludedFromTop10, true); +}); + +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 }); + assert.equal(result.breakdown.actionabilityPoints, 0); +});