git-subtree-dir: relatorio git-subtree-mainline:e322cec92bgit-subtree-split:34bcb132f3
249 lines
7.4 KiB
JavaScript
249 lines
7.4 KiB
JavaScript
'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 "<namespace>:<slug>".
|
|
* 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<object>} rawInsights
|
|
* @param {{validTicketIds: Set<number>}} 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,
|
|
};
|