From 00a42db269b44342b3436a33427529f002b794a5 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 16:49:22 -0300 Subject: [PATCH] fix(v2): AI_FALLBACK replaces the ACTIONABLE_NOW-with-confidence-0 lie, normalize null-like strings Fallback (ai_disabled, ai_error, invalid_json, ai_max_candidates_cap -- any candidate the AI never actually analyzed) now produces classification 'AI_FALLBACK', an explicit technical state that is never part of the 11-class enum sent to OpenAI (the AI itself can never legitimately return it). Previously it silently claimed ACTIONABLE_NOW with confidence 0, which made unanalyzed tickets indistinguishable from genuinely confirmed ones at the classification-label level. Also normalizes blocker_type/waiting_for when the model returns the literal string "null"/"none"/"n/a"/"" instead of JSON null -- done once, in the validation layer (logic/classification.js), not scattered around the workflow. Co-Authored-By: Claude Sonnet 5 --- logic/classification.js | 18 +++++++++-- logic/classification.test.js | 23 ++++++++++++++ logic/fallback.js | 15 +++++++--- logic/fallback.test.js | 33 +++++++++++++++++++-- n8n/nodes/build-sql-candidatos.node.test.js | 5 ++++ sql/candidatos_v2_glpi.sql | 4 +-- 6 files changed, 87 insertions(+), 11 deletions(-) diff --git a/logic/classification.js b/logic/classification.js index 4b565b8..6592257 100644 --- a/logic/classification.js +++ b/logic/classification.js @@ -4,6 +4,18 @@ const VALID_CLASSIFICATIONS = [ 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT', ]; +// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes +// devolve a STRING "null" (ou variantes equivalentes) em vez do valor JSON null. Normaliza +// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA. +const NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']); + +function normalizeNullableString(value) { + if (value === null || value === undefined) return null; + const trimmed = String(value).trim(); + if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null; + return trimmed; +} + function validateAiClassification(raw) { const errors = []; if (!raw || typeof raw !== 'object') { @@ -28,8 +40,8 @@ function validateAiClassification(raw) { classification: raw.classification, actionable_today: raw.actionable_today, confidence, - blocker_type: raw.blocker_type ?? null, - waiting_for: raw.waiting_for ?? null, + blocker_type: normalizeNullableString(raw.blocker_type), + waiting_for: normalizeNullableString(raw.waiting_for), next_action: raw.next_action, suggested_action_type: raw.suggested_action_type ?? 'technical_action', business_risk: raw.business_risk ?? 'medium', @@ -41,4 +53,4 @@ function validateAiClassification(raw) { }; } -module.exports = { VALID_CLASSIFICATIONS, validateAiClassification }; +module.exports = { VALID_CLASSIFICATIONS, validateAiClassification, normalizeNullableString }; diff --git a/logic/classification.test.js b/logic/classification.test.js index 004e57b..0dcc474 100644 --- a/logic/classification.test.js +++ b/logic/classification.test.js @@ -52,3 +52,26 @@ test('todas as classes do dominio sao aceitas', () => { assert.equal(result.valid, true, `classe ${classification} deveria ser valida`); } }); + +test('AI_FALLBACK nao esta no dominio de classes que a IA pode retornar', () => { + assert.ok(!VALID_CLASSIFICATIONS.includes('AI_FALLBACK')); +}); + +test('blocker_type/waiting_for como string "null" sao normalizados para null de verdade', () => { + const result = validateAiClassification(validRaw({ blocker_type: 'null', waiting_for: 'None' })); + assert.equal(result.valid, true); + assert.equal(result.data.blocker_type, null); + assert.equal(result.data.waiting_for, null); +}); + +test('blocker_type/waiting_for como "n/a" ou string vazia tambem viram null', () => { + const result = validateAiClassification(validRaw({ blocker_type: 'N/A', waiting_for: '' })); + assert.equal(result.data.blocker_type, null); + assert.equal(result.data.waiting_for, null); +}); + +test('blocker_type/waiting_for com valor real nao sao afetados pela normalizacao', () => { + const result = validateAiClassification(validRaw({ blocker_type: 'fornecedor', waiting_for: 'Patricia' })); + assert.equal(result.data.blocker_type, 'fornecedor'); + assert.equal(result.data.waiting_for, 'Patricia'); +}); diff --git a/logic/fallback.js b/logic/fallback.js index 19983a1..cdff40d 100644 --- a/logic/fallback.js +++ b/logic/fallback.js @@ -1,11 +1,18 @@ +// AI_FALLBACK e um estado tecnico: significa "a IA nunca analisou este chamado" (erro, +// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e +// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar +// (ver logic/classification.js). Politica de elegibilidade fica centralizada em +// logic/cooldown.js (applyFallbackPolicy), nao aqui. +const AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK'; + function buildFallbackClassification(reason) { return { - classification: 'ACTIONABLE_NOW', - actionable_today: true, + classification: AI_FALLBACK_CLASSIFICATION, + actionable_today: false, confidence: 0, blocker_type: null, waiting_for: null, - next_action: 'Revisar chamado manualmente', + next_action: 'Revisar chamado manualmente (nao analisado pela IA)', suggested_action_type: 'manual_review', business_risk: 'medium', reason: `Fallback deterministico: ${reason}`, @@ -15,4 +22,4 @@ function buildFallbackClassification(reason) { }; } -module.exports = { buildFallbackClassification }; +module.exports = { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION }; diff --git a/logic/fallback.test.js b/logic/fallback.test.js index 154862a..7e1fe11 100644 --- a/logic/fallback.test.js +++ b/logic/fallback.test.js @@ -1,12 +1,41 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { buildFallbackClassification } = require('./fallback'); +const { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION } = require('./fallback'); +const { VALID_CLASSIFICATIONS } = require('./classification'); test('fallback e sempre seguro e marcado como tal', () => { const result = buildFallbackClassification('ai_timeout'); assert.equal(result.is_fallback, true); assert.equal(result.fallback_reason, 'ai_timeout'); assert.equal(result.confidence, 0); - assert.equal(result.classification, 'ACTIONABLE_NOW'); assert.ok(result.reason.includes('Fallback')); }); + +test('fallback NUNCA finge ser ACTIONABLE_NOW -- usa AI_FALLBACK, um estado tecnico explicito', () => { + const result = buildFallbackClassification('ai_error'); + assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION); + assert.notEqual(result.classification, 'ACTIONABLE_NOW'); + assert.equal(result.actionable_today, false); +}); + +test('AI_FALLBACK nunca e uma classe que a propria IA pode retornar (fica fora do schema enviado a OpenAI)', () => { + assert.ok(!VALID_CLASSIFICATIONS.includes(AI_FALLBACK_CLASSIFICATION)); +}); + +test('AI_MAX_CANDIDATES gera AI_FALLBACK, nao ACTIONABLE_NOW', () => { + const result = buildFallbackClassification('ai_max_candidates_cap'); + assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION); + assert.equal(result.fallback_reason, 'ai_max_candidates_cap'); +}); + +test('erro de IA gera AI_FALLBACK', () => { + assert.equal(buildFallbackClassification('ai_error').classification, AI_FALLBACK_CLASSIFICATION); +}); + +test('IA desligada gera AI_FALLBACK', () => { + assert.equal(buildFallbackClassification('ai_disabled').classification, AI_FALLBACK_CLASSIFICATION); +}); + +test('JSON invalido gera AI_FALLBACK', () => { + assert.equal(buildFallbackClassification('invalid_json').classification, AI_FALLBACK_CLASSIFICATION); +}); diff --git a/n8n/nodes/build-sql-candidatos.node.test.js b/n8n/nodes/build-sql-candidatos.node.test.js index c851f37..9890291 100644 --- a/n8n/nodes/build-sql-candidatos.node.test.js +++ b/n8n/nodes/build-sql-candidatos.node.test.js @@ -17,3 +17,8 @@ test('SQL de candidatos V2 NAO tem mais LIMIT fixo em 10 (funil maior)', () => { 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')); +}); diff --git a/sql/candidatos_v2_glpi.sql b/sql/candidatos_v2_glpi.sql index 7735022..ccccc52 100644 --- a/sql/candidatos_v2_glpi.sql +++ b/sql/candidatos_v2_glpi.sql @@ -36,7 +36,7 @@ WITH ultima_interacao AS ( GROUP BY tu.tickets_id ), chamados_base AS ( SELECT - t.id, t.name AS titulo, t.status, + t.id, t.name AS titulo, t.content AS descricao, t.status, CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome, t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate, t.priority, t.urgency, t.impact, @@ -63,7 +63,7 @@ WITH ultima_interacao AS ( AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%' ), chamados_ranked AS ( SELECT - id, titulo, status, status_nome, aberto_em, modificado_em, ultima_interacao_em, + id, titulo, descricao, status, status_nome, aberto_em, modificado_em, ultima_interacao_em, dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco, priority AS prioridade, urgency AS urgencia, impact AS impacto, entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,