| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- import fs from "node:fs";
- import path from "node:path";
- import { fileURLToPath } from "node:url";
- import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
- import { Atendimento } from "../src/models/Atendimento.model.js";
- import { stripHtml, parseAtendenteBody, TRANSCRICAO_SENTINELS } from "../src/utils/atendimentoFormat.js";
- import { SETOR_NOMES } from "../src/config/setores.js";
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
- // "por-turno" (default, ciclo 5+): várias amostras por atendimento, cada uma terminando num turno
- // diferente do atendente. "ultimo-turno" (ciclos 1-4): 1 amostra por atendimento terminando no
- // ÚLTIMO turno — mantido pra poder reproduzir os datasets antigos.
- const MODOS = ["por-turno", "ultimo-turno"];
- function parseArgs(argv) {
- const args = {
- scoreMin: 8,
- incluirParcial: false,
- maxChars: 16000,
- validFrac: 0.1,
- modo: "por-turno",
- alvosPorAtendimento: 2,
- minPalavrasAlvo: 8,
- fracTriviais: 0.15,
- maxAlvoRepetido: 30,
- out: path.resolve(__dirname, "../training/data")
- };
- for (let i = 2; i < argv.length; i += 1) {
- const a = argv[i];
- if (a === "--score-min") args.scoreMin = Number(argv[++i]);
- else if (a === "--incluir-parcial") args.incluirParcial = true;
- else if (a === "--max-chars") args.maxChars = Number(argv[++i]);
- else if (a === "--valid-frac") args.validFrac = Number(argv[++i]);
- else if (a === "--modo") args.modo = argv[++i];
- else if (a === "--alvos-por-atendimento") args.alvosPorAtendimento = Number(argv[++i]);
- else if (a === "--min-palavras-alvo") args.minPalavrasAlvo = Number(argv[++i]);
- else if (a === "--frac-triviais") args.fracTriviais = Number(argv[++i]);
- else if (a === "--max-alvo-repetido") args.maxAlvoRepetido = Number(argv[++i]);
- else if (a === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
- }
- if (!MODOS.includes(args.modo)) {
- throw new Error(`--modo inválido: "${args.modo}" (use ${MODOS.join(" ou ")})`);
- }
- if (!(args.alvosPorAtendimento >= 1) || !(args.fracTriviais >= 0 && args.fracTriviais < 1) || !(args.maxAlvoRepetido >= 1)) {
- throw new Error("--alvos-por-atendimento e --max-alvo-repetido devem ser >= 1; --frac-triviais deve estar em [0, 1)");
- }
- return args;
- }
- function systemPrompt(setor) {
- const area = SETOR_NOMES[setor] ?? (setor ? setor.toLowerCase() : "atendimento ao cliente");
- return [
- `Você é um atendente do setor de ${area} da Star Internet, atendendo clientes pelo WhatsApp.`,
- "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessárias,",
- "explique os passos com clareza e conduza o atendimento até a solução ou encaminhamento correto.",
- "Responda em português brasileiro."
- ].join(" ");
- }
- function scrubTelefones(text) {
- return text
- .replace(/\+?55\s?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}/g, "[telefone]")
- .replace(/\(?\d{2}\)?\s9\d{4}[-\s]?\d{4}/g, "[telefone]");
- }
- // um atendente real dominava tanto os exemplos de encaminhamento ("vou transferir pro colega
- // Jonas") que o modelo aprendia esse nome como resposta genérica de "pra quem eu transfiro"
- // (145 ocorrências em 4,3% das amostras — achado no teste comparativo do ciclo 2, 2026-09-19).
- // Remove o nome específico, mantendo a estrutura da frase.
- function scrubNomeColega(text) {
- return text
- .replace(/(\bcolegas?\s+)[A-ZÀ-Ú][a-zà-úãõç]+,?\s*/gi, "$1")
- .replace(/[ \t]{2,}/g, " ");
- }
- // distingue contato proativo genuíno do atendente (outreach comercial/acompanhamento) de
- // mensagens automáticas (triagem do bot, confirmação de protocolo) que também têm Resposta=1
- // na primeira linha — achado no teste comparativo do ciclo 3 (2026-09-22): só ~8% dos
- // atendimentos marcados "outbound" pela heurística antiga (Resposta===1 na 1a linha) eram
- // outreach real; o resto era inbound com a mensagem de abertura genuína não capturada.
- const OUTREACH_GENUINO_RE = /sou (a|o) \w+ d[oa]|acompanhamento da sua|referente [àa] sua solicita/i;
- // abertura sem conteúdo real (confirmação/ack solto, número puro tipo CPF/telefone/protocolo,
- // placeholder de mídia) — sinal de que a mensagem real do cliente não está na amostra; treinar
- // com isso ensina o modelo a responder vazio a qualquer coisa curta (mesmo achado do ciclo 3).
- const FRAGMENTO_RE = /^(ok|sim|n[ãa]o|certo|obrigad[oa]|blz|beleza|entendi|isso|valeu)[.,!]?$|^[\d\s.\-/]+$|^\[mídia:.*\]$/i;
- function isFragmentoSemContexto(texto) {
- return FRAGMENTO_RE.test(texto.trim());
- }
- function ordenarMensagens(mensagens) {
- return [...mensagens].sort((a, b) => {
- const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
- const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
- return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
- });
- }
- function mensagemTexto(m) {
- const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
- const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
-
- const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw)
- ? ""
- : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw);
- let texto;
- if (transcricao) {
-
- texto = m.Resposta === 1 ? transcricao : `[áudio] ${transcricao}`;
- } else if (m.Tipodemidia && m.Tipodemidia !== "text") {
- texto = `[mídia: ${m.Tipodemidia}]`;
- } else if (m.Resposta === 1) {
- texto = parseAtendenteBody(m.Body).texto;
- } else {
- texto = stripHtml(m.Body);
- }
- if (!texto) return null;
- texto = scrubTelefones(texto);
- if (m.Resposta === 1) texto = scrubNomeColega(texto);
- return texto;
- }
- export function conversaParaMessages(atendimento, mensagens) {
- const ordenadas = ordenarMensagens(mensagens);
- const primeiraBruta = ordenadas.find((m) => m.Resposta === 0 || m.Resposta === 1);
- const outboundGenuino =
- primeiraBruta?.Resposta === 1 && OUTREACH_GENUINO_RE.test(mensagemTexto(primeiraBruta) || "");
- const turnos = [];
- for (const m of ordenadas) {
- if (m.Resposta !== 0 && m.Resposta !== 1) continue;
- const texto = mensagemTexto(m);
- if (!texto) continue;
- const role = m.Resposta === 1 ? "assistant" : "user";
- const anterior = turnos[turnos.length - 1];
- if (anterior && anterior.role === role) anterior.content += `\n${texto}`;
- else turnos.push({ role, content: texto });
- }
- while (turnos.length && turnos[0].role !== "user") turnos.shift();
- while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
- if (outboundGenuino) {
- // contato proativo real do atendente (outreach comercial/acompanhamento): a mensagem de
- // abertura que dá o contexto real já foi descartada no trim acima, deixando o primeiro par
- // cliente/atendente órfão — ex.: cliente só responde "bom dia" e o atendente já confirma algo
- // sem relação visível com a saudação (achado no teste comparativo do ciclo 2, 2026-09-19).
- // Descarta só esse primeiro par (não o atendimento inteiro), mantendo a conversa real que
- // vier depois. Restrito a outreach genuíno (ver OUTREACH_GENUINO_RE) desde o ciclo 4: a
- // heurística antiga (Resposta===1 na 1a linha) capturava também triagem automática do bot e
- // confirmação automática de protocolo — nesses casos o splice removia a explicação real do
- // cliente em vez de um par órfão (achado no teste comparativo do ciclo 3, 2026-09-22).
- turnos.splice(0, 2);
- }
- // 1o turno do cliente sem conteúdo real (ack solto, número puro, mídia) — a mensagem real que
- // abriu o atendimento não está na amostra (comum em atendimentos inbound cuja abertura foi
- // consumida por triagem/roteamento do ifbot antes da criação do protocolo). Descarta a amostra
- // em vez de ensinar o modelo a responder vazio a qualquer abertura curta (achado do ciclo 3).
- if (turnos.length && turnos[0].role === "user" && isFragmentoSemContexto(turnos[0].content)) {
- return null;
- }
- if (turnos.length < 4) return null;
- return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
- }
- function shuffleDeterministico(arr) {
- let seed = 42;
- const rand = () => {
- seed = (seed * 1103515245 + 12345) % 2147483648;
- return seed / 2147483648;
- };
- const out = [...arr];
- for (let i = out.length - 1; i > 0; i -= 1) {
- const j = Math.floor(rand() * (i + 1));
- [out[i], out[j]] = [out[j], out[i]];
- }
- return out;
- }
- // PRNG com seed (mulberry32) — o LCG de shuffleDeterministico perde precisão acima de 2^53 e é
- // fraco pra sortear turnos por atendimento; ele é mantido intocado só pra reproduzir o modo
- // "ultimo-turno" bit a bit.
- function mulberry32(seed) {
- let a = seed >>> 0;
- return () => {
- a = (a + 0x6d2b79f5) | 0;
- let t = Math.imul(a ^ (a >>> 15), 1 | a);
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
- }
- function seedDeTexto(texto) {
- let h = 2166136261;
- for (let i = 0; i < texto.length; i += 1) {
- h ^= texto.charCodeAt(i);
- h = Math.imul(h, 16777619);
- }
- return h >>> 0;
- }
- function shuffleSeeded(arr, seed) {
- const rand = mulberry32(seed);
- const out = [...arr];
- for (let i = out.length - 1; i > 0; i -= 1) {
- const j = Math.floor(rand() * (i + 1));
- [out[i], out[j]] = [out[j], out[i]];
- }
- return out;
- }
- // O mlx-lm com --mask-prompt só calcula loss no ÚLTIMO turno de cada amostra. Com 1 amostra por
- // atendimento terminando no último turno, o alvo virava quase sempre o fechamento ("Disponha!",
- // "De nada", "Certo" — mediana de 7 palavras, 43,6% com <=5) e o modelo aprendia a fechar
- // conversa em vez de resolver (achado no teste comparativo do ciclo 4, 2026-09-24).
- const FECHAMENTO_RE = /encerr(ar|ando)|disponha|qualquer d[uú]vida|[àa] disposi[çc][ãa]o|foi um prazer|de nada|imagina/i;
- const LIMITE_PALAVRAS_FECHAMENTO = 12;
- function contarPalavras(texto) {
- return texto.trim().split(/\s+/).filter(Boolean).length;
- }
- // "trivial" = ack/fechamento curto (poucas palavras, ou fraseado de fechamento em turno curto);
- // "substantivo" = turno em que o atendente de fato responde/conduz algo.
- function tipoDoAlvo(texto, minPalavrasAlvo) {
- const n = contarPalavras(texto);
- if (n < minPalavrasAlvo) return "trivial";
- if (n < LIMITE_PALAVRAS_FECHAMENTO && FECHAMENTO_RE.test(texto)) return "trivial";
- return "substantivo";
- }
- // Expande um atendimento (system + turnos user/assistant alternados, começando em user e
- // terminando em assistant) em amostras por-turno: cada uma é o prefixo da conversa até um turno do
- // atendente. Sorteia (por atendimento, determinístico pelo código) até `alvosPorAtendimento`
- // turnos substantivos; os triviais voltam todos e são amostrados globalmente por quem chama.
- export function amostrasPorTurno(codigo, messages, { alvosPorAtendimento, minPalavrasAlvo }) {
- const [sistema, ...turnos] = messages;
- const substantivos = [];
- const triviais = [];
- for (let i = 1; i < turnos.length; i += 1) {
- if (turnos[i].role !== "assistant") continue;
- const alvo = { codigo, messages: [sistema, ...turnos.slice(0, i + 1)] };
- (tipoDoAlvo(turnos[i].content, minPalavrasAlvo) === "substantivo" ? substantivos : triviais).push(alvo);
- }
- return {
- substantivas: shuffleSeeded(substantivos, seedDeTexto(codigo)).slice(0, alvosPorAtendimento),
- triviais
- };
- }
- function totalChars(messages) {
- return messages.reduce((n, m) => n + m.content.length, 0);
- }
- // Gera as amostras de um conjunto de atendimentos (train ou valid). Split por atendimento é feito
- // ANTES daqui, então nenhum atendimento aparece nos dois lados.
- function expandirConversas(conversas, args, seed) {
- const substantivasBrutas = [];
- const triviaisBrutos = [];
- for (const c of conversas) {
- const r = amostrasPorTurno(c.codigo, c.messages, args);
- substantivasBrutas.push(...r.substantivas);
- triviaisBrutos.push(...r.triviais);
- }
- // teto por alvo repetido: um mesmo texto de atendente (ex.: "Vou transferir seu atendimento",
- // "Certo, pagamento agendado…") com dezenas de ocorrências vira resposta padrão do modelo — mesmo
- // mecanismo do nome "Jonas" no ciclo 2. Chave = início normalizado do alvo, então variações
- // de um mesmo template contam juntas. A ordem já é embaralhada, então o corte não favorece
- // atendimentos específicos.
- let descartadosRepetidos = 0;
- const limitarRepetidos = (lista) => {
- const vistos = new Map();
- return lista.filter((s) => {
- const chave = s.messages[s.messages.length - 1].content.trim().toLowerCase().slice(0, 40);
- const n = vistos.get(chave) ?? 0;
- if (n >= args.maxAlvoRepetido) {
- descartadosRepetidos += 1;
- return false;
- }
- vistos.set(chave, n + 1);
- return true;
- });
- };
- const substantivas = limitarRepetidos(shuffleSeeded(substantivasBrutas, seed + 2));
- const triviais = limitarRepetidos(shuffleSeeded(triviaisBrutos, seed));
- const maxTriviais = Math.round((substantivas.length * args.fracTriviais) / (1 - args.fracTriviais));
- const triviaisSel = triviais.slice(0, maxTriviais);
- const dentroDoLimite = (s) => totalChars(s.messages) <= args.maxChars;
- const subst = substantivas.filter(dentroDoLimite);
- const triv = triviaisSel.filter(dentroDoLimite);
- return {
- amostras: shuffleSeeded([...subst, ...triv], seed + 1),
- nSubstantivas: subst.length,
- nTriviais: triv.length,
- descartadosLongos: substantivas.length - subst.length + triviaisSel.length - triv.length,
- descartadosRepetidos
- };
- }
- async function main() {
- const args = parseArgs(process.argv);
- const resolvidos = args.incluirParcial ? ["sim", "parcial"] : ["sim"];
- const avaliacoes = await AtendimentoAvaliacao.query()
- .where("Avaliavel", true)
- .where("ScoreAtendente", ">=", args.scoreMin)
- .whereIn("Resolvido", resolvidos)
- .whereNot("Sentimento", "negativo");
- console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
- // atendimentos já convertidos em {system, turnos...}; a expansão em amostras depende do --modo
- const conversas = [];
- // conta tanto turnos<4 quanto abertura fragmentada sem contexto (ver isFragmentoSemContexto)
- let descartadosCurtos = 0;
- let descartadosLongos = 0;
- for (const av of avaliacoes) {
- const atendimento = await Atendimento.query()
- .findById(av.AtendimentoId)
- .withGraphFetched("mensagens")
- .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
- if (!atendimento) continue;
- const mensagens = atendimento.mensagens ?? [];
- const messages = conversaParaMessages(atendimento, mensagens);
- if (!messages) {
- descartadosCurtos += 1;
- continue;
- }
- // ultimo-turno: o atendimento inteiro é a amostra, então o teto de caracteres vale pra ele.
- // por-turno: o teto vale por amostra (prefixo), aplicado em expandirConversas.
- if (args.modo === "ultimo-turno" && totalChars(messages) > args.maxChars) {
- descartadosLongos += 1;
- continue;
- }
- conversas.push({ codigo: atendimento.Codigo, messages });
- }
- console.log(`${conversas.length} atendimentos válidos (${descartadosCurtos} curtos demais/sem contexto${args.modo === "ultimo-turno" ? `, ${descartadosLongos} longos demais` : ""})`);
- if (conversas.length === 0) {
- console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
- process.exit(0);
- }
- let train;
- let valid;
- let protocolosTrain;
- let protocolosValid;
- let resumoModo;
- if (args.modo === "ultimo-turno") {
- // legado (ciclos 1-4): 1 amostra por atendimento; embaralha e separa como antes, bit a bit
- const embaralhadas = shuffleDeterministico(conversas);
- const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
- valid = embaralhadas.slice(0, nValid);
- train = embaralhadas.slice(nValid);
- protocolosTrain = train.map((s) => s.codigo);
- protocolosValid = valid.map((s) => s.codigo);
- resumoModo = { atendimentosTrain: train.length, atendimentosValid: valid.length };
- } else {
- // por-turno: o split é por ATENDIMENTO (antes de expandir) — senão prefixos do mesmo
- // atendimento cairiam em train e valid, e a val loss mediria memorização
- const embaralhados = shuffleSeeded(conversas, 42);
- const nValid = Math.max(1, Math.round(embaralhados.length * args.validFrac));
- const convValid = embaralhados.slice(0, nValid);
- const convTrain = embaralhados.slice(nValid);
- const t = expandirConversas(convTrain, args, 42);
- const v = expandirConversas(convValid, args, 43);
- train = t.amostras;
- valid = v.amostras;
- protocolosTrain = convTrain.map((c) => c.codigo);
- protocolosValid = convValid.map((c) => c.codigo);
- resumoModo = {
- atendimentosTrain: convTrain.length,
- atendimentosValid: convValid.length,
- substantivasTrain: t.nSubstantivas,
- triviaisTrain: t.nTriviais,
- substantivasValid: v.nSubstantivas,
- triviaisValid: v.nTriviais,
- descartadosLongos: t.descartadosLongos + v.descartadosLongos,
- descartadosRepetidos: t.descartadosRepetidos + v.descartadosRepetidos
- };
- console.log(
- `train: ${convTrain.length} atendimentos -> ${train.length} amostras (${t.nSubstantivas} substantivas + ${t.nTriviais} triviais) | ` +
- `valid: ${convValid.length} atendimentos -> ${valid.length} amostras (${v.nSubstantivas} + ${v.nTriviais}) | ` +
- `${resumoModo.descartadosLongos} descartadas por --max-chars, ${resumoModo.descartadosRepetidos} por --max-alvo-repetido`
- );
- }
- fs.mkdirSync(args.out, { recursive: true });
- const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
- fs.writeFileSync(path.join(args.out, "train.jsonl"), toJsonl(train));
- fs.writeFileSync(path.join(args.out, "valid.jsonl"), toJsonl(valid));
- fs.writeFileSync(
- path.join(args.out, "manifest.json"),
- JSON.stringify(
- {
- geradoEm: new Date().toISOString(),
- filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
- modo: args.modo,
- ...(args.modo === "por-turno" && {
- alvosPorAtendimento: args.alvosPorAtendimento,
- minPalavrasAlvo: args.minPalavrasAlvo,
- fracTriviais: args.fracTriviais,
- maxAlvoRepetido: args.maxAlvoRepetido
- }),
- train: train.length,
- valid: valid.length,
- ...resumoModo,
- // por atendimento (não por amostra) — permite auditar que nenhum protocolo está nos dois lados
- protocolosTrain,
- protocolosValid
- },
- null,
- 2
- )
- );
- console.log(`Exportado para ${args.out}: train.jsonl (${train.length}) + valid.jsonl (${valid.length})`);
- process.exit(0);
- }
- main().catch((e) => {
- console.error("ERRO:", e);
- process.exit(1);
- });
|