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:
co-authored by
Claude Sonnet 5
parent
ec596a29a8
commit
a99a25cb1a
@@ -1248,8 +1248,7 @@ git commit -m "feat(v2): add short message and coordination digest builders"
|
||||
|
||||
**Files:**
|
||||
- Create: `sql/candidatos_v2_glpi.sql`
|
||||
- Create: `sql/contexto_followups_glpi.sql`
|
||||
- Create: `sql/contexto_tarefas_glpi.sql`
|
||||
- Create: `sql/contexto_glpi.sql`
|
||||
- Create: `n8n/nodes/build-sql-candidatos.node.js`
|
||||
- Create: `n8n/nodes/build-sql-legado.node.js`
|
||||
- Test: `n8n/nodes/build-sql-candidatos.node.test.js`
|
||||
@@ -1344,26 +1343,24 @@ ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias
|
||||
LIMIT {CANDIDATE_POOL_SIZE};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Criar `sql/contexto_followups_glpi.sql` e `sql/contexto_tarefas_glpi.sql`**
|
||||
|
||||
`sql/contexto_followups_glpi.sql`:
|
||||
- [ ] **Step 2: Criar `sql/contexto_glpi.sql`** — followups e tarefas numa unica query (`UNION ALL`, marcada por `tipo`), para que o node MySQL de contexto seja um unico ancestral direto de `Code - Mesclar Contexto e Calcular Hash` (evita depender da ordem relativa de dois nodes MySQL irmaos sem sincronizacao garantida pelo n8n).
|
||||
|
||||
```sql
|
||||
-- Followups publicos dos candidatos (IDs interpolados pelo node, validados como inteiros).
|
||||
SELECT items_id AS tickets_id, id, date, content AS texto
|
||||
-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query
|
||||
-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.
|
||||
-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a
|
||||
-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.
|
||||
|
||||
SELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status
|
||||
FROM glpi_itilfollowups
|
||||
WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})
|
||||
ORDER BY items_id, date ASC;
|
||||
```
|
||||
|
||||
`sql/contexto_tarefas_glpi.sql`:
|
||||
UNION ALL
|
||||
|
||||
```sql
|
||||
-- Tarefas dos candidatos. Coluna de status a confirmar via DESCRIBE glpi_tickettasks
|
||||
-- quando a credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.
|
||||
SELECT tickets_id, id, date, state AS status, content AS texto
|
||||
SELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status
|
||||
FROM glpi_tickettasks
|
||||
WHERE tickets_id IN ({IDS})
|
||||
|
||||
ORDER BY tickets_id, date ASC;
|
||||
```
|
||||
|
||||
@@ -1439,7 +1436,7 @@ Expected: PASS
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add sql/candidatos_v2_glpi.sql sql/contexto_followups_glpi.sql sql/contexto_tarefas_glpi.sql n8n/nodes/build-sql-candidatos.node.js n8n/nodes/build-sql-legado.node.js n8n/nodes/build-sql-candidatos.node.test.js
|
||||
git add sql/candidatos_v2_glpi.sql sql/contexto_glpi.sql n8n/nodes/build-sql-candidatos.node.js n8n/nodes/build-sql-legado.node.js n8n/nodes/build-sql-candidatos.node.test.js
|
||||
git commit -m "feat(v2): candidate pool SQL (admin exclusion preserved, no hard LIMIT 10) + verbatim legacy SQL for shadow mode"
|
||||
```
|
||||
|
||||
@@ -1536,8 +1533,7 @@ function buildDriverCode(driverRelativePath) {
|
||||
|
||||
driver = driver.replace('/* __SQL_CANDIDATOS_V2__ */', sqlAsJsTemplateLiteral('sql/candidatos_v2_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_LEGADO_V1_VERBATIM__ */', sqlAsJsTemplateLiteral('sql/top_10_chamados_abertos_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_CONTEXTO_FOLLOWUPS__ */', sqlAsJsTemplateLiteral('sql/contexto_followups_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_CONTEXTO_TAREFAS__ */', sqlAsJsTemplateLiteral('sql/contexto_tarefas_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_CONTEXTO__ */', sqlAsJsTemplateLiteral('sql/contexto_glpi.sql'));
|
||||
|
||||
const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : '';
|
||||
return `${logicBlock}${driver.trim()}`;
|
||||
@@ -1600,6 +1596,8 @@ git commit -m "feat(v2): add build script that inlines tested logic/*.js into n8
|
||||
- Create: `n8n/nodes/build-coordination-digest.node.js`
|
||||
- Create: `n8n/nodes/compare-legacy-v2.node.js`
|
||||
- Create: `n8n/nodes/debug-panel.node.js`
|
||||
- Create: `n8n/nodes/prepare-memoria-analise-record.node.js` (prepara a linha para `Data Table - Upsert Memoria Analise`, ramo efeito-colateral)
|
||||
- Create: `n8n/nodes/prepare-envio-record.node.js` (prepara a linha para `Data Table - Registrar Envio`, filtra `_grupo==='selected'`, ramo efeito-colateral)
|
||||
|
||||
**Interfaces:**
|
||||
- Cada driver assume que as funções de `logic/*.js` listadas em `// @requires` já estão no escopo (inlined pelo build script da Task 12). Cada um consome `$input.all()`/`$input.first()` (shape n8n padrão) e os nodes anteriores via `$('Nome do Node')`, seguindo exatamente o padrão já usado no V1 e no workflow `[DEV] GTI - GLPI x WhatsApp v7`.
|
||||
@@ -1612,8 +1610,7 @@ Manual Trigger
|
||||
-> Code - Montar SQL Candidatos -> MySQL - Buscar Candidatos
|
||||
-> Code - Normalizar Candidatos Base
|
||||
-> Code - Montar SQL Contexto
|
||||
-> MySQL - Buscar Followups
|
||||
-> MySQL - Buscar Tarefas
|
||||
-> MySQL - Buscar Contexto (followups + tarefas numa unica query UNION ALL, marcada por `tipo` -- evita depender da ordem relativa de dois nodes MySQL irmaos)
|
||||
-> Code - Mesclar Contexto e Calcular Hash
|
||||
-> Data Table - Buscar Memoria (get, returnAll)
|
||||
-> Code - Decidir Necessidade de IA
|
||||
@@ -1622,16 +1619,20 @@ Manual Trigger
|
||||
[true] -> Code - Preparar Prompt IA -> HTTP - OpenAI Classificar Chamado -> Code - Validar Classificacao IA -> Merge - Consolidar Classificacoes (input 0)
|
||||
[false] -> Code - Fallback IA Desligada -> Merge - Consolidar Classificacoes (input 1)
|
||||
[false] -> Code - Reusar Classificacao em Cache -> Merge - Consolidar Classificacoes (input 2)
|
||||
Merge - Consolidar Classificacoes
|
||||
-> Data Table - Upsert Memoria Analise
|
||||
-> Code - Calcular Score
|
||||
-> Code - Selecionar Top 10 (Run Once for All Items)
|
||||
-> Data Table - Registrar Envio
|
||||
-> Code - Montar Mensagem Curta
|
||||
-> Code - Montar Digest Coordenacao
|
||||
-> Code - Comparar Legacy x V2
|
||||
-> Data Table - Registrar Shadow Run
|
||||
-> Code - Montar SQL Legado -> MySQL - Legado Top 10 (Shadow) [alimenta Code - Comparar Legacy x V2 via $('MySQL - Legado Top 10 (Shadow)').all(), mesmo padrao de leitura cruzada ja usado no V1/WhatsApp workflow]
|
||||
Merge - Consolidar Classificacoes (mode: append)
|
||||
-> Code - Calcular Score
|
||||
-> Code - Selecionar Top 10 (Run Once for All Items) [caminho principal]
|
||||
-> Code - Montar Mensagem Curta
|
||||
-> Code - Montar Digest Coordenacao
|
||||
-> Merge - Unir Shadow (append, input 0)
|
||||
-> Code - Preparar Registro Memoria Analise -> Data Table - Upsert Memoria Analise [ramo efeito-colateral, sem continuidade]
|
||||
(a partir de Code - Selecionar Top 10, tambem:)
|
||||
-> Code - Preparar Registro de Envio (filtra _grupo==='selected') -> Data Table - Registrar Envio [ramo efeito-colateral]
|
||||
-> Code - Montar SQL Legado -> MySQL - Legado Top 10 (Shadow) -> Merge - Unir Shadow (append, input 1)
|
||||
|
||||
Merge - Unir Shadow (append)
|
||||
-> Code - Comparar Legacy x V2 [le os dados por nome -- $('Code - Montar Digest Coordenacao').first() e $('MySQL - Legado Top 10 (Shadow)').all() -- o Merge so existe para GARANTIR que os dois ja executaram antes deste node rodar; ler pelo item mesclado seria fragil]
|
||||
-> Data Table - Registrar Shadow Run
|
||||
|
||||
Debug - Ver Painel (Webhook GET, path "coordenador-glpi-v2-debug")
|
||||
-> Data Table - Buscar Shadow Runs Recentes (get, returnAll, sortBy run_at:desc, limit implicito pelos ultimos N)
|
||||
@@ -1641,8 +1642,8 @@ Debug - Ver Painel (Webhook GET, path "coordenador-glpi-v2-debug")
|
||||
|
||||
- [ ] **Step 1: Para cada node de Code listado acima, criar o arquivo driver em `n8n/nodes/` seguindo o padrão dos já criados na Task 11** (comentário `// @requires <logic files>` no topo quando aplicável; corpo usa `$input`, `$('Nome do Node')`, `$env`, retorna `return [{ json: {...} }];` ou, para o de seleção final, roda uma vez para todos os itens: usar `$input.all()` e retornar múltiplos itens). Cada driver deve:
|
||||
- `Code - Normalizar Candidatos Base`: `@requires context.js` — mapeia `$input.all()` com `normalizeTicket(item.json, { followups: [], tasks: [] })`.
|
||||
- `Code - Montar SQL Contexto`: monta as duas queries de `sql/contexto_followups_glpi.sql`/`contexto_tarefas_glpi.sql` substituindo `{IDS}` pelos ids normalizados (`Number(id)`, `join(',')`, nunca concatenando string crua do GLPI — todos os ids já vêm como inteiro da query anterior).
|
||||
- `Code - Mesclar Contexto e Calcular Hash`: `@requires hash.js` — junta `$('Code - Normalizar Candidatos Base').all()` com `$('MySQL - Buscar Followups').all()` e `$input.all()` (tarefas), agrupa por `tickets_id`, chama `buildContextFingerprint`.
|
||||
- `Code - Montar SQL Contexto`: monta a query única de `sql/contexto_glpi.sql` substituindo `{IDS}` pelos ids normalizados (`Number(id)`, `join(',')`, nunca concatenando string crua do GLPI — todos os ids já vêm como inteiro da query anterior).
|
||||
- `Code - Mesclar Contexto e Calcular Hash`: `@requires context.js hash.js` — junta `$('Code - Normalizar Candidatos Base').all()` com `$input.all()` (linhas de `MySQL - Buscar Contexto`, separadas por `tipo==='followup'|'task'`), agrupa por `tickets_id`, chama `buildContextFingerprint`.
|
||||
- `Code - Decidir Necessidade de IA`: `@requires cache.js` — para cada candidato, busca a linha de memória correspondente (de `$('Data Table - Buscar Memoria').all()`) e chama `needsReanalysis`; ordena por `score_criticidade` e limita a `config.aiMaxCandidates` os que vão para IA; os excedentes recebem `precisa_ia:false` com motivo `ai_max_candidates_cap` e caem no branch de fallback determinístico (não ficam sem classificação).
|
||||
- `Code - Preparar Prompt IA`: `@requires context.js` — monta `system` (regras da seção 16 da missão: não inventar fatos, distinguir evidência de inferência, JSON estrito, SLA vencido sozinho não é evidência de acionabilidade, idade sozinha não é acionabilidade) e `user` = `buildAiContextText(ticket, config.aiMaxContextChars)`.
|
||||
- `Code - Validar Classificacao IA`: `@requires classification.js, fallback.js` — tenta `JSON.parse` da resposta; se falhar ou `validateAiClassification` retornar `valid:false`, usa `buildFallbackClassification('invalid_json')`; se o node HTTP anterior tiver `error` (timeout/HTTP≠2xx, capturado via `continueOnFail`), usa `buildFallbackClassification('ai_error')`.
|
||||
@@ -1656,7 +1657,7 @@ Debug - Ver Painel (Webhook GET, path "coordenador-glpi-v2-debug")
|
||||
- `Code - Montar Painel Debug`: monta um HTML/JSON simples com a última linha de `coordenador_glpi_shadow_runs` + contagem de memória (mesmo padrão do node `Debug - Montar Painel` do workflow `[DEV] GTI - GLPI x WhatsApp v7`).
|
||||
|
||||
- [ ] **Step 2: Adicionar ao `n8n/workflow.template.json` todos os nodes não-Code com seus `parameters` finais**:
|
||||
- `MySQL - Buscar Candidatos`, `MySQL - Buscar Followups`, `MySQL - Buscar Tarefas`, `MySQL - Legado Top 10 (Shadow)`: `type: n8n-nodes-base.mySql`, `parameters: { operation: 'executeQuery', query: "={{ $json.query }}" }`, `credentials: { mySql: { name: 'MySQL GLPI - Coordenador' } }` (sem `id` — resolvido na Task 14 quando o id real existir).
|
||||
- `MySQL - Buscar Candidatos`, `MySQL - Buscar Contexto`, `MySQL - Legado Top 10 (Shadow)`: `type: n8n-nodes-base.mySql`, `parameters: { operation: 'executeQuery', query: "={{ $json.query }}" }`, `credentials: { mySql: { name: 'MySQL GLPI - Coordenador' } }` (sem `id` — resolvido na Task 14 quando o id real existir).
|
||||
- `Data Table - Buscar Memoria`: `type: n8n-nodes-base.dataTable`, `parameters: { resource: 'row', operation: 'get', dataTableId: { mode: 'name', value: 'coordenador_glpi_memoria' }, returnAll: true }`.
|
||||
- `Data Table - Upsert Memoria Analise` e `Data Table - Registrar Envio`: `operation: 'upsert'` no mesmo `dataTableId`, campos mapeados via `columns` (resourceMapper — ver Task 14 para o shape exato, resolvido com `n8n_validate_workflow`/`n8n_autofix_workflow`).
|
||||
- `Data Table - Registrar Shadow Run` e `Data Table - Buscar Shadow Runs Recentes`: mesma lógica sobre `coordenador_glpi_shadow_runs`.
|
||||
@@ -1692,7 +1693,7 @@ git commit -m "feat(v2): complete n8n workflow template (25+ nodes) covering ful
|
||||
- [ ] **Step 1: Confirmar a credencial MySQL** — chamar `n8n_manage_credentials` (`action: list`) e localizar `MySQL GLPI - Coordenador`. Se ainda não existir, parar aqui e avisar o usuário (não simular/hardcodar). Se existir, anotar o `id`.
|
||||
|
||||
- [ ] **Step 2: Introspecção obrigatória do schema real (nunca assumir)** — com a credencial MySQL viva, rodar via node `mysql` temporário (`n8n_test_workflow` num workflow mínimo de 1 node, ou reaproveitando `MySQL - Buscar Candidatos` já criado) as queries:
|
||||
- `SHOW COLUMNS FROM glpi_tickettasks;` — confirmar se o campo de status é `state` (assumido no Task 11) ou outro nome; ajustar `sql/contexto_tarefas_glpi.sql` se necessário.
|
||||
- `SHOW COLUMNS FROM glpi_tickettasks;` — confirmar se o campo de status é `state` (assumido no Task 11) ou outro nome; ajustar `sql/contexto_glpi.sql` se necessário.
|
||||
- `SHOW COLUMNS FROM glpi_itilfollowups;` — confirmar `content`/`date`/`is_private` existem como assumido.
|
||||
- Rodar `sql/candidatos_v2_glpi.sql` (com `{CANDIDATE_POOL_SIZE}`→`5` para teste rápido) e conferir que retorna linhas plausíveis.
|
||||
- Registrar tudo (colunas confirmadas, ajustes feitos) em `docs/superpowers/notes/2026-08-24-schema-introspection.md`.
|
||||
|
||||
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,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,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,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,
|
||||
},
|
||||
}];
|
||||
@@ -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,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 }];
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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 } };
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// @requires context.js
|
||||
const items = $input.all();
|
||||
return items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));
|
||||
@@ -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 } };
|
||||
});
|
||||
@@ -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') } };
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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 } };
|
||||
});
|
||||
+469
-18
@@ -6,7 +6,7 @@
|
||||
"name": "Manual Trigger",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [180, 300],
|
||||
"position": [-1600, 0],
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [180, 640],
|
||||
"position": [-1600, 900],
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
@@ -28,7 +28,7 @@
|
||||
"name": "Config - Coordenador GLPI v2",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [440, 300],
|
||||
"position": [-1360, 0],
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
@@ -48,37 +48,488 @@
|
||||
"name": "Code - Montar SQL Candidatos",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [700, 220],
|
||||
"parameters": {
|
||||
"jsCode": "__BUILD__:build-sql-candidatos.node.js"
|
||||
}
|
||||
"position": [-1120, -160],
|
||||
"parameters": { "jsCode": "__BUILD__:build-sql-candidatos.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "code-montar-sql-legado",
|
||||
"name": "Code - Montar SQL Legado",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [700, 460],
|
||||
"position": [-1120, 900],
|
||||
"parameters": { "jsCode": "__BUILD__:build-sql-legado.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "mysql-buscar-candidatos",
|
||||
"name": "MySQL - Buscar Candidatos",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 2.4,
|
||||
"position": [-880, -160],
|
||||
"parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} },
|
||||
"credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } }
|
||||
},
|
||||
{
|
||||
"id": "mysql-legado-top10-shadow",
|
||||
"name": "MySQL - Legado Top 10 (Shadow)",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 2.4,
|
||||
"position": [-880, 900],
|
||||
"parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} },
|
||||
"credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } }
|
||||
},
|
||||
{
|
||||
"id": "code-normalizar-candidatos-base",
|
||||
"name": "Code - Normalizar Candidatos Base",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [-640, -160],
|
||||
"parameters": { "jsCode": "__BUILD__:normalize-candidates.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "code-montar-sql-contexto",
|
||||
"name": "Code - Montar SQL Contexto",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [-400, -160],
|
||||
"parameters": { "jsCode": "__BUILD__:build-sql-context.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "mysql-buscar-contexto",
|
||||
"name": "MySQL - Buscar Contexto",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 2.4,
|
||||
"position": [-160, -160],
|
||||
"parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} },
|
||||
"credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } }
|
||||
},
|
||||
{
|
||||
"id": "code-mesclar-contexto-hash",
|
||||
"name": "Code - Mesclar Contexto e Calcular Hash",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [80, -160],
|
||||
"parameters": { "jsCode": "__BUILD__:merge-context-hash.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "datatable-buscar-memoria",
|
||||
"name": "Data Table - Buscar Memoria",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1.1,
|
||||
"position": [320, -160],
|
||||
"parameters": {
|
||||
"jsCode": "__BUILD__:build-sql-legado.node.js"
|
||||
"resource": "row",
|
||||
"operation": "get",
|
||||
"dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" },
|
||||
"filters": { "conditions": [] },
|
||||
"returnAll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-decidir-necessidade-ia",
|
||||
"name": "Code - Decidir Necessidade de IA",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [560, -160],
|
||||
"parameters": { "jsCode": "__BUILD__:decide-needs-ai.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "if-precisa-ia",
|
||||
"name": "IF - Precisa IA?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.3,
|
||||
"position": [800, -160],
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
|
||||
"conditions": [
|
||||
{
|
||||
"id": "cond-precisa-ia",
|
||||
"leftValue": "={{ $json.precisa_ia }}",
|
||||
"rightValue": true,
|
||||
"operator": { "type": "boolean", "operation": "true", "singleValue": true }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "if-ia-ligada",
|
||||
"name": "IF - IA Ligada?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.3,
|
||||
"position": [1040, -320],
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
|
||||
"conditions": [
|
||||
{
|
||||
"id": "cond-ia-ligada",
|
||||
"leftValue": "={{ $json.config.aiEnabled }}",
|
||||
"rightValue": true,
|
||||
"operator": { "type": "boolean", "operation": "true", "singleValue": true }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-preparar-prompt-ia",
|
||||
"name": "Code - Preparar Prompt IA",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1280, -400],
|
||||
"parameters": { "jsCode": "__BUILD__:prepare-ai-prompt.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "http-openai-classificar",
|
||||
"name": "HTTP - OpenAI Classificar Chamado",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1520, -400],
|
||||
"continueOnFail": true,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"contentType": "json",
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.requestBody) }}",
|
||||
"options": { "timeout": 30000 }
|
||||
},
|
||||
"credentials": { "openAiApi": { "id": "WUGriAn5FgCiLza2", "name": "OpenAI - Coordenador GLPI" } }
|
||||
},
|
||||
{
|
||||
"id": "code-validar-classificacao-ia",
|
||||
"name": "Code - Validar Classificacao IA",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1760, -400],
|
||||
"parameters": { "jsCode": "__BUILD__:validate-ai-response.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "code-fallback-ia-desligada",
|
||||
"name": "Code - Fallback IA Desligada",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1280, -160],
|
||||
"parameters": { "jsCode": "__BUILD__:ai-disabled-fallback.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "code-reusar-classificacao-cache",
|
||||
"name": "Code - Reusar Classificacao em Cache",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1040, 80],
|
||||
"parameters": { "jsCode": "__BUILD__:reuse-cached-classification.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "merge-consolidar-classificacoes",
|
||||
"name": "Merge - Consolidar Classificacoes",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 3.2,
|
||||
"position": [2000, -160],
|
||||
"parameters": { "mode": "append", "numberInputs": 3 }
|
||||
},
|
||||
{
|
||||
"id": "code-calcular-score",
|
||||
"name": "Code - Calcular Score",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2240, -160],
|
||||
"parameters": { "jsCode": "__BUILD__:compute-score.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "code-preparar-registro-memoria-analise",
|
||||
"name": "Code - Preparar Registro Memoria Analise",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2480, 160],
|
||||
"parameters": { "jsCode": "__BUILD__:prepare-memoria-analise-record.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "datatable-upsert-memoria-analise",
|
||||
"name": "Data Table - Upsert Memoria Analise",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1.1,
|
||||
"position": [2720, 160],
|
||||
"parameters": {
|
||||
"resource": "row",
|
||||
"operation": "upsert",
|
||||
"dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" },
|
||||
"columns": {
|
||||
"mappingMode": "defineBelow",
|
||||
"matchingColumns": ["ticket_id"],
|
||||
"value": {
|
||||
"ticket_id": "={{ $json.ticket_id }}",
|
||||
"last_analyzed_at": "={{ $json.last_analyzed_at }}",
|
||||
"last_context_hash": "={{ $json.last_context_hash }}",
|
||||
"last_meaningful_change_at": "={{ $json.last_meaningful_change_at }}",
|
||||
"ai_classification": "={{ $json.ai_classification }}",
|
||||
"ai_confidence": "={{ $json.ai_confidence }}",
|
||||
"blocker_type": "={{ $json.blocker_type }}",
|
||||
"waiting_for": "={{ $json.waiting_for }}",
|
||||
"next_action": "={{ $json.next_action }}",
|
||||
"next_review_at": "={{ $json.next_review_at }}",
|
||||
"last_score": "={{ $json.last_score }}",
|
||||
"last_ai_reason": "={{ $json.last_ai_reason }}",
|
||||
"updated_at": "={{ $json.updated_at }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-selecionar-top10",
|
||||
"name": "Code - Selecionar Top 10",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2480, -320],
|
||||
"parameters": { "jsCode": "__BUILD__:select-top10.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "code-preparar-registro-envio",
|
||||
"name": "Code - Preparar Registro de Envio",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2720, -560],
|
||||
"parameters": { "jsCode": "__BUILD__:prepare-envio-record.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "datatable-registrar-envio",
|
||||
"name": "Data Table - Registrar Envio",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1.1,
|
||||
"position": [2960, -560],
|
||||
"parameters": {
|
||||
"resource": "row",
|
||||
"operation": "upsert",
|
||||
"dataTableId": { "mode": "name", "value": "coordenador_glpi_memoria" },
|
||||
"columns": {
|
||||
"mappingMode": "defineBelow",
|
||||
"matchingColumns": ["ticket_id"],
|
||||
"value": {
|
||||
"ticket_id": "={{ $json.ticket_id }}",
|
||||
"last_sent_at": "={{ $json.last_sent_at }}",
|
||||
"times_sent": "={{ $json.times_sent }}",
|
||||
"times_sent_30d": "={{ $json.times_sent_30d }}",
|
||||
"consecutive_days_sent": "={{ $json.consecutive_days_sent }}",
|
||||
"updated_at": "={{ $json.updated_at }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-montar-mensagem-curta",
|
||||
"name": "Code - Montar Mensagem Curta",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2720, -320],
|
||||
"parameters": { "jsCode": "__BUILD__:build-top-message.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "code-montar-digest-coordenacao",
|
||||
"name": "Code - Montar Digest Coordenacao",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2960, -320],
|
||||
"parameters": { "jsCode": "__BUILD__:build-coordination-digest.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "merge-unir-shadow",
|
||||
"name": "Merge - Unir Shadow",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 3.2,
|
||||
"position": [3200, 160],
|
||||
"parameters": { "mode": "append", "numberInputs": 2 }
|
||||
},
|
||||
{
|
||||
"id": "code-comparar-legacy-v2",
|
||||
"name": "Code - Comparar Legacy x V2",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [3440, 160],
|
||||
"parameters": { "jsCode": "__BUILD__:compare-legacy-v2.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "datatable-registrar-shadow-run",
|
||||
"name": "Data Table - Registrar Shadow Run",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1.1,
|
||||
"position": [3680, 160],
|
||||
"parameters": {
|
||||
"resource": "row",
|
||||
"operation": "insert",
|
||||
"dataTableId": { "mode": "name", "value": "coordenador_glpi_shadow_runs" },
|
||||
"columns": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"run_at": "={{ $json.run_at }}",
|
||||
"legacy_ids": "={{ $json.legacy_ids }}",
|
||||
"v2_ids": "={{ $json.v2_ids }}",
|
||||
"added": "={{ $json.added }}",
|
||||
"removed": "={{ $json.removed }}",
|
||||
"summary": "={{ $json.summary }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "debug-ver-painel",
|
||||
"name": "Debug - Ver Painel",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [-1600, 1200],
|
||||
"parameters": {
|
||||
"httpMethod": "GET",
|
||||
"path": "coordenador-glpi-v2-debug",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "datatable-buscar-shadow-runs-recentes",
|
||||
"name": "Data Table - Buscar Shadow Runs Recentes",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1.1,
|
||||
"position": [-1360, 1200],
|
||||
"parameters": {
|
||||
"resource": "row",
|
||||
"operation": "get",
|
||||
"dataTableId": { "mode": "name", "value": "coordenador_glpi_shadow_runs" },
|
||||
"filters": { "conditions": [] },
|
||||
"returnAll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-montar-painel-debug",
|
||||
"name": "Code - Montar Painel Debug",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [-1120, 1200],
|
||||
"parameters": { "jsCode": "__BUILD__:debug-panel.node.js" }
|
||||
},
|
||||
{
|
||||
"id": "debug-responder",
|
||||
"name": "Debug - Responder",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.5,
|
||||
"position": [-880, 1200],
|
||||
"parameters": { "respondWith": "json", "responseBody": "={{ $json }}", "options": {} }
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Manual Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{ "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 }
|
||||
]
|
||||
]
|
||||
"main": [[{ "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Config - Coordenador GLPI v2": {
|
||||
"main": [[
|
||||
{ "node": "Code - Montar SQL Candidatos", "type": "main", "index": 0 },
|
||||
{ "node": "Code - Montar SQL Legado", "type": "main", "index": 0 }
|
||||
]]
|
||||
},
|
||||
"Code - Montar SQL Candidatos": {
|
||||
"main": [[{ "node": "MySQL - Buscar Candidatos", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Montar SQL Legado": {
|
||||
"main": [[{ "node": "MySQL - Legado Top 10 (Shadow)", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"MySQL - Buscar Candidatos": {
|
||||
"main": [[{ "node": "Code - Normalizar Candidatos Base", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Normalizar Candidatos Base": {
|
||||
"main": [[{ "node": "Code - Montar SQL Contexto", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Montar SQL Contexto": {
|
||||
"main": [[{ "node": "MySQL - Buscar Contexto", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"MySQL - Buscar Contexto": {
|
||||
"main": [[{ "node": "Code - Mesclar Contexto e Calcular Hash", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Mesclar Contexto e Calcular Hash": {
|
||||
"main": [[{ "node": "Data Table - Buscar Memoria", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Data Table - Buscar Memoria": {
|
||||
"main": [[{ "node": "Code - Decidir Necessidade de IA", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Decidir Necessidade de IA": {
|
||||
"main": [[{ "node": "IF - Precisa IA?", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"IF - Precisa IA?": {
|
||||
"main": [
|
||||
[
|
||||
{ "node": "Code - Montar SQL Candidatos", "type": "main", "index": 0 },
|
||||
{ "node": "Code - Montar SQL Legado", "type": "main", "index": 0 }
|
||||
]
|
||||
[{ "node": "IF - IA Ligada?", "type": "main", "index": 0 }],
|
||||
[{ "node": "Code - Reusar Classificacao em Cache", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"IF - IA Ligada?": {
|
||||
"main": [
|
||||
[{ "node": "Code - Preparar Prompt IA", "type": "main", "index": 0 }],
|
||||
[{ "node": "Code - Fallback IA Desligada", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"Code - Preparar Prompt IA": {
|
||||
"main": [[{ "node": "HTTP - OpenAI Classificar Chamado", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"HTTP - OpenAI Classificar Chamado": {
|
||||
"main": [[{ "node": "Code - Validar Classificacao IA", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Validar Classificacao IA": {
|
||||
"main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Fallback IA Desligada": {
|
||||
"main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 1 }]]
|
||||
},
|
||||
"Code - Reusar Classificacao em Cache": {
|
||||
"main": [[{ "node": "Merge - Consolidar Classificacoes", "type": "main", "index": 2 }]]
|
||||
},
|
||||
"Merge - Consolidar Classificacoes": {
|
||||
"main": [[{ "node": "Code - Calcular Score", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Calcular Score": {
|
||||
"main": [[
|
||||
{ "node": "Code - Selecionar Top 10", "type": "main", "index": 0 },
|
||||
{ "node": "Code - Preparar Registro Memoria Analise", "type": "main", "index": 0 }
|
||||
]]
|
||||
},
|
||||
"Code - Preparar Registro Memoria Analise": {
|
||||
"main": [[{ "node": "Data Table - Upsert Memoria Analise", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Selecionar Top 10": {
|
||||
"main": [[
|
||||
{ "node": "Code - Montar Mensagem Curta", "type": "main", "index": 0 },
|
||||
{ "node": "Code - Preparar Registro de Envio", "type": "main", "index": 0 }
|
||||
]]
|
||||
},
|
||||
"Code - Preparar Registro de Envio": {
|
||||
"main": [[{ "node": "Data Table - Registrar Envio", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Montar Mensagem Curta": {
|
||||
"main": [[{ "node": "Code - Montar Digest Coordenacao", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Montar Digest Coordenacao": {
|
||||
"main": [[{ "node": "Merge - Unir Shadow", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"MySQL - Legado Top 10 (Shadow)": {
|
||||
"main": [[{ "node": "Merge - Unir Shadow", "type": "main", "index": 1 }]]
|
||||
},
|
||||
"Merge - Unir Shadow": {
|
||||
"main": [[{ "node": "Code - Comparar Legacy x V2", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Comparar Legacy x V2": {
|
||||
"main": [[{ "node": "Data Table - Registrar Shadow Run", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Debug - Ver Painel": {
|
||||
"main": [[{ "node": "Data Table - Buscar Shadow Runs Recentes", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Data Table - Buscar Shadow Runs Recentes": {
|
||||
"main": [[{ "node": "Code - Montar Painel Debug", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Code - Montar Painel Debug": {
|
||||
"main": [[{ "node": "Debug - Responder", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Followups publicos dos candidatos (IDs interpolados pelo node, validados como inteiros).
|
||||
SELECT items_id AS tickets_id, id, date, content AS texto
|
||||
FROM glpi_itilfollowups
|
||||
WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})
|
||||
ORDER BY items_id, date ASC;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query
|
||||
-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.
|
||||
-- IDs interpolados pelo node como lista de inteiros (nunca string crua do GLPI).
|
||||
-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a
|
||||
-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.
|
||||
|
||||
SELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status
|
||||
FROM glpi_itilfollowups
|
||||
WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status
|
||||
FROM glpi_tickettasks
|
||||
WHERE tickets_id IN ({IDS})
|
||||
|
||||
ORDER BY tickets_id, date ASC;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Tarefas dos candidatos. Coluna de status a confirmar via DESCRIBE glpi_tickettasks
|
||||
-- quando a credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.
|
||||
SELECT tickets_id, id, date, state AS status, content AS texto
|
||||
FROM glpi_tickettasks
|
||||
WHERE tickets_id IN ({IDS})
|
||||
ORDER BY tickets_id, date ASC;
|
||||
@@ -5,8 +5,13 @@ const ROOT = path.join(__dirname, '..');
|
||||
|
||||
function readLogicSource(fileName) {
|
||||
const raw = fs.readFileSync(path.join(ROOT, 'logic', fileName), 'utf8');
|
||||
// Remove a linha final "module.exports = {...}" -- as funcoes ficam soltas no escopo do node.
|
||||
return raw.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '').trim();
|
||||
return raw
|
||||
// Remove a linha final "module.exports = {...}" -- as funcoes ficam soltas no escopo do node.
|
||||
.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '')
|
||||
// Remove requires de outros modulos de logic/*.js -- quando inlined, a funcao requerida
|
||||
// ja esta no mesmo escopo (senao vira identificador duplicado: function X + const {X} = require(...)).
|
||||
.replace(/^const\s*\{[^}]*\}\s*=\s*require\(['"]\.\/[a-zA-Z0-9_-]+['"]\);?\s*$/gm, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlinedLogic(fileNames) {
|
||||
@@ -29,8 +34,7 @@ function buildDriverCode(driverRelativePath) {
|
||||
|
||||
driver = driver.replace('/* __SQL_CANDIDATOS_V2__ */', sqlAsJsTemplateLiteral('sql/candidatos_v2_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_LEGADO_V1_VERBATIM__ */', sqlAsJsTemplateLiteral('sql/top_10_chamados_abertos_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_CONTEXTO_FOLLOWUPS__ */', sqlAsJsTemplateLiteral('sql/contexto_followups_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_CONTEXTO_TAREFAS__ */', sqlAsJsTemplateLiteral('sql/contexto_tarefas_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_CONTEXTO__ */', sqlAsJsTemplateLiteral('sql/contexto_glpi.sql'));
|
||||
|
||||
const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : '';
|
||||
return `${logicBlock}${driver.trim()}`;
|
||||
|
||||
@@ -26,3 +26,24 @@ test('nenhum node de Code sobra com marcador nao resolvido', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('todo jsCode gerado e sintaticamente valido (pega identificador duplicado, chaves erradas, etc.)', () => {
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type === 'n8n-nodes-base.code') {
|
||||
assert.doesNotThrow(() => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('$input', '$env', '$', node.parameters.jsCode);
|
||||
}, `node ${node.name} tem jsCode com erro de sintaxe`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('nenhum jsCode gerado contem "require(" sobrando (modulos devem estar totalmente inlined)', () => {
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type === 'n8n-nodes-base.code') {
|
||||
assert.ok(!/\brequire\(/.test(node.parameters.jsCode), `node ${node.name} ainda tem require() nao resolvido`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user