chore: import relatorio (subtree)

git-subtree-dir: relatorio
git-subtree-mainline: e322cec92b
git-subtree-split: 34bcb132f3
This commit is contained in:
Gustavo Santos
2026-10-01 14:26:02 -03:00
35 changed files with 6220 additions and 0 deletions
+248
View File
@@ -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,
};