feat(v2): complete n8n workflow template (36 nodes) covering full V2 pipeline + shadow branch

- 14 new Code node drivers implementing the pipeline stages: normalize
  candidates, build context SQL, merge context + hash, decide AI need,
  prepare AI prompt, validate AI response, AI-disabled fallback, reuse
  cached classification, compute score, select top 10, prepare memory/
  envio records, build message/digest, compare legacy x V2, debug panel.
- Consolidated the two context queries (followups/tasks) into a single
  UNION ALL query tagged by tipo, and added a "Merge - Unir Shadow"
  synchronization node before comparing legacy x V2: n8n does not
  guarantee relative execution order between sibling branches that only
  share an early common ancestor, so cross-branch data was only safe to
  read by name once a real Merge node forced both to complete first.
- Reordered score calculation before the memory upsert (score is needed
  in the persisted row) and turned both Data Table writes into
  side-effect branches that fan out from the main path instead of
  gating it, so the Data Table node's own output shape never needs to
  carry the full pipeline state forward.
- Fixed a duplicate-identifier bug in the build script: score.js's
  require('./cooldown') survived inlining and collided with
  cooldown.js's own computeCooldown function in the same Code node
  scope. build-workflow.js now strips inter-module requires when
  inlining, and a new test syntax-checks every generated jsCode string
  plus asserts no leftover require() calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-24 15:31:44 -03:00
co-authored by Claude Sonnet 5
parent ec596a29a8
commit a99a25cb1a
24 changed files with 1818 additions and 76 deletions
+2
View File
@@ -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 } }];
+8
View File
@@ -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 } }];
+9
View File
@@ -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 } }];
+33
View File
@@ -0,0 +1,33 @@
// 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' };
});
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 }),
mensagem: digestData.mensagem,
digest: digestData.digest,
},
}];
+14
View File
@@ -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 } };
});
+11
View File
@@ -0,0 +1,11 @@
const rows = $input.all().map((item) => item.json);
rows.sort((a, b) => Number(b.run_at || 0) - Number(a.run_at || 0));
const latest = rows[0] || null;
const painel = {
gerado_em: new Date().toISOString(),
ultima_execucao_shadow: latest,
total_execucoes_recentes: rows.length,
};
return [{ json: painel }];
+43
View File
@@ -0,0 +1,43 @@
// @requires config.js cache.js
const config = parseConfig($env);
const now = Date.now();
const items = $input.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;
const analysis = needsReanalysis({ memory, currentHash: item.contextHash, now });
return { ticket: item.ticket, contextHash: item.contextHash, 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,
memory: item.memory,
needsAiReason: item.needsAiReason,
resolucao,
precisa_ia: resolucao === 'ia',
config,
now,
},
};
});
+28
View File
@@ -0,0 +1,28 @@
// @requires 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 } };
});
+3
View File
@@ -0,0 +1,3 @@
// @requires context.js
const items = $input.all();
return items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));
+57
View File
@@ -0,0 +1,57 @@
// @requires 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.',
'Identifique: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos que justifique a conclusao?',
'Se a informacao for insuficiente para decidir com confianca, use a classificacao INSUFFICIENT_CONTEXT e confidence baixo -- nao invente uma acao.',
`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), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas).`,
].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 } };
});
+22
View File
@@ -0,0 +1,22 @@
const ONE_DAY = 24 * 60 * 60 * 1000;
return $input.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,27 @@
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),
last_context_hash: data.contextHash,
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,27 @@
// @requires fallback.js
return $input.all().map((item) => {
const data = item.json;
if (data.resolucao === 'cache' && data.memory) {
const m = data.memory;
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: [],
is_fallback: false,
fallback_reason: null,
},
},
};
}
return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } };
});
+24
View File
@@ -0,0 +1,24 @@
// @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,
aiClassification: data.aiClassification,
memory: data.memory,
contextHash: data.contextHash,
contextChanged: data.contextChanged,
config: data.config,
now: data.now,
}));
const config = scored.length ? scored[0].config : parseConfig($env);
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];
+25
View File
@@ -0,0 +1,25 @@
// @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;
}
const validation = validateAiClassification(raw);
const aiClassification = validation.valid ? validation.data : buildFallbackClassification('invalid_json');
return { json: { ...original, aiClassification } };
});