'use strict'; /** * Validacao do structured output da IA, fingerprint estavel e transicao de * estado da memoria de insights (secoes 5 a 15 da missao). * * Principio: "SQL conta. IA entende." Este modulo NUNCA aceita numeros da * IA como fato — ele so valida forma, evidencia e confianca, e decide o * que pode ser exibido. */ const ALLOWED_TYPES = [ 'NORMAL', 'ATTENTION', 'ANOMALY', 'RECURRENCE', 'SYSTEMIC_PATTERN', 'CLIENT_CONCENTRATION', 'CATEGORY_SPIKE', 'BACKLOG_TREND', 'SLA_RISK', 'POSSIBLE_PREVENTABLE_DEMAND', ]; const ALLOWED_SEVERITIES = ['low', 'medium', 'high']; const SUBJECT_PREFIX_BY_TYPE = { CLIENT_CONCENTRATION: 'cliente', CATEGORY_SPIKE: 'categoria', RECURRENCE: 'tema', SYSTEMIC_PATTERN: 'sistemico', POSSIBLE_PREVENTABLE_DEMAND: 'tema', SLA_RISK: 'tema', BACKLOG_TREND: 'tema', ANOMALY: 'tema', ATTENTION: 'tema', NORMAL: 'tema', }; const COMBINING_DIACRITICS = new RegExp('[\\u0300-\\u036f]', 'g'); function slugify(texto) { return String(texto || '') .normalize('NFD') .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD) .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 60) || 'geral'; } /** * Normaliza subject_key para o formato ":". * Se a IA nao mandar um namespace valido, o codigo infere pelo type. */ function normalizeSubjectKey(rawSubjectKey, type) { const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema'; const texto = String(rawSubjectKey || '').trim(); const match = texto.match(/^([a-zA-Z_]+):(.+)$/); if (match) { const namespace = slugify(match[1]); const slug = slugify(match[2]); return `${namespace}:${slug}`; } return `${prefixoEsperado}:${slugify(texto)}`; } function computeFingerprint(type, subjectKey) { return `${type}:${subjectKey}`; } /** * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros — * isso e responsabilidade exclusiva do SQL/codigo determinista. */ function validateInsightShape(raw) { const erros = []; if (!raw || typeof raw !== 'object') { return { valido: false, erros: ['insight_nao_e_objeto'] }; } if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido'); if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida'); if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) { erros.push('title_invalido'); } if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) { erros.push('summary_invalido'); } if ( !Array.isArray(raw.evidence_ticket_ids) || raw.evidence_ticket_ids.length === 0 || !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0) ) { erros.push('evidence_ticket_ids_invalido'); } if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) { erros.push('confidence_invalida'); } if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido'); return { valido: erros.length === 0, erros }; } /** * Sanitiza e filtra a lista de insights brutos retornados pela IA. * * @param {Array} rawInsights * @param {{validTicketIds: Set}} context - ids realmente enviados a IA * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}} */ function sanitizeInsights(rawInsights, context, config) { const accepted = []; const suppressed = []; const fingerprintsNoRun = new Set(); const lista = Array.isArray(rawInsights) ? rawInsights : []; for (const raw of lista) { const { valido, erros } = validateInsightShape(raw); if (!valido) { suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` }); continue; } const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type); const fingerprint = computeFingerprint(raw.type, subjectKey); const enriched = { ...raw, subject_key: subjectKey, fingerprint }; if (raw.confidence < config.AI_MIN_CONFIDENCE) { suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' }); continue; } const evidenciaForaDoContexto = raw.evidence_ticket_ids.some( (id) => !context.validTicketIds.has(id) ); if (evidenciaForaDoContexto) { suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' }); continue; } if (!raw.reportable) { suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' }); continue; } if (fingerprintsNoRun.has(fingerprint)) { suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' }); continue; } fingerprintsNoRun.add(fingerprint); accepted.push(enriched); } // ordena por severidade desc, depois confianca desc, antes de cortar o maximo const pesoSeveridade = { high: 3, medium: 2, low: 1 }; accepted.sort( (a, b) => pesoSeveridade[b.severity] - pesoSeveridade[a.severity] || b.confidence - a.confidence ); const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS); const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS); for (const insight of excedentes) { suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' }); } return { accepted: dentroDoLimite, suppressed }; } /** * Calcula a transicao de estado de memoria para UM insight aceito no * periodo atual, dado o registro anterior (ou null se for a 1a vez). * * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved' */ function nextMemoryState(existingRecord, todayIso) { if (!existingRecord) { return { first_seen_at: todayIso, last_seen_at: todayIso, occurrences: 1, current_status: 'new', last_reported_at: todayIso, }; } return { first_seen_at: existingRecord.first_seen_at, last_seen_at: todayIso, occurrences: (existingRecord.occurrences || 0) + 1, current_status: 'persisting', last_reported_at: todayIso, }; } /** * Registros de memoria que TINHAM insight ativo mas nao foram detectados * de novo hoje -> viram 'normalizing' (reporta 1x "voltou ao padrao") e, * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui). */ function nextStateForAbsentRecord(existingRecord, todayIso) { if (existingRecord.current_status === 'resolved') { return { ...existingRecord }; // permanece silencioso, nada muda } if (existingRecord.current_status === 'normalizing') { return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at }; } // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x return { ...existingRecord, current_status: 'normalizing', last_reported_at: todayIso, }; } /** * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase * todos os dias (secao 14). */ function describeOccurrence(state) { if (state.current_status === 'new') return 'identificado hoje'; if (state.current_status === 'persisting') { return `segue acima do padrao pelo ${state.occurrences}o dia`; } if (state.current_status === 'normalizing') return 'voltou ao padrao'; return null; // 'resolved' -> nao reporta } module.exports = { ALLOWED_TYPES, ALLOWED_SEVERITIES, slugify, normalizeSubjectKey, computeFingerprint, validateInsightShape, sanitizeInsights, nextMemoryState, nextStateForAbsentRecord, describeOccurrence, };