From db7a261a100a0ac83fb527afe0654d8e96005cad Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 16:47:32 -0300 Subject: [PATCH] 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 --- logic/context.js | 67 +++++++++++++++++++++++++++++-------------- logic/context.test.js | 33 +++++++++++++++++++-- logic/hash.js | 10 +++++-- logic/hash.test.js | 25 ++++++++++++++-- 4 files changed, 106 insertions(+), 29 deletions(-) diff --git a/logic/context.js b/logic/context.js index 9d6d4d5..cd4ca92 100644 --- a/logic/context.js +++ b/logic/context.js @@ -1,9 +1,21 @@ +const { normalizeGlpiText } = require('./normalize'); + function normalizeTicket(row, extras) { - const followups = (extras && extras.followups) || []; - const tasks = (extras && extras.tasks) || []; + const followups = ((extras && extras.followups) || []).map((f) => ({ + 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 { id: Number(row.id), - titulo: row.titulo, + titulo: normalizeGlpiText(row.titulo), + descricao: normalizeGlpiText(row.descricao), status: Number(row.status), status_nome: row.status_nome, 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; } +// 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) { const budget = Math.max(200, Number(maxChars) || 4000); + const followupsText = ticket.followups .slice(-5) - .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`) + .map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`) .join('\n'); const tasksText = ticket.tasks .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'); - const header = [ - `Chamado #${ticket.id} - ${ticket.titulo}`, - `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`, - `Tecnico: ${ticket.tecnico_atribuido}`, - `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}`, - ].join('\n'); - const body = [ - 'Ultimos apontamentos:', - followupsText || '(nenhum)', - '', - 'Ultimas tarefas:', - tasksText || '(nenhuma)', - ].join('\n'); - return truncate(`${header}\n\n${body}`, budget); + + const secoes = [ + `TITULO\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`, + `DESCRICAO ORIGINAL\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`, + [ + `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}`, + ].join('\n'), + `FOLLOW-UPS\n${followupsText || '(nenhum)'}`, + `TAREFAS\n${tasksText || '(nenhuma)'}`, + ]; + + const contextoCompleto = `Chamado #${ticket.id}\n\n${secoes.join('\n\n')}`; + return truncatePreservingLines(contextoCompleto, budget); } -module.exports = { normalizeTicket, buildAiContextText, truncate }; +module.exports = { normalizeTicket, buildAiContextText, truncate, truncatePreservingLines }; diff --git a/logic/context.test.js b/logic/context.test.js index 8304f9e..e71407e 100644 --- a/logic/context.test.js +++ b/logic/context.test.js @@ -4,7 +4,7 @@ const { normalizeTicket, buildAiContextText, truncate } = require('./context'); function rawRow(overrides) { return Object.assign({ - id: '38611', titulo: ' Colocar etiqueta sala 03 ', status: '2', status_nome: 'Em atendimento', + id: '38611', titulo: ' Colocar etiqueta sala 03 ', descricao: '

Descricao original do chamado

', status: '2', status_nome: 'Em atendimento', 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, prioridade: '3', urgencia: '3', impacto: '3', entidade: 'Matriz', categoria: 'Facilities', @@ -16,7 +16,7 @@ function rawRow(overrides) { test('normalizeTicket converte tipos e booleans 0/1', () => { const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] }); assert.equal(t.id, 38611); - assert.equal(t.titulo, ' Colocar etiqueta sala 03 '); + assert.equal(t.titulo, 'Colocar etiqueta sala 03'); assert.equal(t.sla_vencido, true); assert.equal(t.sla_em_risco, false); assert.equal(t.sem_tecnico, true); @@ -24,6 +24,20 @@ test('normalizeTicket converte tipos e booleans 0/1', () => { 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: '

Tarefa concluida

' }], + }); + assert.equal(t.followups[0].texto, 'Aguardando retorno'); + assert.equal(t.tasks[0].texto, 'Tarefa concluida'); +}); + test('truncate corta e adiciona reticencias', () => { const long = 'a'.repeat(50); 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.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')); +}); diff --git a/logic/hash.js b/logic/hash.js index 6ab40d2..027e74b 100644 --- a/logic/hash.js +++ b/logic/hash.js @@ -13,11 +13,15 @@ function djb2Hash(str) { 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) { - const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}`).sort(); - const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.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}:${t.texto || ''}`).sort(); const picked = {}; for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field]; picked.followups = followups; diff --git a/logic/hash.test.js b/logic/hash.test.js index b2a084b..36e77e5 100644 --- a/logic/hash.test.js +++ b/logic/hash.test.js @@ -1,6 +1,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { buildContextFingerprint, stableStringify } = require('./hash'); +const { normalizeGlpiText } = require('./normalize'); function baseTicket(overrides) { return Object.assign({ @@ -8,8 +9,10 @@ function baseTicket(overrides) { tecnico_atribuido: 'Wagner', categoria: 'Rede', entidade: 'Matriz', - followups: [{ id: 1, date: '2026-08-20 10:00:00' }], - tasks: [{ id: 1, status: 1, date: '2026-08-19 09:00:00' }], + titulo: 'Nobreak desligando', + 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_sem_interacao: 3, }, overrides); @@ -23,7 +26,7 @@ test('alterar apenas dias_aberto/dias_sem_interacao NAO muda o hash', () => { test('novo followup MUDA o hash', () => { 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); }); @@ -33,6 +36,22 @@ test('mudanca de tecnico MUDA o hash', () => { assert.notEqual(a, b); }); +test('mesmo conteudo normalizado de fontes com encoding HTML diferente gera o MESMO hash', () => { + const textoHtml = normalizeGlpiText('

Aguardando cliente

'); + 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', () => { assert.equal(stableStringify({ b: 1, a: 2 }), stableStringify({ a: 2, b: 1 })); });