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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
// @requires fallback.js
return $input.all().map((item) => ({ json: { ...item.json, aiClassification: buildFallbackClassification('ai_disabled') } }));
@@ -0,0 +1,14 @@
// @requires message.js
const data = $input.first().json; // { mensagem, selected, excluded, dateStr }
const changedBlocked = data.excluded.filter((item) => {
const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';
if (!isCoordination) return false;
const memory = item.memory;
if (!memory) return true;
return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;
});
const digest = buildCoordinationDigest(changedBlocked, data.dateStr);
return [{ json: { ...data, digest, changedBlocked } }];
@@ -0,0 +1,17 @@
// @requires config.js
// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),
// entao a configuracao vem do node "Config - Coordenador GLPI v2" (mesmo padrao do V1),
// nao de variaveis de ambiente reais.
const configSource = $('Config - Coordenador GLPI v2').first().json;
const config = parseConfig(configSource);
const glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''");
const queryTemplate = /* __SQL_CANDIDATOS_V2__ */;
const query = queryTemplate
.replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))
.replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))
.replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)
.replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));
return [{ json: { query, config } }];
@@ -0,0 +1,24 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('SQL de candidatos V2 preserva a exclusao administrativa do V1', () => {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes("NOT LIKE 'ADMINISTRATIVO%'"));
assert.ok(sql.includes("NOT LIKE 'DESENVOLVIMENTO%'"));
assert.ok(sql.includes("NOT LIKE 'INFRA%'"));
assert.ok(sql.includes('is_deleted = false'));
assert.ok(sql.includes('status NOT IN (5, 6)'));
});
test('SQL de candidatos V2 NAO tem mais LIMIT fixo em 10 (funil maior)', () => {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes('LIMIT {CANDIDATE_POOL_SIZE}'));
assert.ok(!sql.includes('LIMIT 10'));
});
test('SQL de candidatos V2 traz a descricao real do chamado (t.content) para a IA', () => {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes('t.content AS descricao'));
});
@@ -0,0 +1,8 @@
const candidatos = $input.all().map((item) => item.json);
const ids = candidatos.map((c) => Number(c.id)).filter((id) => Number.isInteger(id));
const idsList = ids.length ? ids.join(',') : '0';
const queryTemplate = /* __SQL_CONTEXTO__ */;
const query = queryTemplate.replaceAll('{IDS}', idsList);
return [{ json: { query, candidateIds: ids } }];
@@ -0,0 +1,11 @@
const config = $('Config - Coordenador GLPI v2').first().json;
const glpiBaseUrl = String(config.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''");
const queryTemplate = /* __SQL_LEGADO_V1_VERBATIM__ */;
const query = queryTemplate
.replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', '2')
.replaceAll('{HORAS_SLA_RISCO}', '8')
.replaceAll('{GLPI_BASE_URL}', glpiBaseUrl);
return [{ json: { query } }];
@@ -0,0 +1,9 @@
// @requires message.js
const items = $input.all().map((item) => item.json);
const selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);
const excluded = items.filter((data) => data._grupo === 'excluded');
const dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });
const mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});
return [{ json: { mensagem, selected, excluded, dateStr } }];
@@ -0,0 +1,83 @@
// @requires dataQuality.js
// Este node roda depois de "Merge - Unir Shadow" (append: Code - Montar Digest Coordenacao +
// MySQL - Legado Top 10 (Shadow)), que existe so para GARANTIR que os dois ja executaram --
// os dados continuam sendo lidos por nome, nao pelo item mesclado, para nao depender de ordem.
const digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }
const legacyRows = $('MySQL - Legado Top 10 (Shadow)').all().map((item) => item.json);
const legacyIds = legacyRows.map((row) => Number(row.id));
const v2Ids = digestData.selected.map((item) => Number(item.ticket.id));
const added = v2Ids.filter((id) => !legacyIds.includes(id));
const removed = legacyIds.filter((id) => !v2Ids.includes(id));
const addedReasons = added.map((id) => {
const item = digestData.selected.find((s) => Number(s.ticket.id) === id);
return { id, motivo: item ? `${item.aiClassification.classification} - ${item.aiClassification.reason}` : 'desconhecido' };
});
const removedReasons = removed.map((id) => {
const excludedItem = digestData.excluded.find((e) => Number(e.ticket.id) === id);
return { id, motivo: excludedItem ? (excludedItem.exclusionReason || excludedItem.aiClassification.classification) : 'nao estava nos candidatos V2' };
});
// --- observabilidade (secao 24 da missao) ---
const allItems = [...digestData.selected, ...digestData.excluded];
const cacheHits = allItems.filter((i) => i.resolucao === 'cache').length;
const aiRequested = allItems.filter((i) => i.resolucao === 'ia').length;
const aiFallbackCapCount = allItems.filter((i) => i.resolucao === 'fallback_cap').length;
const aiSuccess = allItems.filter((i) => i.resolucao === 'ia' && !i.aiClassification.is_fallback).length;
const aiErrors = allItems.filter((i) => i.resolucao === 'ia' && i.aiClassification.is_fallback).length;
const aiFallbackCount = allItems.filter((i) => i.aiClassification.is_fallback).length;
const aiFallbackByReason = {};
allItems
.filter((i) => i.aiClassification.is_fallback)
.forEach((i) => {
const r = i.aiClassification.fallback_reason || 'unknown';
aiFallbackByReason[r] = (aiFallbackByReason[r] || 0) + 1;
});
const classificationCounts = {};
allItems.forEach((i) => {
const c = i.aiClassification.classification;
classificationCounts[c] = (classificationCounts[c] || 0) + 1;
});
const actionableRealCount = allItems.filter((i) => !i.aiClassification.is_fallback
&& (i.aiClassification.classification === 'ACTIONABLE_NOW' || i.aiClassification.classification === 'UNASSIGNED_ACTIONABLE')).length;
const criticalOverrideCount = allItems.filter((i) => i.decisionReason && String(i.decisionReason).endsWith('_critical_override')).length;
const observability = {
candidate_count: allItems.length,
cache_hits: cacheHits,
cache_misses: allItems.length - cacheHits,
ai_requested: aiRequested,
ai_success: aiSuccess,
ai_errors: aiErrors,
ai_fallback_count: aiFallbackCount,
ai_fallback_cap_count: aiFallbackCapCount,
ai_fallback_by_reason: aiFallbackByReason,
classification_counts: classificationCounts,
insufficient_context_count: classificationCounts.INSUFFICIENT_CONTEXT || 0,
actionable_real_count: actionableRealCount,
critical_override_count: criticalOverrideCount,
top10_count: digestData.selected.length,
blocked_count: digestData.excluded.length,
coordination_count: classificationCounts.NEEDS_COORDINATION || 0,
};
const dataQualityReview = buildDataQualityReview(allItems);
return [{
json: {
run_at: Date.now(),
legacy_ids: JSON.stringify(legacyIds),
v2_ids: JSON.stringify(v2Ids),
added: JSON.stringify(added),
removed: JSON.stringify(removed),
summary: JSON.stringify({ addedReasons, removedReasons, observability, dataQualityReview }),
mensagem: digestData.mensagem,
digest: digestData.digest,
},
}];
@@ -0,0 +1,14 @@
// @requires cooldown.js score.js
return $input.all().map((item) => {
const data = item.json;
const contextChanged = data.resolucao !== 'cache';
const result = computeScore({
ticket: data.ticket,
aiClassification: data.aiClassification,
memory: data.memory,
contextChanged,
config: data.config,
now: data.now,
});
return { json: { ...data, contextChanged, scoreResult: result } };
});
@@ -0,0 +1,53 @@
// @requires config.js cache.js classification.js
// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2".
const config = parseConfig($('Config - Coordenador GLPI v2').first().json);
const now = Date.now();
// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a
// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.
const items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }
const memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);
const memoryByTicket = {};
for (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;
const withDecision = items.map((item) => {
const memory = memoryByTicket[String(item.ticket.id)] || null;
// Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de
// AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no
// GLPI -- sem precisar apagar a Data Table na mao (missao "finalizacao para producao", secao 12).
const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);
const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });
return {
ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,
};
});
const needingAi = withDecision
.filter((item) => item.needsAiRaw)
.sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));
const allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));
return withDecision.map((item) => {
let resolucao;
if (!item.needsAiRaw) {
resolucao = 'cache';
} else if (allowedIds.has(item.ticket.id)) {
resolucao = 'ia';
} else {
resolucao = 'fallback_cap';
}
return {
json: {
ticket: item.ticket,
contextHash: item.contextHash,
cacheKey: item.cacheKey,
memory: item.memory,
needsAiReason: item.needsAiReason,
resolucao,
precisa_ia: resolucao === 'ia',
config,
now,
},
};
});
@@ -0,0 +1,26 @@
// @requires config.js hash.js slack.js
// Roda depois de "Data Table - Buscar Envios Recentes" (fetch por report_date=hoje), que existe
// so pra trazer os registros de envio ja existentes; os dados do digest sao lidos por nome
// (mesmo padrao ja usado em Code - Comparar Legacy x V2).
const digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }
const config = digestData.selected.length ? digestData.selected[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);
const existingRecords = $input.all().map((item) => item.json);
// $execution.mode: 'manual' quando disparado pelo Manual Trigger, senao ('trigger' no
// Schedule Trigger) tratamos como execucao agendada. Nao inventa um mecanismo novo de deteccao.
const runType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
const messageHash = computeMessageHash(digestData.mensagem);
const guard = evaluateSendGuard({
config, existingRecords, reportDate: digestData.dateStr, runType, messageHash,
});
return [{
json: {
mensagem: digestData.mensagem,
dateStr: digestData.dateStr,
runType,
messageHash,
shouldSend: guard.shouldSend,
reason: guard.reason,
},
}];
@@ -0,0 +1,10 @@
// Roda depois de "Data Table - Registrar Envio Digest (Falha)" ja ter gravado o status=failed.
// So agora propaga o erro de verdade, pra a execucao no n8n ficar visivelmente com status de
// erro (missao "finalizacao para producao", secao 19: "Nao silenciar erro").
const registro = $input.first().json;
throw new Error(`Envio ao Slack falhou (report_date=${registro.report_date}, run_type=${registro.run_type}): ${registro.error_detail}`);
// eslint-disable-next-line no-unreachable -- linha morta de proposito: o validador estatico do
// n8n-mcp exige um "return" literal no texto do node (heuristica que nao reconhece um node cujo
// unico proposito e lancar erro -- confirmado via validate_node, mesmo comportamento do falso
// positivo do .join() ja documentado neste projeto). Nunca executa; o throw acima sempre sai primeiro.
return [];
@@ -0,0 +1,28 @@
// @requires normalize.js context.js hash.js
const candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);
const contextRows = $input.all().map((item) => item.json);
function groupBy(rows, key) {
const map = {};
for (const row of rows) {
const k = String(row[key]);
if (!map[k]) map[k] = [];
map[k].push(row);
}
return map;
}
const followupRows = contextRows.filter((row) => row.tipo === 'followup');
const taskRows = contextRows.filter((row) => row.tipo === 'task');
const followupsByTicket = groupBy(followupRows, 'tickets_id');
const tasksByTicket = groupBy(taskRows, 'tickets_id');
return candidatosBase.map((base) => {
const idKey = String(base.id);
const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));
const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));
const ticket = normalizeTicket(base, { followups, tasks });
const contextHash = buildContextFingerprint(ticket);
return { json: { ticket, contextHash } };
});
@@ -0,0 +1,3 @@
// @requires normalize.js context.js
const items = $input.all();
return items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));
@@ -0,0 +1,84 @@
// @requires normalize.js context.js
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',
];
const systemPrompt = [
'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',
'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',
'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',
'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',
'Idade do chamado sozinha NAO e evidencia de acionabilidade.',
'',
'=== O QUE SIGNIFICA ACIONAVEL (ACTIONABLE_NOW / UNASSIGNED_ACTIONABLE) ===',
'ACTIONABLE_NOW significa: existe pelo menos uma acao concreta que um integrante do suporte pode executar AGORA para fazer o chamado avancar. NAO precisa significar que o chamado sera resolvido integralmente hoje -- precisa significar que o suporte consegue fazer algo util agora.',
'Isso INCLUI, entre outras: executar diagnostico; testar equipamento; verificar configuracao; solicitar informacao ao cliente (quando ainda NAO foi solicitada); entrar em contato com fornecedor (quando ainda NAO foi contatado); marcar/agendar visita; combinar horario; atribuir tecnico; realizar inventario; validar cenario; executar instalacao; iniciar migracao; cobrar informacao que ainda NAO foi solicitada; atualizar configuracao; reproduzir problema; levantar requisitos; ou qualquer outro proximo passo concreto sob controle do suporte.',
'',
'=== CONTATAR != AGUARDAR (diferenca critica) ===',
'Precisar contatar alguem (cliente ou fornecedor) que AINDA NAO foi contatado e ACIONAVEL AGORA: ex. "Precisamos perguntar ao cliente qual horario pode realizar a manutencao" -> ACTIONABLE_NOW, next_action "Contatar cliente para combinar horario". "Precisamos pedir orcamento ao fornecedor" -> ACTIONABLE_NOW.',
'Ja ter contatado e estar esperando resposta e WAITING_*: ex. "Cliente ja foi contatado em 23/08 e estamos aguardando resposta" -> WAITING_REQUESTER. "Orcamento ja solicitado; aguardando retorno do fornecedor" -> WAITING_VENDOR.',
'So classifique WAITING_REQUESTER, WAITING_VENDOR ou WAITING_APPROVAL quando existir evidencia POSITIVA no historico de que (1) a acao necessaria ja foi executada pelo suporte E (2) existe uma dependencia externa ainda aberta. Sem essa evidencia dupla, NAO assuma espera -- procure se existe uma acao ainda nao realizada primeiro.',
'',
'=== TECNICO INTERNO NAO E FORNECEDOR ===',
'O campo TECNICO do chamado lista responsaveis/tecnicos internos da equipe de suporte. Se uma pendencia depende de alguem listado nesse campo, NUNCA classifique como WAITING_VENDOR (fornecedor externo) -- normalmente e ACTIONABLE_NOW (se o proprio suporte pode agir) ou UNASSIGNED_ACTIONABLE (se depende de atribuicao). WAITING_VENDOR e reservado para fornecedores/terceiros genuinamente externos a equipe.',
'',
'=== STATUS "PLANEJADO" NAO SIGNIFICA BLOQUEADO ===',
'STATUS Planejado sozinho NAO significa WAITING_REQUESTER, WAITING_VENDOR nem SCHEDULED. Se existe tarefa ou apontamento descrevendo um passo operacional disponivel agora, o chamado pode ser ACTIONABLE_NOW mesmo estando "Planejado".',
'Use SCHEDULED apenas quando existir data, horario, janela ou evento futuro explicito no chamado (ou evidencia inequivoca de que nenhuma acao pode/deve ocorrer antes dessa data).',
'',
'=== CRONOLOGIA E OBRIGATORIA ===',
'Interprete os apontamentos em ordem temporal -- o mais recente relevante prevalece sobre um bloqueio antigo ja superado. Exemplo: "20/08 aguardando fornecedor" seguido de "22/08 pecas chegaram" seguido de "23/08 falta instalar" -> ACTIONABLE_NOW (instalar pecas), NAO WAITING_VENDOR. Nao se prenda a um estado antigo se um apontamento mais novo o superou.',
'',
'=== NEXT_ACTION DEVE SER CONCRETO ===',
'Para ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE, next_action precisa nomear a acao real: "Realizar inventario das maquinas", "Contatar cliente para combinar recolha", "Testar nobreak com carga", "Atribuir tecnico para visita", "Iniciar migracao dos e-mails", "Validar configuracao da impressora", "Solicitar dados de acesso ao cliente".',
'NUNCA aceite como next_action frases genericas que nao provam acionabilidade nenhuma: "Revisar chamado", "Dar andamento", "Analisar situacao", "Verificar chamado", "Acompanhar". Se voce so consegue pensar numa frase generica dessas, reconsidere a classificacao -- provavelmente falta evidencia concreta.',
'',
'=== QUANDO USAR INSUFFICIENT_CONTEXT ===',
'Antes de classificar como INSUFFICIENT_CONTEXT, pergunte-se: existe alguma acao concreta que o suporte consegue executar mesmo sem informacao completa? Falta de diagnostico nao significa falta de acao -- ex. "nao sabemos por que o notebook nao conecta" ainda pode ser ACTIONABLE_NOW com next_action "Realizar diagnostico de conectividade".',
'Use INSUFFICIENT_CONTEXT somente quando realmente nao for possivel determinar um proximo passo razoavelmente especifico -- nem mesmo um passo de diagnostico/investigacao.',
'',
'Identifique sempre: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio (interno ou externo)? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos, em ordem cronologica, que justifique a conclusao?',
`Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo, deve ser uma acao concreta quando ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas, em ordem cronologica quando relevante).`,
].join('\n');
return $input.all().map((item) => {
const data = item.json;
const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);
const requestBody = {
model: data.config.aiModel,
temperature: 0.1,
response_format: {
type: 'json_schema',
json_schema: {
name: 'ticket_classification',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
classification: { type: 'string', enum: VALID_CLASSIFICATIONS },
actionable_today: { type: 'boolean' },
confidence: { type: 'number' },
blocker_type: { type: ['string', 'null'] },
waiting_for: { type: ['string', 'null'] },
next_action: { type: 'string' },
suggested_action_type: { type: 'string' },
business_risk: { type: 'string' },
reason: { type: 'string' },
evidence: { type: 'array', items: { type: 'string' } },
},
required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],
},
},
},
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
};
return { json: { ...data, requestBody } };
});
@@ -0,0 +1,69 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
// O prompt em si nao pode ser testado deterministicamente sem chamar a IA de verdade -- estes
// testes garantem que as REGRAS exigidas pela missao "finalizacao para producao" continuam
// escritas no prompt (nao foram removidas/reescritas por acidente numa edicao futura). A prova
// comportamental real fica nas execucoes reais contra o GLPI (Run com AI_CLASSIFIER_VERSION
// nova), inspecionadas manualmente no relatorio.
function readPrompt() {
return fs.readFileSync(path.join(__dirname, 'prepare-ai-prompt.node.js'), 'utf8');
}
test('prompt define acionabilidade de forma pratica ("suporte consegue fazer algo util agora")', () => {
const src = readPrompt();
assert.ok(/suporte consegue fazer algo util agora/i.test(src));
});
test('prompt distingue contatar (ainda nao feito) de aguardar (ja feito) -- teste obrigatorio #2/#3', () => {
const src = readPrompt();
assert.ok(/AINDA NAO foi contatado/.test(src));
assert.ok(/ja foi contatado/i.test(src) || /Ja ter contatado/.test(src));
});
test('prompt exige evidencia dupla (acao ja executada + dependencia externa aberta) pra WAITING_*', () => {
const src = readPrompt();
assert.ok(/evidencia POSITIVA/.test(src));
assert.ok(/dependencia externa ainda aberta/.test(src));
});
test('prompt instrui que tecnico interno (campo TECNICO) nunca vira WAITING_VENDOR -- teste obrigatorio #1', () => {
const src = readPrompt();
assert.ok(/NUNCA classifique como WAITING_VENDOR/.test(src));
assert.ok(/tecnicos internos da equipe de suporte/.test(src));
});
test('prompt instrui que STATUS Planejado sozinho nao significa bloqueio -- teste obrigatorio #6', () => {
const src = readPrompt();
assert.ok(/Planejado sozinho NAO significa/.test(src));
});
test('prompt exige data/horario/janela explicita para SCHEDULED', () => {
const src = readPrompt();
assert.ok(/SCHEDULED apenas quando existir data, horario, janela/.test(src));
});
test('prompt exige interpretacao cronologica (apontamento mais recente prevalece) -- teste obrigatorio #8', () => {
const src = readPrompt();
assert.ok(/ordem temporal/.test(src));
assert.ok(/mais recente relevante prevalece/.test(src));
});
test('prompt rejeita next_action generico com exemplos explicitos', () => {
const src = readPrompt();
for (const frase of ['Revisar chamado', 'Dar andamento', 'Analisar situacao', 'Verificar chamado', 'Acompanhar']) {
assert.ok(src.includes(frase), `prompt deveria citar "${frase}" como exemplo generico rejeitado`);
}
});
test('prompt pede para considerar acao de diagnostico antes de usar INSUFFICIENT_CONTEXT -- teste obrigatorio #7', () => {
const src = readPrompt();
assert.ok(/Falta de diagnostico nao significa falta de acao/.test(src));
});
test('prompt continua exigindo resposta em JSON estrito com o dominio de classes correto', () => {
const src = readPrompt();
assert.ok(src.includes('VALID_CLASSIFICATIONS.join'));
});
@@ -0,0 +1,25 @@
const ONE_DAY = 24 * 60 * 60 * 1000;
// So roda depois de "Data Table - Registrar Envio Digest (Sucesso)" -- ou seja, so depois do
// Slack confirmar sucesso de verdade (missao "finalizacao para producao", secao 19). Os dados
// do Top10 sao lidos por nome (nao por $input, que aqui e o registro do envio, nao os tickets).
return $('Code - Selecionar Top 10').all()
.filter((item) => item.json._grupo === 'selected')
.map((item) => {
const data = item.json;
const memory = data.memory;
const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;
const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;
const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;
const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;
return {
json: {
ticket_id: data.ticket.id,
last_sent_at: data.now,
times_sent: timesSent,
times_sent_30d: timesSent30d,
consecutive_days_sent: consecutiveDaysSent,
updated_at: data.now,
},
};
});
@@ -0,0 +1,30 @@
const items = $input.all().map((item) => item.json);
const ONE_DAY = 24 * 60 * 60 * 1000;
return items.map((data) => {
const cls = data.aiClassification.classification;
let nextReviewAt = null;
if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY;
else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY;
return {
json: {
ticket_id: data.ticket.id,
last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now),
// Guarda a chave composta (hash de conteudo + AI_CLASSIFIER_VERSION), nao o hash puro --
// e o que faz o cache invalidar sozinho quando o prompt/classificador muda de versao,
// sem precisar apagar a Data Table na mao. Ver logic/cache.js -> buildCacheKey.
last_context_hash: data.cacheKey,
last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),
ai_classification: cls,
ai_confidence: data.aiClassification.confidence,
blocker_type: data.aiClassification.blocker_type,
waiting_for: data.aiClassification.waiting_for,
next_action: data.aiClassification.next_action,
next_review_at: nextReviewAt,
last_score: data.scoreResult.score,
last_ai_reason: data.aiClassification.reason,
updated_at: data.now,
},
};
});
@@ -0,0 +1,5 @@
// Branch "nao deve enviar" do guard (SEND_ENABLED=false ou ja enviado com sucesso hoje).
// Node terminal, so pra deixar visivel na execucao do n8n por que nao mandou nada -- sem
// escrever em nenhuma Data Table (nao e uma falha, e uma decisao esperada).
const data = $input.first().json;
return [{ json: { skipped: true, reason: data.reason, dateStr: data.dateStr, runType: data.runType } }];
@@ -0,0 +1,18 @@
// @requires hash.js slack.js
// So roda no output de erro do node "Slack - Enviar Mensagem" (onError: continueErrorOutput).
// Registra status=failed com o erro real ANTES de propagar a falha (proximo node lanca o
// erro de verdade, pra execucao ficar visivelmente vermelha no n8n -- nao silenciar).
const guardData = $('Code - Guard de Envio Slack').first().json;
const errorItem = $input.first().json || {};
const errorMessage = String(errorItem.error || errorItem.message || 'Erro desconhecido ao enviar mensagem ao Slack').slice(0, 500);
const record = buildEnvioDigestRecord({
reportDate: guardData.dateStr,
runType: guardData.runType,
messageHash: guardData.messageHash,
sentAt: Date.now(),
status: 'failed',
errorDetail: errorMessage,
});
return [{ json: record }];
@@ -0,0 +1,14 @@
// @requires hash.js slack.js
// So roda no output de sucesso do node "Slack - Enviar Mensagem" (onError: continueErrorOutput).
// Registrar sent_at/status so DEPOIS da confirmacao real do Slack -- nunca antes (missao
// "finalizacao para producao", secao 19).
const guardData = $('Code - Guard de Envio Slack').first().json;
const record = buildEnvioDigestRecord({
reportDate: guardData.dateStr,
runType: guardData.runType,
messageHash: guardData.messageHash,
sentAt: Date.now(),
status: 'success',
});
return [{ json: record }];
@@ -0,0 +1,31 @@
// @requires fallback.js
return $input.all().map((item) => {
const data = item.json;
if (data.resolucao === 'cache' && data.memory) {
const m = data.memory;
const isFallback = m.ai_classification === 'AI_FALLBACK';
return {
json: {
...data,
aiClassification: {
classification: m.ai_classification,
actionable_today: m.ai_classification === 'ACTIONABLE_NOW' || m.ai_classification === 'UNASSIGNED_ACTIONABLE',
confidence: Number(m.ai_confidence || 0),
blocker_type: m.blocker_type || null,
waiting_for: m.waiting_for || null,
next_action: m.next_action || 'Revisar chamado',
suggested_action_type: 'technical_action',
business_risk: 'medium',
reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.',
evidence: [],
// Precisa refletir de verdade o que foi cacheado -- um AI_FALLBACK cacheado
// continua sendo fallback (senao a politica de elegibilidade em cooldown.js
// nao aplicaria a exclusao/override certos numa segunda execucao com cache).
is_fallback: isFallback,
fallback_reason: isFallback ? 'cached_ai_fallback' : null,
},
},
};
}
return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } };
});
@@ -0,0 +1,30 @@
// @requires config.js diversity.js
const items = $input.all().map((item) => item.json);
const scored = items.map((data) => ({
ticket: data.ticket,
score: data.scoreResult.score,
group: data.scoreResult.group,
excludedFromTop10: data.scoreResult.excludedFromTop10,
exclusionReason: data.scoreResult.exclusionReason,
decisionReason: data.scoreResult.decisionReason,
aiClassification: data.aiClassification,
memory: data.memory,
contextHash: data.contextHash,
contextChanged: data.contextChanged,
// 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir
// Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas
// de IA reais), senao se perde depois deste node.
resolucao: data.resolucao,
config: data.config,
now: data.now,
}));
// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2".
const config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);
const { selected, excluded } = selectTop(scored, config.topN, config);
const selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));
const excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));
return [...selectedOut, ...excludedOut];
@@ -0,0 +1,40 @@
// @requires classification.js fallback.js
const promptItems = $('Code - Preparar Prompt IA').all();
const responseItems = $input.all();
return responseItems.map((responseItem, index) => {
const original = (promptItems[index] && promptItems[index].json) || {};
const httpJson = responseItem.json || {};
if (httpJson.error) {
return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };
}
let raw = null;
try {
const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;
raw = content ? JSON.parse(content) : null;
} catch (e) {
raw = null;
}
// Passa o ticket pra validacao poder aplicar a guarda deterministica "tecnico interno nunca
// vira WAITING_VENDOR" (logic/classification.js -> isWaitingForInternalTechnician). Sem
// isso a validacao so olha o JSON cru, nunca os fatos do proprio chamado.
const validation = validateAiClassification(raw, original.ticket);
let aiClassification;
if (validation.valid) {
aiClassification = validation.data;
} else {
// Motivo especifico e auditavel por que a IA caiu pra fallback -- nao joga tudo em
// "invalid_json" (missao "correcao final", secao 9: decision_reason distinguivel).
const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno'));
const isGenericAction = validation.errors.some((e) => e.includes('generico demais'));
let reason = 'invalid_json';
if (isSemanticConflict) reason = 'ai_semantic_conflict';
else if (isGenericAction) reason = 'generic_or_unsupported_next_action';
aiClassification = buildFallbackClassification(reason);
}
return { json: { ...original, aiClassification } };
});
File diff suppressed because it is too large Load Diff