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', ]; // Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de // validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO // chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao. const AI_CLASSIFIER_VERSION = 3; // 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; } // next_action generico nao prova acionabilidade nenhuma -- ver missao "finalizacao para // producao", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas // classes o next_action E a evidencia de que existe um proximo passo concreto sob controle // do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma // frase curta ("aguardar resposta") e legitima ali. const ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']); const GENERIC_NEXT_ACTION_PHRASES = new Set([ 'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado', 'analisar situacao', 'analisar a situacao', 'verificar chamado', 'verificar o chamado', 'acompanhar', 'acompanhar chamado', 'acompanhar o chamado', ]); function normalizeForComparison(text) { return String(text || '') .trim() .toLowerCase() .normalize('NFD') .replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar "situação" == "situacao" .replace(/[.!]+$/g, ''); } function isGenericNextAction(nextAction) { return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction)); } // Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca // pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao "finalizacao para // producao", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui. function isWaitingForInternalTechnician(waitingFor, ticket) { if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false; const tecnico = normalizeForComparison(ticket.tecnico_atribuido); const alvo = normalizeForComparison(waitingFor); if (!alvo || tecnico === 'sem tecnico atribuido') return false; return tecnico.includes(alvo); } function validateAiClassification(raw, ticket) { const errors = []; if (!raw || typeof raw !== 'object') { return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] }; } if (!VALID_CLASSIFICATIONS.includes(raw.classification)) { errors.push(`classification invalida: ${raw.classification}`); } if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean'); const confidence = Number(raw.confidence); if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1'); if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) { errors.push('next_action invalido (vazio ou muito longo)'); } else if ( ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification) && isGenericNextAction(raw.next_action) ) { errors.push(`next_action generico demais para ${raw.classification}: "${raw.next_action}"`); } if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array'); if (typeof raw.reason !== 'string') errors.push('reason deve ser string'); const waitingFor = normalizeNullableString(raw.waiting_for); if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) { errors.push(`waiting_for ("${waitingFor}") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`); } if (errors.length) return { valid: false, data: null, errors }; return { valid: true, errors: [], data: { classification: raw.classification, actionable_today: raw.actionable_today, confidence, blocker_type: normalizeNullableString(raw.blocker_type), waiting_for: waitingFor, next_action: raw.next_action, suggested_action_type: raw.suggested_action_type ?? 'technical_action', business_risk: raw.business_risk ?? 'medium', reason: raw.reason, evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [], is_fallback: false, fallback_reason: null, classifier_version: AI_CLASSIFIER_VERSION, }, }; } module.exports = { VALID_CLASSIFICATIONS, AI_CLASSIFIER_VERSION, validateAiClassification, normalizeNullableString, isGenericNextAction, isWaitingForInternalTechnician, };