feat(v2): normalize GLPI text before context/hash, add real ticket description
context.normalizeTicket now runs normalizeGlpiText on titulo, descricao (new field, sourced from glpi_tickets.content -- never fetched before), and every followup/task texto. buildAiContextText restructured into delimited sections (TITULO / DESCRICAO ORIGINAL / STATUS-TECNICO-GRUPO / FOLLOW-UPS / TAREFAS) instead of one flat string. hash.buildContextFingerprint now includes titulo/descricao and the normalized followup/task texto (previously only id/date/status), so: (a) the same content encoded differently (HTML vs escaped entities) produces the same hash, and (b) editing a followup's actual content (same id/date) is now detected as a real context change -- previously silently invisible to the hash. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d7650308a9
commit
db7a261a10
+45
-20
@@ -1,9 +1,21 @@
|
|||||||
|
const { normalizeGlpiText } = require('./normalize');
|
||||||
|
|
||||||
function normalizeTicket(row, extras) {
|
function normalizeTicket(row, extras) {
|
||||||
const followups = (extras && extras.followups) || [];
|
const followups = ((extras && extras.followups) || []).map((f) => ({
|
||||||
const tasks = (extras && extras.tasks) || [];
|
id: f.id,
|
||||||
|
date: f.date,
|
||||||
|
texto: normalizeGlpiText(f.texto),
|
||||||
|
}));
|
||||||
|
const tasks = ((extras && extras.tasks) || []).map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
date: t.date,
|
||||||
|
status: t.status,
|
||||||
|
texto: normalizeGlpiText(t.texto),
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
titulo: row.titulo,
|
titulo: normalizeGlpiText(row.titulo),
|
||||||
|
descricao: normalizeGlpiText(row.descricao),
|
||||||
status: Number(row.status),
|
status: Number(row.status),
|
||||||
status_nome: row.status_nome,
|
status_nome: row.status_nome,
|
||||||
aberto_em: row.aberto_em,
|
aberto_em: row.aberto_em,
|
||||||
@@ -32,31 +44,44 @@ function truncate(text, maxChars) {
|
|||||||
return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;
|
return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do
|
||||||
|
// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).
|
||||||
|
function truncatePreservingLines(text, maxChars) {
|
||||||
|
const str = String(text || '');
|
||||||
|
return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /
|
||||||
|
// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega
|
||||||
|
// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).
|
||||||
function buildAiContextText(ticket, maxChars) {
|
function buildAiContextText(ticket, maxChars) {
|
||||||
const budget = Math.max(200, Number(maxChars) || 4000);
|
const budget = Math.max(200, Number(maxChars) || 4000);
|
||||||
|
|
||||||
const followupsText = ticket.followups
|
const followupsText = ticket.followups
|
||||||
.slice(-5)
|
.slice(-5)
|
||||||
.map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`)
|
.map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
const tasksText = ticket.tasks
|
const tasksText = ticket.tasks
|
||||||
.slice(-5)
|
.slice(-5)
|
||||||
.map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`)
|
.map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
const header = [
|
|
||||||
`Chamado #${ticket.id} - ${ticket.titulo}`,
|
const secoes = [
|
||||||
`Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`,
|
`TITULO\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,
|
||||||
`Tecnico: ${ticket.tecnico_atribuido}`,
|
`DESCRICAO ORIGINAL\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,
|
||||||
`Prioridade: ${ticket.prioridade} Urgencia: ${ticket.urgencia} Impacto: ${ticket.impacto}`,
|
[
|
||||||
|
`STATUS: ${ticket.status_nome}`,
|
||||||
|
`TECNICO: ${ticket.tecnico_atribuido}`,
|
||||||
|
`GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,
|
||||||
|
`PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,
|
||||||
`SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,
|
`SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,
|
||||||
].join('\n');
|
].join('\n'),
|
||||||
const body = [
|
`FOLLOW-UPS\n${followupsText || '(nenhum)'}`,
|
||||||
'Ultimos apontamentos:',
|
`TAREFAS\n${tasksText || '(nenhuma)'}`,
|
||||||
followupsText || '(nenhum)',
|
];
|
||||||
'',
|
|
||||||
'Ultimas tarefas:',
|
const contextoCompleto = `Chamado #${ticket.id}\n\n${secoes.join('\n\n')}`;
|
||||||
tasksText || '(nenhuma)',
|
return truncatePreservingLines(contextoCompleto, budget);
|
||||||
].join('\n');
|
|
||||||
return truncate(`${header}\n\n${body}`, budget);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { normalizeTicket, buildAiContextText, truncate };
|
module.exports = { normalizeTicket, buildAiContextText, truncate, truncatePreservingLines };
|
||||||
|
|||||||
+30
-1
@@ -4,7 +4,7 @@ const { normalizeTicket, buildAiContextText, truncate } = require('./context');
|
|||||||
|
|
||||||
function rawRow(overrides) {
|
function rawRow(overrides) {
|
||||||
return Object.assign({
|
return Object.assign({
|
||||||
id: '38611', titulo: ' Colocar etiqueta sala 03 ', status: '2', status_nome: 'Em atendimento',
|
id: '38611', titulo: ' Colocar etiqueta sala 03 ', descricao: '<p>Descricao original do chamado</p>', status: '2', status_nome: 'Em atendimento',
|
||||||
aberto_em: '2026-08-01 08:00:00', ultima_interacao_em: '2026-08-22 08:00:00',
|
aberto_em: '2026-08-01 08:00:00', ultima_interacao_em: '2026-08-22 08:00:00',
|
||||||
dias_aberto: '23', dias_sem_interacao: '2', sla_vencido: '1', sla_em_risco: '0', horas_ate_sla: null,
|
dias_aberto: '23', dias_sem_interacao: '2', sla_vencido: '1', sla_em_risco: '0', horas_ate_sla: null,
|
||||||
prioridade: '3', urgencia: '3', impacto: '3', entidade: 'Matriz', categoria: 'Facilities',
|
prioridade: '3', urgencia: '3', impacto: '3', entidade: 'Matriz', categoria: 'Facilities',
|
||||||
@@ -24,6 +24,20 @@ test('normalizeTicket converte tipos e booleans 0/1', () => {
|
|||||||
assert.deepEqual(t.followups, []);
|
assert.deepEqual(t.followups, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizeTicket normaliza HTML da descricao', () => {
|
||||||
|
const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] });
|
||||||
|
assert.equal(t.descricao, 'Descricao original do chamado');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeTicket normaliza HTML de followups e tarefas', () => {
|
||||||
|
const t = normalizeTicket(rawRow({}), {
|
||||||
|
followups: [{ id: 1, date: '2026-08-20', texto: '<p>Aguardando retorno</p>' }],
|
||||||
|
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: '<p>Tarefa concluida</p>' }],
|
||||||
|
});
|
||||||
|
assert.equal(t.followups[0].texto, 'Aguardando retorno');
|
||||||
|
assert.equal(t.tasks[0].texto, 'Tarefa concluida');
|
||||||
|
});
|
||||||
|
|
||||||
test('truncate corta e adiciona reticencias', () => {
|
test('truncate corta e adiciona reticencias', () => {
|
||||||
const long = 'a'.repeat(50);
|
const long = 'a'.repeat(50);
|
||||||
assert.equal(truncate(long, 10), `${'a'.repeat(7)}...`);
|
assert.equal(truncate(long, 10), `${'a'.repeat(7)}...`);
|
||||||
@@ -39,3 +53,18 @@ test('buildAiContextText respeita o orcamento de caracteres', () => {
|
|||||||
assert.ok(text.length <= 300);
|
assert.ok(text.length <= 300);
|
||||||
assert.ok(text.includes('#38611'));
|
assert.ok(text.includes('#38611'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildAiContextText tem secoes delimitadas: titulo, descricao, follow-ups, tarefas', () => {
|
||||||
|
const t = normalizeTicket(rawRow({}), {
|
||||||
|
followups: [{ id: 1, date: '2026-08-20', texto: 'Aguardando resposta' }],
|
||||||
|
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: 'Tarefa feita' }],
|
||||||
|
});
|
||||||
|
const text = buildAiContextText(t, 4000);
|
||||||
|
assert.ok(text.includes('TITULO'));
|
||||||
|
assert.ok(text.includes('DESCRICAO ORIGINAL'));
|
||||||
|
assert.ok(text.includes('Descricao original do chamado'));
|
||||||
|
assert.ok(text.includes('FOLLOW-UPS'));
|
||||||
|
assert.ok(text.includes('Aguardando resposta'));
|
||||||
|
assert.ok(text.includes('TAREFAS'));
|
||||||
|
assert.ok(text.includes('Tarefa feita'));
|
||||||
|
});
|
||||||
|
|||||||
+7
-3
@@ -13,11 +13,15 @@ function djb2Hash(str) {
|
|||||||
return hash.toString(16).padStart(8, '0');
|
return hash.toString(16).padStart(8, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
const FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade'];
|
const FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];
|
||||||
|
|
||||||
|
// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint
|
||||||
|
// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma
|
||||||
|
// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca
|
||||||
|
// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.
|
||||||
function buildContextFingerprint(ticket) {
|
function buildContextFingerprint(ticket) {
|
||||||
const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}`).sort();
|
const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();
|
||||||
const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}`).sort();
|
const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();
|
||||||
const picked = {};
|
const picked = {};
|
||||||
for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];
|
for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];
|
||||||
picked.followups = followups;
|
picked.followups = followups;
|
||||||
|
|||||||
+22
-3
@@ -1,6 +1,7 @@
|
|||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { buildContextFingerprint, stableStringify } = require('./hash');
|
const { buildContextFingerprint, stableStringify } = require('./hash');
|
||||||
|
const { normalizeGlpiText } = require('./normalize');
|
||||||
|
|
||||||
function baseTicket(overrides) {
|
function baseTicket(overrides) {
|
||||||
return Object.assign({
|
return Object.assign({
|
||||||
@@ -8,8 +9,10 @@ function baseTicket(overrides) {
|
|||||||
tecnico_atribuido: 'Wagner',
|
tecnico_atribuido: 'Wagner',
|
||||||
categoria: 'Rede',
|
categoria: 'Rede',
|
||||||
entidade: 'Matriz',
|
entidade: 'Matriz',
|
||||||
followups: [{ id: 1, date: '2026-08-20 10:00:00' }],
|
titulo: 'Nobreak desligando',
|
||||||
tasks: [{ id: 1, status: 1, date: '2026-08-19 09:00:00' }],
|
descricao: 'Nobreak do servidor desligando sozinho',
|
||||||
|
followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }],
|
||||||
|
tasks: [{ id: 1, status: 1, date: '2026-08-19 09:00:00', texto: 'Verificado no local' }],
|
||||||
dias_aberto: 10,
|
dias_aberto: 10,
|
||||||
dias_sem_interacao: 3,
|
dias_sem_interacao: 3,
|
||||||
}, overrides);
|
}, overrides);
|
||||||
@@ -23,7 +26,7 @@ test('alterar apenas dias_aberto/dias_sem_interacao NAO muda o hash', () => {
|
|||||||
|
|
||||||
test('novo followup MUDA o hash', () => {
|
test('novo followup MUDA o hash', () => {
|
||||||
const a = buildContextFingerprint(baseTicket({}));
|
const a = buildContextFingerprint(baseTicket({}));
|
||||||
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00' }, { id: 2, date: '2026-08-21 08:00:00' }] }));
|
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }, { id: 2, date: '2026-08-21 08:00:00', texto: 'Peca chegou' }] }));
|
||||||
assert.notEqual(a, b);
|
assert.notEqual(a, b);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +36,22 @@ test('mudanca de tecnico MUDA o hash', () => {
|
|||||||
assert.notEqual(a, b);
|
assert.notEqual(a, b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mesmo conteudo normalizado de fontes com encoding HTML diferente gera o MESMO hash', () => {
|
||||||
|
const textoHtml = normalizeGlpiText('<p>Aguardando cliente</p>');
|
||||||
|
const textoEscapado = normalizeGlpiText('<p>Aguardando cliente</p>');
|
||||||
|
assert.equal(textoHtml, textoEscapado, 'pre-condicao: normalizacao deve produzir o mesmo texto');
|
||||||
|
|
||||||
|
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoHtml }] }));
|
||||||
|
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoEscapado }] }));
|
||||||
|
assert.equal(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edicao real do conteudo de um followup (mesmo id/date) MUDA o hash', () => {
|
||||||
|
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }] }));
|
||||||
|
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Peca instalada, testando' }] }));
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
test('stableStringify ignora ordem de chaves', () => {
|
test('stableStringify ignora ordem de chaves', () => {
|
||||||
assert.equal(stableStringify({ b: 1, a: 2 }), stableStringify({ a: 2, b: 1 }));
|
assert.equal(stableStringify({ b: 1, a: 2 }), stableStringify({ a: 2, b: 1 }));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user