fix(v2): fix cache is_fallback bug, wire observability + data quality, drop debug webhook, fix GLPI URL
- Real bug found by code review while implementing this cycle: "Code - Reusar Classificacao em Cache" always hardcoded is_fallback: false when reconstructing a classification from memory, even when the cached classification was itself AI_FALLBACK. On a cache hit for a previously fallback-classified ticket, this would have let it skip applyFallbackPolicy's exclusion entirely (the policy checks aiClassification.is_fallback first). Fixed to derive is_fallback from the cached ai_classification value. - select-top10.node.js now carries `resolucao` (ia | cache | fallback_cap) through to the scored item, previously dropped -- needed to tell cache hits apart from real AI calls in observability. - Code - Comparar Legacy x V2 now computes the full observability summary (candidate_count, cache_hits/misses, ai_requested/success/errors, ai_fallback_count and by-reason breakdown, classification_counts, insufficient_context_count, actionable_real_count, critical_override_count, top10_count, blocked_count, coordination_count) and the data-quality review (buildDataQualityReview over INSUFFICIENT_CONTEXT tickets), both stored in the shadow_runs `summary` JSON column -- no new Data Table needed. - Removed the debug webhook entirely (Debug - Ver Painel, Data Table - Buscar Shadow Runs Recentes debug branch, Code - Montar Painel Debug, Debug - Responder) and the now-unused debug-panel.node.js driver. Workflow went from 37 to 33 nodes. Debugging goes through n8n execution data, the Data Tables, and the shadow diff instead, as requested. - Fixed glpi_base_url: was still V1's own placeholder (https://glpi.suaempresa.com.br, never a real value, not report anonymization) -- confirmed the real domain (https://glpi.gtigrupo.com.br) from the live, active GLPI x WhatsApp workflow's own GLPI API config in the same n8n instance. SQL still only ever receives it through {GLPI_BASE_URL} substitution from the Config node, never hardcoded. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
485234dc88
commit
3c87ab55e1
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
||||
// @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.
|
||||
@@ -19,6 +20,55 @@ const removedReasons = removed.map((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(),
|
||||
@@ -26,7 +76,7 @@ return [{
|
||||
v2_ids: JSON.stringify(v2Ids),
|
||||
added: JSON.stringify(added),
|
||||
removed: JSON.stringify(removed),
|
||||
summary: JSON.stringify({ addedReasons, removedReasons }),
|
||||
summary: JSON.stringify({ addedReasons, removedReasons, observability, dataQualityReview }),
|
||||
mensagem: digestData.mensagem,
|
||||
digest: digestData.digest,
|
||||
},
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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 }];
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -17,8 +18,11 @@ return $input.all().map((item) => {
|
||||
business_risk: 'medium',
|
||||
reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.',
|
||||
evidence: [],
|
||||
is_fallback: false,
|
||||
fallback_reason: null,
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,10 +7,15 @@ const scored = items.map((data) => ({
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{ "id": "glpi-base-url-v2", "name": "glpi_base_url", "type": "string", "value": "https://glpi.suaempresa.com.br" },
|
||||
{ "id": "glpi-base-url-v2", "name": "glpi_base_url", "type": "string", "value": "https://glpi.gtigrupo.com.br" },
|
||||
{ "id": "cfg-ai-enabled", "name": "AI_ENABLED", "type": "boolean", "value": true },
|
||||
{ "id": "cfg-ai-provider", "name": "AI_PROVIDER", "type": "string", "value": "openai" },
|
||||
{ "id": "cfg-ai-model", "name": "AI_MODEL", "type": "string", "value": "gpt-4o-mini" },
|
||||
@@ -400,49 +400,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
@@ -551,15 +508,6 @@
|
||||
},
|
||||
"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": {},
|
||||
|
||||
@@ -47,3 +47,30 @@ test('nenhum jsCode gerado contem "require(" sobrando (modulos devem estar total
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow gerado NAO tem nenhum node de webhook de debug', () => {
|
||||
const wf = buildWorkflow();
|
||||
const names = wf.nodes.map((n) => n.name);
|
||||
assert.ok(!names.includes('Debug - Ver Painel'));
|
||||
assert.ok(!names.includes('Debug - Responder'));
|
||||
assert.ok(!names.includes('Code - Montar Painel Debug'));
|
||||
const webhookNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.webhook');
|
||||
assert.equal(webhookNodes.length, 0, 'nenhum node webhook deveria sobrar no workflow gerado');
|
||||
});
|
||||
|
||||
test('glpi_base_url no Config nao e mais o placeholder do V1', () => {
|
||||
const wf = buildWorkflow();
|
||||
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
|
||||
const glpiField = configNode.parameters.assignments.assignments.find((a) => a.name === 'glpi_base_url');
|
||||
assert.notEqual(glpiField.value, 'https://glpi.suaempresa.com.br');
|
||||
assert.equal(glpiField.value, 'https://glpi.gtigrupo.com.br');
|
||||
});
|
||||
|
||||
test('link_glpi na query de candidatos usa a variavel de config, nao um dominio fixo no SQL', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
|
||||
assert.ok(sql.includes("CONCAT('{GLPI_BASE_URL}'"), 'o SQL deve continuar usando o placeholder substituido pelo node, nunca um dominio hardcoded');
|
||||
assert.ok(!sql.includes('suaempresa.com.br'));
|
||||
assert.ok(!sql.includes('gtigrupo.com.br'), 'o dominio real deve vir so da config, nunca hardcoded no SQL');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user