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); });