chore: import coordenador (subtree)

git-subtree-dir: coordenador
git-subtree-mainline: facbe2e340
git-subtree-split: ce7af94d55
This commit is contained in:
Gustavo Santos
2026-10-01 14:25:55 -03:00
72 changed files with 8913 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
function needsReanalysis({ memory, currentHash, now }) {
if (!memory) return { needsAi: true, reason: 'no_cache' };
if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };
if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };
return { needsAi: false, reason: 'cache_valid' };
}
// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO
// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe
// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao
// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem
// precisar apagar a Data Table na mao (missao "finalizacao para producao", secao 12).
function buildCacheKey(contextHash, classifierVersion) {
return `${contextHash}:v${classifierVersion}`;
}
module.exports = { needsReanalysis, buildCacheKey };
+52
View File
@@ -0,0 +1,52 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { needsReanalysis, buildCacheKey } = require('./cache');
test('sem memoria previa, sempre precisa de IA', () => {
const result = needsReanalysis({ memory: null, currentHash: 'abc', now: 1000 });
assert.equal(result.needsAi, true);
assert.equal(result.reason, 'no_cache');
});
test('hash igual e sem next_review_at vencido usa cache', () => {
const memory = { last_context_hash: 'abc', next_review_at: null };
const result = needsReanalysis({ memory, currentHash: 'abc', now: 1000 });
assert.equal(result.needsAi, false);
assert.equal(result.reason, 'cache_valid');
});
test('hash diferente forca reavaliacao (mudanca significativa)', () => {
const memory = { last_context_hash: 'abc', next_review_at: null };
const result = needsReanalysis({ memory, currentHash: 'xyz', now: 1000 });
assert.equal(result.needsAi, true);
assert.equal(result.reason, 'context_changed');
});
test('next_review_at vencido forca reavaliacao mesmo com hash igual', () => {
const memory = { last_context_hash: 'abc', next_review_at: 500 };
const result = needsReanalysis({ memory, currentHash: 'abc', now: 1000 });
assert.equal(result.needsAi, true);
assert.equal(result.reason, 'review_due');
});
test('buildCacheKey combina hash de conteudo e versao do classificador', () => {
assert.equal(buildCacheKey('abc123', 3), 'abc123:v3');
assert.equal(buildCacheKey('abc123', 4), 'abc123:v4');
});
test('mudanca de AI_CLASSIFIER_VERSION invalida o cache mesmo com conteudo identico -- missao secao 12', () => {
const contentHash = 'abc123';
const oldCacheKey = buildCacheKey(contentHash, 2);
const newCacheKey = buildCacheKey(contentHash, 3);
const memory = { last_context_hash: oldCacheKey, next_review_at: null };
const result = needsReanalysis({ memory, currentHash: newCacheKey, now: 1000 });
assert.equal(result.needsAi, true, 'bump de versao deve forcar reclassificacao mesmo com o mesmo conteudo');
});
test('mesma versao + mesmo hash de conteudo usa cache normalmente -- missao secao 12', () => {
const cacheKey = buildCacheKey('abc123', 3);
const memory = { last_context_hash: cacheKey, next_review_at: null };
const result = needsReanalysis({ memory, currentHash: cacheKey, now: 1000 });
assert.equal(result.needsAi, false);
assert.equal(result.reason, 'cache_valid');
});
+123
View File
@@ -0,0 +1,123 @@
const VALID_CLASSIFICATIONS = [
'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',
'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',
'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',
];
// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de
// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO
// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.
// v4: lista de frases genericas rejeitadas expandida ("Validar e executar" e variantes --
// missao "correcao final", secao 5). Bump forca reclassificacao de qualquer candidato
// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases.
const AI_CLASSIFIER_VERSION = 4;
// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes
// devolve a STRING "null" (ou variantes equivalentes) em vez do valor JSON null. Normaliza
// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.
const NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);
function normalizeNullableString(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;
return trimmed;
}
// next_action generico nao prova acionabilidade nenhuma -- ver missao "finalizacao para
// producao", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas
// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle
// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma
// frase curta ("aguardar resposta") e legitima ali.
const ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);
// Lista expandida na missao "correcao final" (secao 5) -- "Validar e executar" era o rotulo
// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js).
const GENERIC_NEXT_ACTION_PHRASES = new Set([
'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',
'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado',
'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',
'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado',
]);
function normalizeForComparison(text) {
return String(text || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar "situação" == "situacao"
.replace(/[.!]+$/g, '');
}
function isGenericNextAction(nextAction) {
return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));
}
// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca
// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao "finalizacao para
// producao", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.
function isWaitingForInternalTechnician(waitingFor, ticket) {
if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;
const tecnico = normalizeForComparison(ticket.tecnico_atribuido);
const alvo = normalizeForComparison(waitingFor);
if (!alvo || tecnico === 'sem tecnico atribuido') return false;
return tecnico.includes(alvo);
}
function validateAiClassification(raw, ticket) {
const errors = [];
if (!raw || typeof raw !== 'object') {
return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };
}
if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {
errors.push(`classification invalida: ${raw.classification}`);
}
if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');
const confidence = Number(raw.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');
if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {
errors.push('next_action invalido (vazio ou muito longo)');
} else if (
ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)
&& isGenericNextAction(raw.next_action)
) {
errors.push(`next_action generico demais para ${raw.classification}: "${raw.next_action}"`);
}
if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');
if (typeof raw.reason !== 'string') errors.push('reason deve ser string');
const waitingFor = normalizeNullableString(raw.waiting_for);
if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {
errors.push(`waiting_for ("${waitingFor}") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);
}
if (errors.length) return { valid: false, data: null, errors };
return {
valid: true,
errors: [],
data: {
classification: raw.classification,
actionable_today: raw.actionable_today,
confidence,
blocker_type: normalizeNullableString(raw.blocker_type),
waiting_for: waitingFor,
next_action: raw.next_action,
suggested_action_type: raw.suggested_action_type ?? 'technical_action',
business_risk: raw.business_risk ?? 'medium',
reason: raw.reason,
evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],
is_fallback: false,
fallback_reason: null,
classifier_version: AI_CLASSIFIER_VERSION,
},
};
}
module.exports = {
VALID_CLASSIFICATIONS,
AI_CLASSIFIER_VERSION,
validateAiClassification,
normalizeNullableString,
isGenericNextAction,
isWaitingForInternalTechnician,
};
+147
View File
@@ -0,0 +1,147 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { validateAiClassification, VALID_CLASSIFICATIONS, AI_CLASSIFIER_VERSION } = require('./classification');
function validRaw(overrides) {
return Object.assign({
classification: 'ACTIONABLE_NOW',
actionable_today: true,
confidence: 0.91,
blocker_type: null,
waiting_for: null,
next_action: 'Validar equipamento e registrar resultado.',
suggested_action_type: 'technical_action',
business_risk: 'medium',
reason: 'Existe acao tecnica pendente.',
evidence: ['ultimo apontamento indica necessidade de validacao'],
}, overrides);
}
test('objeto valido passa e normaliza campos', () => {
const result = validateAiClassification(validRaw({}));
assert.equal(result.valid, true);
assert.equal(result.data.classification, 'ACTIONABLE_NOW');
assert.equal(result.data.is_fallback, false);
});
test('classification invalida falha com erro claro', () => {
const result = validateAiClassification(validRaw({ classification: 'ALGO_INVENTADO' }));
assert.equal(result.valid, false);
assert.ok(result.errors.some((e) => e.includes('classification')));
});
test('JSON invalido/null nao lanca excecao', () => {
assert.doesNotThrow(() => validateAiClassification(null));
assert.doesNotThrow(() => validateAiClassification('nao e objeto'));
assert.equal(validateAiClassification(null).valid, false);
});
test('next_action vazia ou so espacos falha (nao vazia e requisito minimo de qualidade)', () => {
assert.equal(validateAiClassification(validRaw({ next_action: '' })).valid, false);
assert.equal(validateAiClassification(validRaw({ next_action: ' ' })).valid, false);
});
test('confidence fora de 0..1 falha', () => {
const result = validateAiClassification(validRaw({ confidence: 1.5 }));
assert.equal(result.valid, false);
});
test('evidence e limitado a 5 itens', () => {
const result = validateAiClassification(validRaw({ evidence: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }));
assert.equal(result.data.evidence.length, 5);
});
test('todas as classes do dominio sao aceitas', () => {
for (const classification of VALID_CLASSIFICATIONS) {
const result = validateAiClassification(validRaw({ classification }));
assert.equal(result.valid, true, `classe ${classification} deveria ser valida`);
}
});
test('AI_FALLBACK nao esta no dominio de classes que a IA pode retornar', () => {
assert.ok(!VALID_CLASSIFICATIONS.includes('AI_FALLBACK'));
});
test('blocker_type/waiting_for como string "null" sao normalizados para null de verdade', () => {
const result = validateAiClassification(validRaw({ blocker_type: 'null', waiting_for: 'None' }));
assert.equal(result.valid, true);
assert.equal(result.data.blocker_type, null);
assert.equal(result.data.waiting_for, null);
});
test('blocker_type/waiting_for como "n/a" ou string vazia tambem viram null', () => {
const result = validateAiClassification(validRaw({ blocker_type: 'N/A', waiting_for: '' }));
assert.equal(result.data.blocker_type, null);
assert.equal(result.data.waiting_for, null);
});
test('blocker_type/waiting_for com valor real nao sao afetados pela normalizacao', () => {
const result = validateAiClassification(validRaw({ blocker_type: 'fornecedor', waiting_for: 'Patricia' }));
assert.equal(result.data.blocker_type, 'fornecedor');
assert.equal(result.data.waiting_for, 'Patricia');
});
test('AI_CLASSIFIER_VERSION esta definida e e carimbada em toda classificacao valida', () => {
assert.equal(typeof AI_CLASSIFIER_VERSION, 'number');
const result = validateAiClassification(validRaw({}));
assert.equal(result.data.classifier_version, AI_CLASSIFIER_VERSION);
});
test('tecnico interno (aparece em TECNICO do chamado) nunca vira WAITING_VENDOR -- missao secao 6', () => {
const ticket = { tecnico_atribuido: 'Rafael Brito' };
const result = validateAiClassification(
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'Rafael Brito', actionable_today: false }),
ticket,
);
assert.equal(result.valid, false);
assert.ok(result.errors.some((e) => e.includes('tecnico interno')));
});
test('WAITING_VENDOR com fornecedor externo de verdade (nao aparece em TECNICO) continua valido', () => {
const ticket = { tecnico_atribuido: 'Rafael Brito' };
const result = validateAiClassification(
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'fornecimento de baterias', actionable_today: false }),
ticket,
);
assert.equal(result.valid, true);
});
test('WAITING_VENDOR sem ticket informado (uso legado/sem contexto) nao quebra a validacao', () => {
const result = validateAiClassification(
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'fornecedor XPTO', actionable_today: false }),
);
assert.equal(result.valid, true);
});
test('next_action generico ("Revisar chamado") e rejeitado para ACTIONABLE_NOW -- missao secao 9', () => {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Revisar chamado' }));
assert.equal(result.valid, false);
assert.ok(result.errors.some((e) => e.includes('generico')));
});
test('next_action generico ("Acompanhar") e rejeitado para UNASSIGNED_ACTIONABLE', () => {
const result = validateAiClassification(validRaw({ classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Acompanhar', actionable_today: true }));
assert.equal(result.valid, false);
});
test('next_action concreto ("Realizar inventario das maquinas") passa para ACTIONABLE_NOW', () => {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Realizar inventário das máquinas' }));
assert.equal(result.valid, true);
});
test('next_action curto tipo "aguardar resposta" e permitido em classes WAITING_* (nao exige verbo de acao do suporte)', () => {
const result = validateAiClassification(validRaw({ classification: 'WAITING_REQUESTER', next_action: 'Aguardar resposta do cliente', actionable_today: false }));
assert.equal(result.valid, true);
});
test('"Validar e executar" e rejeitado -- era o rotulo generico que apareceu em quase toda mensagem real (missao "correcao final" secao 5)', () => {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Validar e executar' }));
assert.equal(result.valid, false);
});
test('demais frases genericas da lista expandida sao rejeitadas: "Prosseguir atendimento", "Executar chamado", "Tratar chamado", "Validar chamado", "Analisar chamado"', () => {
for (const frase of ['Prosseguir atendimento', 'Executar chamado', 'Tratar chamado', 'Validar chamado', 'Analisar chamado']) {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: frase }));
assert.equal(result.valid, false, `"${frase}" deveria ser rejeitada`);
}
});
+44
View File
@@ -0,0 +1,44 @@
function toBool(value, defaultValue) {
if (value === undefined || value === null || value === '') return defaultValue;
return String(value).toLowerCase() !== 'false';
}
function toNumber(value, defaultValue) {
const n = Number(value);
return Number.isFinite(n) ? n : defaultValue;
}
function parseConfig(env) {
const e = env || {};
return {
aiEnabled: toBool(e.AI_ENABLED, true),
aiProvider: e.AI_PROVIDER || 'openai',
aiModel: e.AI_MODEL || 'gpt-4o-mini',
candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),
// Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem
// analise semantica por um cap artificial (missao "finalizacao para producao", secao
// 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para
// economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.
aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),
topN: Math.min(toNumber(e.TOP_N, 10), 10),
cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),
aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),
aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),
devMode: toBool(e.DEV_MODE, true),
shadowMode: toBool(e.SHADOW_MODE, true),
diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),
horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),
// Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar
// (missao "finalizacao para producao", secao 22). Default false: so quem liga
// explicitamente no Config node manda mensagem de verdade.
environment: e.ENVIRONMENT || 'dev',
sendEnabled: toBool(e.SEND_ENABLED, false),
// So importa pra execucoes 'manual' -- protege contra alguem clicar "Execute Workflow" em
// producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca
// olham esse campo. Default false: manual nunca envia de verdade sem override explicito
// (missao "correcao final", secao 16/17).
forceSend: toBool(e.FORCE_SEND, false),
};
}
module.exports = { parseConfig, toBool, toNumber };
+45
View File
@@ -0,0 +1,45 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { parseConfig } = require('./config');
test('valores padrao quando env vazio', () => {
const config = parseConfig({});
assert.equal(config.aiEnabled, true);
assert.equal(config.aiProvider, 'openai');
assert.equal(config.candidatePoolSize, 60);
assert.equal(config.aiMaxCandidates, 60);
assert.equal(config.topN, 10);
assert.equal(config.aiConfidenceThreshold, 0.6);
assert.equal(config.shadowMode, true);
assert.equal(config.devMode, true);
assert.equal(config.environment, 'dev');
assert.equal(config.sendEnabled, false);
});
test('aiMaxCandidates default e igual a candidatePoolSize default -- nenhum candidato elegivel fica de fora por cap artificial (missao secao 13)', () => {
const config = parseConfig({});
assert.equal(config.aiMaxCandidates, config.candidatePoolSize);
});
test('SEND_ENABLED=true liga o envio real; ENVIRONMENT=production e respeitado', () => {
const config = parseConfig({ SEND_ENABLED: 'true', ENVIRONMENT: 'production' });
assert.equal(config.sendEnabled, true);
assert.equal(config.environment, 'production');
});
test('AI_ENABLED=false desliga a IA', () => {
const config = parseConfig({ AI_ENABLED: 'false' });
assert.equal(config.aiEnabled, false);
});
test('TOP_N nunca passa de 10 mesmo se configurado maior', () => {
const config = parseConfig({ TOP_N: '25' });
assert.equal(config.topN, 10);
});
test('overrides numericos sao respeitados', () => {
const config = parseConfig({ CANDIDATE_POOL_SIZE: '80', AI_MAX_CANDIDATES: '15', COOLDOWN_DAYS: '2' });
assert.equal(config.candidatePoolSize, 80);
assert.equal(config.aiMaxCandidates, 15);
assert.equal(config.cooldownDays, 2);
});
+87
View File
@@ -0,0 +1,87 @@
const { normalizeGlpiText } = require('./normalize');
function normalizeTicket(row, extras) {
const followups = ((extras && extras.followups) || []).map((f) => ({
id: f.id,
date: f.date,
texto: normalizeGlpiText(f.texto),
}));
const tasks = ((extras && extras.tasks) || []).map((t) => ({
id: t.id,
date: t.date,
status: t.status,
texto: normalizeGlpiText(t.texto),
}));
return {
id: Number(row.id),
titulo: normalizeGlpiText(row.titulo),
descricao: normalizeGlpiText(row.descricao),
status: Number(row.status),
status_nome: row.status_nome,
aberto_em: row.aberto_em,
ultima_interacao_em: row.ultima_interacao_em,
dias_aberto: Number(row.dias_aberto),
dias_sem_interacao: Number(row.dias_sem_interacao),
sla_vencido: Number(row.sla_vencido) === 1,
sla_em_risco: Number(row.sla_em_risco) === 1,
horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),
prioridade: Number(row.prioridade),
urgencia: Number(row.urgencia),
impacto: Number(row.impacto),
entidade: row.entidade,
categoria: row.categoria,
tecnico_atribuido: row.tecnico_atribuido,
sem_tecnico: Number(row.sem_tecnico) === 1,
link_glpi: row.link_glpi,
score_criticidade: Number(row.score_criticidade),
followups,
tasks,
};
}
function truncate(text, maxChars) {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;
}
// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do
// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).
function truncatePreservingLines(text, maxChars) {
const str = String(text || '');
return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;
}
// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /
// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega
// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).
function buildAiContextText(ticket, maxChars) {
const budget = Math.max(200, Number(maxChars) || 4000);
const followupsText = ticket.followups
.slice(-5)
.map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)
.join('\n');
const tasksText = ticket.tasks
.slice(-5)
.map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)
.join('\n');
const secoes = [
`TITULO\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,
`DESCRICAO ORIGINAL\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,
[
`STATUS: ${ticket.status_nome}`,
`TECNICO: ${ticket.tecnico_atribuido}`,
`GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,
`PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,
`SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,
].join('\n'),
`FOLLOW-UPS\n${followupsText || '(nenhum)'}`,
`TAREFAS\n${tasksText || '(nenhuma)'}`,
];
const contextoCompleto = `Chamado #${ticket.id}\n\n${secoes.join('\n\n')}`;
return truncatePreservingLines(contextoCompleto, budget);
}
module.exports = { normalizeTicket, buildAiContextText, truncate, truncatePreservingLines };
+70
View File
@@ -0,0 +1,70 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { normalizeTicket, buildAiContextText, truncate } = require('./context');
function rawRow(overrides) {
return Object.assign({
id: '38611', titulo: ' Colocar etiqueta sala 03 ', descricao: '<p>Descricao original do chamado</p>', status: '2', status_nome: 'Em atendimento',
aberto_em: '2026-08-01 08:00:00', ultima_interacao_em: '2026-08-22 08:00:00',
dias_aberto: '23', dias_sem_interacao: '2', sla_vencido: '1', sla_em_risco: '0', horas_ate_sla: null,
prioridade: '3', urgencia: '3', impacto: '3', entidade: 'Matriz', categoria: 'Facilities',
tecnico_atribuido: 'Sem tecnico atribuido', sem_tecnico: '1', link_glpi: 'https://glpi/x?id=38611',
score_criticidade: '145',
}, overrides);
}
test('normalizeTicket converte tipos e booleans 0/1', () => {
const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] });
assert.equal(t.id, 38611);
assert.equal(t.titulo, 'Colocar etiqueta sala 03');
assert.equal(t.sla_vencido, true);
assert.equal(t.sla_em_risco, false);
assert.equal(t.sem_tecnico, true);
assert.equal(t.dias_aberto, 23);
assert.deepEqual(t.followups, []);
});
test('normalizeTicket normaliza HTML da descricao', () => {
const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] });
assert.equal(t.descricao, 'Descricao original do chamado');
});
test('normalizeTicket normaliza HTML de followups e tarefas', () => {
const t = normalizeTicket(rawRow({}), {
followups: [{ id: 1, date: '2026-08-20', texto: '&#60;p&#62;Aguardando retorno&#60;/p&#62;' }],
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: '<p>Tarefa concluida</p>' }],
});
assert.equal(t.followups[0].texto, 'Aguardando retorno');
assert.equal(t.tasks[0].texto, 'Tarefa concluida');
});
test('truncate corta e adiciona reticencias', () => {
const long = 'a'.repeat(50);
assert.equal(truncate(long, 10), `${'a'.repeat(7)}...`);
assert.equal(truncate('curto', 10), 'curto');
});
test('buildAiContextText respeita o orcamento de caracteres', () => {
const t = normalizeTicket(rawRow({}), {
followups: [{ id: 1, date: '2026-08-20', texto: 'x'.repeat(500) }],
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: 'y'.repeat(500) }],
});
const text = buildAiContextText(t, 300);
assert.ok(text.length <= 300);
assert.ok(text.includes('#38611'));
});
test('buildAiContextText tem secoes delimitadas: titulo, descricao, follow-ups, tarefas', () => {
const t = normalizeTicket(rawRow({}), {
followups: [{ id: 1, date: '2026-08-20', texto: 'Aguardando resposta' }],
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: 'Tarefa feita' }],
});
const text = buildAiContextText(t, 4000);
assert.ok(text.includes('TITULO'));
assert.ok(text.includes('DESCRICAO ORIGINAL'));
assert.ok(text.includes('Descricao original do chamado'));
assert.ok(text.includes('FOLLOW-UPS'));
assert.ok(text.includes('Aguardando resposta'));
assert.ok(text.includes('TAREFAS'));
assert.ok(text.includes('Tarefa feita'));
});
+89
View File
@@ -0,0 +1,89 @@
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 };
+118
View File
@@ -0,0 +1,118 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { computeCooldown, applyFallbackPolicy, isCriticalOverride } = 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('BUG REAL corrigido: WAITING_VENDOR continua bloqueado do Top10 mesmo com contextChanged=true -- missao "finalizacao para producao" secao 10', () => {
// Antes deste ciclo, contextChanged=true desbloqueava um chamado que CONTINUAVA
// classificado como WAITING_VENDOR apos a reanalise -- foi exatamente o que aconteceu na
// Run A anterior. contextChanged so deve decidir SE reanalisamos (logic/cache.js), nunca
// SE um chamado ainda bloqueado pode competir no Top10.
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, true);
assert.equal(result.reason, 'blocked_waiting_vendor');
});
test('chamado sai de WAITING_VENDOR para ACTIONABLE_NOW apos evento real (cronologia) -- deixa de ser bloqueado', () => {
// A reclassificacao em si (peca chegou, virou ACTIONABLE_NOW) e responsabilidade do prompt/
// IA; aqui validamos que, uma vez que a classificacao ja NAO esta mais em
// BLOCKING_CLASSIFICATIONS, o cooldown para de bloquear -- sem depender de contextChanged.
const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: true, memory, now: NOW, config: CONFIG });
assert.equal(result.blocked, false);
});
test('WAITING_APPROVAL tambem fica fora do Top10 incondicionalmente -- classe adicionada nesta missao', () => {
const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_APPROVAL', contextChanged: true, memory: null, now: NOW, config: CONFIG });
assert.equal(result.blocked, true);
assert.equal(result.reason, 'blocked_waiting_approval');
});
test('PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE tambem ficam fora do Top10 incondicionalmente', () => {
const r1 = computeCooldown({ ticket: ticket({}), classification: 'PROJECT_LONG_TERM', contextChanged: true, memory: null, now: NOW, config: CONFIG });
const r2 = computeCooldown({ ticket: ticket({}), classification: 'DUPLICATE_OR_OBSOLETE', contextChanged: true, memory: null, now: NOW, config: CONFIG });
assert.equal(r1.blocked, true);
assert.equal(r2.blocked, true);
});
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);
});
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);
});
+29
View File
@@ -0,0 +1,29 @@
// Fila interna de auditoria/calibracao para chamados classificados como INSUFFICIENT_CONTEXT.
// Nao e enviada a ninguem -- serve so pra identificar problemas de preenchimento no GLPI
// (ver secao 10/14 da missao original). Fica gravada dentro do resumo do shadow run.
function buildDataQualityReview(items) {
return items
.filter((item) => item.aiClassification.classification === 'INSUFFICIENT_CONTEXT')
.map((item) => {
const t = item.ticket;
const followups = t.followups || [];
const tasks = t.tasks || [];
const contextSize = (t.descricao || '').length
+ followups.reduce((sum, f) => sum + (f.texto || '').length, 0)
+ tasks.reduce((sum, task) => sum + (task.texto || '').length, 0);
return {
ticket_id: t.id,
title: t.titulo,
ai_reason: item.aiClassification.reason,
context_size: contextSize,
has_description: Boolean(t.descricao && t.descricao.trim().length > 0),
followup_count: followups.length,
task_count: tasks.length,
has_solution: Boolean(t.solucao),
last_interaction_at: t.ultima_interacao_em,
};
});
}
module.exports = { buildDataQualityReview };
+46
View File
@@ -0,0 +1,46 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildDataQualityReview } = require('./dataQuality');
function scoredItem(overrides) {
return Object.assign({
ticket: {
id: 33492, titulo: 'Instalar novo servidor', descricao: 'Descricao do chamado',
followups: [{ id: 1, texto: 'ok' }], tasks: [], ultima_interacao_em: '2025-11-06 15:49:15',
},
aiClassification: { classification: 'INSUFFICIENT_CONTEXT', reason: 'Sem evidencia de proxima acao.' },
}, overrides);
}
test('inclui apenas chamados INSUFFICIENT_CONTEXT', () => {
const items = [
scoredItem({}),
scoredItem({ aiClassification: { classification: 'ACTIONABLE_NOW', reason: 'x' } }),
];
const review = buildDataQualityReview(items);
assert.equal(review.length, 1);
assert.equal(review[0].ticket_id, 33492);
});
test('registro traz os campos minimos pedidos para auditoria', () => {
const review = buildDataQualityReview([scoredItem({})]);
const entry = review[0];
assert.equal(entry.ticket_id, 33492);
assert.equal(entry.title, 'Instalar novo servidor');
assert.equal(entry.ai_reason, 'Sem evidencia de proxima acao.');
assert.equal(entry.has_description, true);
assert.equal(entry.followup_count, 1);
assert.equal(entry.task_count, 0);
assert.equal(entry.last_interaction_at, '2025-11-06 15:49:15');
assert.ok(typeof entry.context_size === 'number' && entry.context_size > 0);
});
test('has_description e false quando nao ha descricao real', () => {
const review = buildDataQualityReview([scoredItem({ ticket: { ...scoredItem({}).ticket, descricao: '' } })]);
assert.equal(review[0].has_description, false);
});
test('lista vazia quando nao ha nenhum INSUFFICIENT_CONTEXT', () => {
const items = [scoredItem({ aiClassification: { classification: 'ACTIONABLE_NOW', reason: 'x' } })];
assert.deepEqual(buildDataQualityReview(items), []);
});
+45
View File
@@ -0,0 +1,45 @@
function selectTop(scored, topN, config) {
const eligible = scored.filter((item) => !item.excludedFromTop10);
const excluded = scored.filter((item) => item.excludedFromTop10);
// BUG REAL comprovado em execucao ao vivo (missao "correcao final", secao 14): Array.sort e
// estavel, e muitos chamados empatam em score -- sem um criterio de desempate deterministico,
// a ordem de chegada de $input.all() (que o n8n NAO garante entre branches paralelos --
// cache/ia/fallback se juntam de novo antes daqui) decidia o desempate. Duas execucoes
// seguidas com os MESMOS dados produziram Top10 DIFERENTES so por causa disso, gerando duas
// mensagens reais distintas pro Slack no mesmo dia. ticket.id como criterio secundario
// garante o mesmo resultado sempre que os scores forem os mesmos -- nao muda QUEM vence
// (isso continua sendo so o score), so desempata de forma reproduzivel.
const sorted = [...eligible].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return String(a.ticket.id).localeCompare(String(b.ticket.id));
});
const groupCap = Math.max(1, Math.ceil(topN * 0.5));
const groupCounts = {};
const firstPass = [];
const deferred = [];
for (const item of sorted) {
const count = groupCounts[item.group] || 0;
if (count < groupCap && firstPass.length < topN) {
firstPass.push(item);
groupCounts[item.group] = count + 1;
} else {
deferred.push(item);
}
}
let selected = firstPass;
if (selected.length < topN) {
selected = selected.concat(deferred.slice(0, topN - selected.length));
}
selected = selected.slice(0, topN);
const selectedIds = new Set(selected.map((item) => item.ticket.id));
const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));
return { selected, excluded: excluded.concat(notSelected) };
}
module.exports = { selectTop };
+60
View File
@@ -0,0 +1,60 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { selectTop } = require('./diversity');
function item(id, score, group, excludedFromTop10) {
return { ticket: { id }, score, group, excludedFromTop10: Boolean(excludedFromTop10), aiClassification: {} };
}
test('Top final nunca ultrapassa topN', () => {
const scored = Array.from({ length: 20 }, (_, i) => item(i, 100 - i, 'ranking_geral', false));
const { selected } = selectTop(scored, 10, {});
assert.equal(selected.length, 10);
});
test('itens excluidos do Top10 nunca aparecem selecionados', () => {
const scored = [item(1, 500, 'critico_acionavel', true), item(2, 10, 'ranking_geral', false)];
const { selected } = selectTop(scored, 10, {});
assert.ok(!selected.some((s) => s.ticket.id === 1));
});
test('10 criticos legitimos no mesmo grupo entram todos (diversidade nao bloqueia emergencia real)', () => {
const scored = Array.from({ length: 10 }, (_, i) => item(i, 500 - i, 'critico_acionavel', false));
const { selected } = selectTop(scored, 10, {});
assert.equal(selected.length, 10);
assert.deepEqual(selected.map((s) => s.ticket.id).sort(), scored.map((s) => s.ticket.id).sort());
});
test('BUG REAL comprovado em execucao real: mesmo conjunto de itens empatados em score, em ordens de chegada diferentes, produz o MESMO Top10 -- missao "correcao final" secao 14/15', () => {
// Simula o que aconteceu de verdade: 12 itens com o MESMO score, chegando em duas ordens
// diferentes (n8n nao garante ordem entre branches paralelos). Sem desempate deterministico
// por ticket.id, cada execucao escolhia um subconjunto de 10 diferente -> mensagens
// diferentes -> hash diferente -> idempotencia nunca detectava como "mesmo dia, mesmo digest".
const ids = [38413, 38476, 38477, 38509, 36925, 37691, 37389, 38657, 75, 38611, 38583, 38658];
const ordemA = ids.map((id) => item(id, 200, 'ranking_geral', false));
const ordemB = [...ordemA].reverse();
const resultA = selectTop(ordemA, 10, {});
const resultB = selectTop(ordemB, 10, {});
const idsA = resultA.selected.map((s) => s.ticket.id);
const idsB = resultB.selected.map((s) => s.ticket.id);
assert.deepEqual(idsA, idsB, 'a mesma composicao de candidatos deveria produzir o mesmo Top10, independente da ordem de chegada');
});
test('desempate por ticket.id nao muda QUEM vence quando os scores sao diferentes (so desempata igualdade)', () => {
const scored = [item(2, 100, 'ranking_geral', false), item(1, 200, 'ranking_geral', false)];
const { selected } = selectTop(scored, 10, {});
assert.equal(selected[0].ticket.id, 1, 'score maior continua vencendo, desempate so entra quando ha empate real');
});
test('com grupos variados, ordena por score respeitando diversidade leve', () => {
const scored = [
...Array.from({ length: 8 }, (_, i) => item(`c${i}`, 300 - i, 'critico_acionavel', false)),
item('t1', 250, 'sem_tecnico', false),
item('t2', 240, 'quick_win', false),
];
const { selected } = selectTop(scored, 10, {});
assert.equal(selected.length, 10);
assert.ok(selected.some((s) => s.ticket.id === 't1'));
});
+25
View File
@@ -0,0 +1,25 @@
// AI_FALLBACK e um estado tecnico: significa "a IA nunca analisou este chamado" (erro,
// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e
// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar
// (ver logic/classification.js). Politica de elegibilidade fica centralizada em
// logic/cooldown.js (applyFallbackPolicy), nao aqui.
const AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';
function buildFallbackClassification(reason) {
return {
classification: AI_FALLBACK_CLASSIFICATION,
actionable_today: false,
confidence: 0,
blocker_type: null,
waiting_for: null,
next_action: 'Revisar chamado manualmente (nao analisado pela IA)',
suggested_action_type: 'manual_review',
business_risk: 'medium',
reason: `Fallback deterministico: ${reason}`,
evidence: [],
is_fallback: true,
fallback_reason: reason,
};
}
module.exports = { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION };
+41
View File
@@ -0,0 +1,41 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION } = require('./fallback');
const { VALID_CLASSIFICATIONS } = require('./classification');
test('fallback e sempre seguro e marcado como tal', () => {
const result = buildFallbackClassification('ai_timeout');
assert.equal(result.is_fallback, true);
assert.equal(result.fallback_reason, 'ai_timeout');
assert.equal(result.confidence, 0);
assert.ok(result.reason.includes('Fallback'));
});
test('fallback NUNCA finge ser ACTIONABLE_NOW -- usa AI_FALLBACK, um estado tecnico explicito', () => {
const result = buildFallbackClassification('ai_error');
assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION);
assert.notEqual(result.classification, 'ACTIONABLE_NOW');
assert.equal(result.actionable_today, false);
});
test('AI_FALLBACK nunca e uma classe que a propria IA pode retornar (fica fora do schema enviado a OpenAI)', () => {
assert.ok(!VALID_CLASSIFICATIONS.includes(AI_FALLBACK_CLASSIFICATION));
});
test('AI_MAX_CANDIDATES gera AI_FALLBACK, nao ACTIONABLE_NOW', () => {
const result = buildFallbackClassification('ai_max_candidates_cap');
assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION);
assert.equal(result.fallback_reason, 'ai_max_candidates_cap');
});
test('erro de IA gera AI_FALLBACK', () => {
assert.equal(buildFallbackClassification('ai_error').classification, AI_FALLBACK_CLASSIFICATION);
});
test('IA desligada gera AI_FALLBACK', () => {
assert.equal(buildFallbackClassification('ai_disabled').classification, AI_FALLBACK_CLASSIFICATION);
});
test('JSON invalido gera AI_FALLBACK', () => {
assert.equal(buildFallbackClassification('invalid_json').classification, AI_FALLBACK_CLASSIFICATION);
});
+32
View File
@@ -0,0 +1,32 @@
function stableStringify(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
const keys = Object.keys(value).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
}
function djb2Hash(str) {
let hash = 5381;
for (let i = 0; i < str.length; i += 1) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
const FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];
// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint
// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma
// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca
// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.
function buildContextFingerprint(ticket) {
const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();
const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();
const picked = {};
for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];
picked.followups = followups;
picked.tasks = tasks;
return djb2Hash(stableStringify(picked));
}
module.exports = { stableStringify, djb2Hash, buildContextFingerprint };
+57
View File
@@ -0,0 +1,57 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildContextFingerprint, stableStringify } = require('./hash');
const { normalizeGlpiText } = require('./normalize');
function baseTicket(overrides) {
return Object.assign({
status: 2,
tecnico_atribuido: 'Wagner',
categoria: 'Rede',
entidade: 'Matriz',
titulo: 'Nobreak desligando',
descricao: 'Nobreak do servidor desligando sozinho',
followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }],
tasks: [{ id: 1, status: 1, date: '2026-08-19 09:00:00', texto: 'Verificado no local' }],
dias_aberto: 10,
dias_sem_interacao: 3,
}, overrides);
}
test('alterar apenas dias_aberto/dias_sem_interacao NAO muda o hash', () => {
const a = buildContextFingerprint(baseTicket({ dias_aberto: 10, dias_sem_interacao: 3 }));
const b = buildContextFingerprint(baseTicket({ dias_aberto: 11, dias_sem_interacao: 4 }));
assert.equal(a, b);
});
test('novo followup MUDA o hash', () => {
const a = buildContextFingerprint(baseTicket({}));
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }, { id: 2, date: '2026-08-21 08:00:00', texto: 'Peca chegou' }] }));
assert.notEqual(a, b);
});
test('mudanca de tecnico MUDA o hash', () => {
const a = buildContextFingerprint(baseTicket({ tecnico_atribuido: 'Wagner' }));
const b = buildContextFingerprint(baseTicket({ tecnico_atribuido: 'Eric' }));
assert.notEqual(a, b);
});
test('mesmo conteudo normalizado de fontes com encoding HTML diferente gera o MESMO hash', () => {
const textoHtml = normalizeGlpiText('<p>Aguardando cliente</p>');
const textoEscapado = normalizeGlpiText('&#60;p&#62;Aguardando cliente&#60;/p&#62;');
assert.equal(textoHtml, textoEscapado, 'pre-condicao: normalizacao deve produzir o mesmo texto');
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoHtml }] }));
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoEscapado }] }));
assert.equal(a, b);
});
test('edicao real do conteudo de um followup (mesmo id/date) MUDA o hash', () => {
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }] }));
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Peca instalada, testando' }] }));
assert.notEqual(a, b);
});
test('stableStringify ignora ordem de chaves', () => {
assert.equal(stableStringify({ b: 1, a: 2 }), stableStringify({ a: 2, b: 1 }));
});
+62
View File
@@ -0,0 +1,62 @@
// BUG REAL (missao "correcao final", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui
// ("Validar e executar") que sempre vencia o next_action de verdade da IA -- por isso TODA
// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel,
// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js)
// um next_action especifico como "Realizar inventario das maquinas". Removido daqui: agora
// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado).
// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso "atribuir
// tecnico" E a acao real, independente do que o next_action interno da IA descreva.
const ACTION_LABELS = {
UNASSIGNED_ACTIONABLE: 'Atribuir técnico',
WAITING_APPROVAL: 'Cobrar aprovação',
INSUFFICIENT_CONTEXT: 'Completar informação',
};
function shortActionLabel(aiClassification) {
if (aiClassification.is_fallback) return 'Revisar chamado';
const fromMap = ACTION_LABELS[aiClassification.classification];
if (fromMap) return fromMap;
const raw = String(aiClassification.next_action || 'Revisar chamado').trim();
return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;
}
function tituloCurto(texto) {
const limpo = String(texto || 'Sem titulo').replace(/\s+/g, ' ').trim();
return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;
}
function signalLine(ticket) {
const parts = [];
parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);
if (ticket.sla_vencido) parts.push('SLA vencido');
parts.push(`parado ${ticket.dias_sem_interacao}d`);
return parts.join(' • ');
}
function buildTopMessage(selected, dateStr, config) {
if (!selected.length) {
return `@time Coordenador GLPI - ${dateStr}\n\nNenhum chamado acionável encontrado hoje.`;
}
const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;
const blocks = selected.map((item, index) => {
const t = item.ticket;
const linhasDoBloco = [
`${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,
signalLine(t),
`→ ${shortActionLabel(item.aiClassification)}`,
t.link_glpi,
];
return linhasDoBloco.join('\n');
});
const partesDaMensagem = [header, ...blocks];
return partesDaMensagem.join('\n\n');
}
function buildCoordinationDigest(changedBlocked, dateStr) {
if (!changedBlocked.length) return null;
const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);
const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];
return partesDoDigest.join('\n');
}
module.exports = { shortActionLabel, tituloCurto, signalLine, buildTopMessage, buildCoordinationDigest };
+106
View File
@@ -0,0 +1,106 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildTopMessage, buildCoordinationDigest, shortActionLabel, tituloCurto } = require('./message');
function selectedItem(overrides) {
return Object.assign({
ticket: {
id: 38611, titulo: 'Colocar etiqueta sala 03', tecnico_atribuido: 'Sem tecnico atribuido',
sem_tecnico: true, sla_vencido: false, dias_sem_interacao: 2, link_glpi: 'https://glpi/x?id=38611',
},
aiClassification: { classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Atribuir e executar', is_fallback: false },
}, overrides);
}
test('mensagem final contem cabecalho, id, link e acao curta', () => {
const msg = buildTopMessage([selectedItem({})], '24/08', {});
assert.ok(msg.includes('#38611'));
assert.ok(msg.includes('https://glpi/x?id=38611'));
assert.ok(msg.includes('→'));
assert.ok(msg.includes('24/08'));
});
test('mensagem NAO contem score, confianca ou justificativa longa', () => {
const msg = buildTopMessage([selectedItem({})], '24/08', {});
assert.ok(!/confian(c|ç)a/i.test(msg));
assert.ok(!/score/i.test(msg));
});
test('linha de acao e curta (limite de tamanho)', () => {
const label = shortActionLabel({ classification: 'ACTIONABLE_NOW', next_action: 'x'.repeat(100), is_fallback: false });
assert.ok(label.length <= 40);
});
test('tituloCurto limita a 60 caracteres', () => {
const curto = tituloCurto('a'.repeat(100));
assert.ok(curto.length <= 60);
});
test('digest de coordenacao vazio retorna null (nao manda lista igual todo dia)', () => {
assert.equal(buildCoordinationDigest([], '24/08'), null);
});
test('mensagem nao tem linhas em branco duplicadas entre cabecalho e blocos', () => {
const msg = buildTopMessage([selectedItem({}), selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38612 } })], '24/08', {});
assert.ok(!msg.includes('\n\n\n'), 'nao deveria haver 3+ quebras de linha seguidas');
});
test('digest de coordenacao lista apenas os itens passados (novos/alterados)', () => {
const digest = buildCoordinationDigest([{ ticket: { id: 37691 }, aiClassification: { reason: 'aguardando definicao' }, exclusionReason: 'needs_coordination' }], '24/08');
assert.ok(digest.includes('#37691'));
});
test('BUG REAL corrigido: ACTIONABLE_NOW mostra o next_action real da IA, nao o rotulo fixo "Validar e executar" -- missao "correcao final" secao 2/5', () => {
const item = selectedItem({
aiClassification: { classification: 'ACTIONABLE_NOW', next_action: 'Realizar inventário das máquinas', is_fallback: false },
});
const msg = buildTopMessage([item], '24/08', {});
assert.ok(msg.includes('Realizar inventário das máquinas'));
assert.ok(!msg.includes('Validar e executar'));
});
test('UNASSIGNED_ACTIONABLE continua mostrando "Atribuir técnico" (rotulo fixo correto, nao e o bug)', () => {
const item = selectedItem({
aiClassification: { classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Levar patch cord e etiquetar', is_fallback: false },
});
const msg = buildTopMessage([item], '24/08', {});
assert.ok(msg.includes('Atribuir técnico'));
});
test('mensagem nunca contem a sequencia literal de dois caracteres barra-n (bug reportado: \\\\n aparecendo como texto) -- teste obrigatorio #1', () => {
const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({
ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Chamado ${i}` },
aiClassification: { classification: 'ACTIONABLE_NOW', next_action: `Diagnosticar item ${i}`, is_fallback: false },
}));
const msg = buildTopMessage(dezItens, '24/08', {});
assert.ok(!msg.includes('\\n'), 'mensagem nao pode conter a sequencia literal barra-n');
});
test('cada URL termina exatamente no ticket_id -- o proximo item nunca vaza pra dentro do link anterior -- teste obrigatorio #3', () => {
const dois = [
selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38413, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413' } }),
selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38477, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477' } }),
];
const msg = buildTopMessage(dois, '24/08', {});
assert.ok(msg.includes('id=38413\n'), 'a URL do primeiro chamado deve terminar em id=38413 seguido de quebra de linha real, nao emendar no proximo item');
assert.ok(!/id=38413\d/.test(msg), 'nenhum digito deveria colar direto depois do id do primeiro chamado (URL corrompida)');
assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413'));
assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477'));
});
test('URL fica pura (sem colchetes markdown [url](url)) -- Slack ja auto-linka texto puro -- teste obrigatorio #4', () => {
const msg = buildTopMessage([selectedItem({})], '24/08', {});
assert.ok(!/\[https?:\/\//.test(msg), 'nao deveria embrulhar a URL em markdown [texto](url)');
});
test('mensagem com 10 chamados continua curta (teste obrigatorio #19 -- nao vira relatorio longo)', () => {
const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({
ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Titulo do chamado numero ${i} com texto razoavelmente longo` },
}));
const msg = buildTopMessage(dezItens, '25/08', {});
// 10 blocos de ~4 linhas curtas cada + cabecalho: bem abaixo de um "relatorio". Limite
// generoso o suficiente pra nao quebrar por titulos longos, mas que pegaria regressao real
// (ex.: reason/evidence/JSON vazando pra mensagem).
assert.ok(msg.length < 2000, `mensagem ficou com ${msg.length} caracteres, esperado < 2000`);
assert.ok(!/"classification"|"evidence"|"confidence"/.test(msg), 'mensagem nao pode vazar campos internos/JSON');
});
+58
View File
@@ -0,0 +1,58 @@
const NAMED_ENTITIES = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
};
const MAX_DECODE_ITERATIONS = 5;
function decodeEntitiesOnce(text) {
let result = text.replace(/&#(\d+);/g, (match, code) => {
const num = Number(code);
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
});
result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
const num = parseInt(hex, 16);
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
});
for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {
result = result.split(entity).join(char);
}
return result;
}
function stripHtmlTags(text) {
return text.replace(/<[^>]*>/g, ' ');
}
// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS
// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,
// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto
// que nao seja literalmente uma entidade HTML ou uma tag).
function normalizeGlpiText(rawText) {
if (rawText === null || rawText === undefined) return '';
let text = String(rawText);
for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {
const decoded = decodeEntitiesOnce(text);
if (decoded === text) break;
text = decoded;
}
text = stripHtmlTags(text);
text = text
.split('\n')
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return text;
}
module.exports = { normalizeGlpiText, decodeEntitiesOnce, stripHtmlTags };
+63
View File
@@ -0,0 +1,63 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { normalizeGlpiText } = require('./normalize');
test('HTML normal e removido, texto preservado', () => {
assert.equal(normalizeGlpiText('<p>Aguardando cliente</p>'), 'Aguardando cliente');
});
test('entidades HTML escapadas uma vez sao decodificadas e as tags removidas', () => {
const entrada = '&#60;p&#62;Aguardando retorno de Patricia sobre troca da carcaça&#60;/p&#62;';
assert.equal(normalizeGlpiText(entrada), 'Aguardando retorno de Patricia sobre troca da carcaça');
});
test('entidades HTML escapadas duas vezes sao decodificadas iterativamente', () => {
const entrada = '&amp;#60;p&amp;#62;texto&amp;#60;/p&amp;#62;';
assert.equal(normalizeGlpiText(entrada), 'texto');
});
test('entidades nomeadas comuns (amp, nbsp) sao decodificadas', () => {
assert.equal(normalizeGlpiText('Suporte&nbsp;&amp;&nbsp;Manutenção'), 'Suporte & Manutenção');
});
test('texto sem HTML permanece intacto (so trim de espacos)', () => {
assert.equal(normalizeGlpiText(' Servidor não responde '), 'Servidor não responde');
});
test('caracteres acentuados sao preservados', () => {
assert.equal(normalizeGlpiText('<p>Impressão não está funcionando corretamente na área de atendimento</p>'), 'Impressão não está funcionando corretamente na área de atendimento');
});
test('codigo tecnico (numeros, versao, hostname) e preservado', () => {
const entrada = '<p>Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50</p>';
assert.equal(normalizeGlpiText(entrada), 'Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50');
});
test('string vazia retorna string vazia', () => {
assert.equal(normalizeGlpiText(''), '');
});
test('null e undefined retornam string vazia sem lancar excecao', () => {
assert.doesNotThrow(() => normalizeGlpiText(null));
assert.doesNotThrow(() => normalizeGlpiText(undefined));
assert.equal(normalizeGlpiText(null), '');
assert.equal(normalizeGlpiText(undefined), '');
});
test('quebras de linha excessivas e espacos duplicados sao colapsados', () => {
const entrada = '<p>Linha 1</p>\n\n\n\n<p>Linha 2 com espacos</p>';
const resultado = normalizeGlpiText(entrada);
assert.ok(!resultado.includes('\n\n\n'));
assert.ok(!/ {2,}/.test(resultado));
});
test('decodificacao para no limite de iteracoes sem lancar excecao (protecao contra loop)', () => {
const entradaMaliciosa = '&amp;'.repeat(20) + '#60;p&#62;';
assert.doesNotThrow(() => normalizeGlpiText(entradaMaliciosa));
});
test('conteudo normalizado de duas codificacoes diferentes do mesmo texto e identico', () => {
const versaoHtml = '<p>Aguardando cliente</p>';
const versaoEscapada = '&#60;p&#62;Aguardando cliente&#60;/p&#62;';
assert.equal(normalizeGlpiText(versaoHtml), normalizeGlpiText(versaoEscapada));
});
+6
View File
@@ -0,0 +1,6 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
test('node:test runner funciona', () => {
assert.equal(1 + 1, 2);
});
+70
View File
@@ -0,0 +1,70 @@
const { computeCooldown, applyFallbackPolicy } = 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,
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;
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 || fallbackPolicy.blocked;
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';
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,
// 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,
};
}
module.exports = { computeScore, CLASSIFICATION_BASE_POINTS };
+102
View File
@@ -0,0 +1,102 @@
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({ 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');
});
test('BUG REAL comprovado na Run A anterior: WAITING_VENDOR fica fora do Top10 mesmo com contextChanged=true -- missao "finalizacao para producao" secao 10', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'WAITING_VENDOR', actionable_today: false, confidence: 0.8 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
assert.equal(result.exclusionReason, 'blocked_waiting_vendor');
});
test('WAITING_APPROVAL (pontuacao base positiva) tambem fica fora do Top10 -- antes so tinha penalidade de score, nao exclusao', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'WAITING_APPROVAL', actionable_today: false, confidence: 0.8 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
});
+64
View File
@@ -0,0 +1,64 @@
const { djb2Hash } = require('./hash');
// Hash estavel do texto exato que seria enviado -- usado como parte da chave de
// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no
// mesmo dia nunca devem gerar dois envios reais.
function computeMessageHash(text) {
return djb2Hash(String(text || ''));
}
// SEND_ENABLED e o gate principal de "isso pode realmente sair pro Slack" -- nunca depende de
// editar codigo (missao "finalizacao para producao", secao 22). Runs 'manual' tem uma segunda
// trava: mesmo com SEND_ENABLED=true (producao), clicar "Execute Workflow" NAO envia de
// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por
// alguem testando/depurando em producao (missao "correcao final", secao 16/17).
function shouldSendByConfig(config, runType) {
if (!config || !config.sendEnabled) return false;
if (runType === 'manual' && !config.forceSend) return false;
return true;
}
// Idempotencia: so conta como "ja enviado" um registro com status 'success' para a MESMA
// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao
// bloqueia um novo envio -- so um sucesso anterior bloqueia.
function alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {
return (existingRecords || []).some((record) => (
record
&& record.status === 'success'
&& record.report_date === reportDate
&& record.run_type === runType
&& record.message_hash === messageHash
));
}
// Decisao unica de "devo mandar pro Slack agora" -- centraliza os dois motivos possiveis de
// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.
function evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {
if (!shouldSendByConfig(config, runType)) {
const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';
return { shouldSend: false, reason };
}
if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {
return { shouldSend: false, reason: 'already_sent' };
}
return { shouldSend: true, reason: 'ok' };
}
function buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {
return {
report_date: reportDate,
run_type: runType,
message_hash: messageHash,
sent_at: sentAt,
status,
error_detail: errorDetail || null,
};
}
module.exports = {
computeMessageHash,
shouldSendByConfig,
alreadySentSuccessfully,
evaluateSendGuard,
buildEnvioDigestRecord,
};
+100
View File
@@ -0,0 +1,100 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
computeMessageHash, shouldSendByConfig, alreadySentSuccessfully, evaluateSendGuard, buildEnvioDigestRecord,
} = require('./slack');
test('computeMessageHash e deterministico para o mesmo texto', () => {
assert.equal(computeMessageHash('ola mundo'), computeMessageHash('ola mundo'));
});
test('computeMessageHash muda quando o texto muda', () => {
assert.notEqual(computeMessageHash('ola mundo'), computeMessageHash('ola mundo!'));
});
test('SEND_ENABLED=false nunca deixa enviar -- missao secao 22/13', () => {
assert.equal(shouldSendByConfig({ sendEnabled: false }), false);
});
test('SEND_ENABLED=true libera o envio (sujeito ainda a idempotencia)', () => {
assert.equal(shouldSendByConfig({ sendEnabled: true }), true);
});
test('alreadySentSuccessfully detecta envio de sucesso identico (mesma data/tipo/hash)', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), true);
});
test('alreadySentSuccessfully ignora registros de falha -- retry apos erro nao fica bloqueado', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'failed' }];
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
});
test('alreadySentSuccessfully ignora envios de outro dia ou outro hash', () => {
const existing = [{ report_date: '2026-08-24', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
const existing2 = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'xyz', status: 'success' }];
assert.equal(alreadySentSuccessfully(existing2, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
});
test('evaluateSendGuard: Slack NAO envia com SEND_ENABLED=false -- teste obrigatorio #13', () => {
const result = evaluateSendGuard({ config: { sendEnabled: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, false);
assert.equal(result.reason, 'send_disabled');
});
test('evaluateSendGuard: Slack envia com SEND_ENABLED=true e nenhum envio anterior -- teste obrigatorio #14', () => {
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, true);
assert.equal(result.reason, 'ok');
});
test('evaluateSendGuard: envio duplicado e bloqueado -- teste obrigatorio #15', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: existing, reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, false);
assert.equal(result.reason, 'already_sent');
});
test('runType manual NAO envia mesmo com SEND_ENABLED=true, a menos que FORCE_SEND tambem esteja ligado -- missao "correcao final" secao 16', () => {
const result = evaluateSendGuard({
config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc',
});
assert.equal(result.shouldSend, false);
assert.equal(result.reason, 'manual_run_needs_force_send');
});
test('runType manual envia quando FORCE_SEND esta explicitamente ligado -- missao "correcao final" secao 17', () => {
const result = evaluateSendGuard({
config: { sendEnabled: true, forceSend: true }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc',
});
assert.equal(result.shouldSend, true);
});
test('runType scheduled envia normalmente com SEND_ENABLED=true, independente de FORCE_SEND', () => {
const result = evaluateSendGuard({
config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc',
});
assert.equal(result.shouldSend, true);
});
test('evaluateSendGuard: retry (mensagem diferente) apos falha anterior nao e bloqueado', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'failed' }];
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: existing, reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, true);
});
test('buildEnvioDigestRecord monta o registro de sucesso corretamente', () => {
const record = buildEnvioDigestRecord({ reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc', sentAt: 12345, status: 'success' });
assert.deepEqual(record, {
report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', sent_at: 12345, status: 'success', error_detail: null,
});
});
test('buildEnvioDigestRecord monta o registro de falha com error_detail -- falha nunca registra sucesso (teste obrigatorio #16)', () => {
const record = buildEnvioDigestRecord({
reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc', sentAt: 12345, status: 'failed', errorDetail: 'HTTP 500',
});
assert.equal(record.status, 'failed');
assert.equal(record.error_detail, 'HTTP 500');
});