| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- 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));
- function parseArgs(argv) {
- const args = {
- scoreMin: 8,
- incluirParcial: false,
- maxChars: 16000,
- validFrac: 0.1,
- 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 === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
- }
- 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, " ");
- }
- 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 outbound = primeiraBruta?.Resposta === 1;
- 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 (outbound) {
- // atendimento iniciado pelo atendente (contato proativo): 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.
- turnos.splice(0, 2);
- }
- 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;
- }
- 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)`);
- const samples = [];
- 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;
- }
- const chars = messages.reduce((n, m) => n + m.content.length, 0);
- if (chars > args.maxChars) {
- descartadosLongos += 1;
- continue;
- }
- samples.push({ codigo: atendimento.Codigo, messages });
- }
- console.log(`${samples.length} amostras válidas (${descartadosCurtos} curtas demais, ${descartadosLongos} longas demais)`);
- if (samples.length === 0) {
- console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
- process.exit(0);
- }
- const embaralhadas = shuffleDeterministico(samples);
- const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
- const valid = embaralhadas.slice(0, nValid);
- const train = embaralhadas.slice(nValid);
- 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 },
- train: train.length,
- valid: valid.length,
- protocolos: embaralhadas.map((s) => s.codigo)
- },
- 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);
- });
|