chore: import relatorio (subtree)
git-subtree-dir: relatorio git-subtree-mainline:e322cec92bgit-subtree-split:34bcb132f3
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user