exportarDatasetTreinamento.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import fs from "node:fs";
  2. import path from "node:path";
  3. import { fileURLToPath } from "node:url";
  4. import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
  5. import { Atendimento } from "../src/models/Atendimento.model.js";
  6. import { stripHtml, parseAtendenteBody, TRANSCRICAO_SENTINELS } from "../src/utils/atendimentoFormat.js";
  7. import { SETOR_NOMES } from "../src/config/setores.js";
  8. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  9. function parseArgs(argv) {
  10. const args = {
  11. scoreMin: 8,
  12. incluirParcial: false,
  13. maxChars: 16000,
  14. validFrac: 0.1,
  15. out: path.resolve(__dirname, "../training/data")
  16. };
  17. for (let i = 2; i < argv.length; i += 1) {
  18. const a = argv[i];
  19. if (a === "--score-min") args.scoreMin = Number(argv[++i]);
  20. else if (a === "--incluir-parcial") args.incluirParcial = true;
  21. else if (a === "--max-chars") args.maxChars = Number(argv[++i]);
  22. else if (a === "--valid-frac") args.validFrac = Number(argv[++i]);
  23. else if (a === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
  24. }
  25. return args;
  26. }
  27. function systemPrompt(setor) {
  28. const area = SETOR_NOMES[setor] ?? (setor ? setor.toLowerCase() : "atendimento ao cliente");
  29. return [
  30. `Você é um atendente do setor de ${area} da Star Internet, atendendo clientes pelo WhatsApp.`,
  31. "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessárias,",
  32. "explique os passos com clareza e conduza o atendimento até a solução ou encaminhamento correto.",
  33. "Responda em português brasileiro."
  34. ].join(" ");
  35. }
  36. function scrubTelefones(text) {
  37. return text
  38. .replace(/\+?55\s?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}/g, "[telefone]")
  39. .replace(/\(?\d{2}\)?\s9\d{4}[-\s]?\d{4}/g, "[telefone]");
  40. }
  41. // um atendente real dominava tanto os exemplos de encaminhamento ("vou transferir pro colega
  42. // Jonas") que o modelo aprendia esse nome como resposta genérica de "pra quem eu transfiro"
  43. // (145 ocorrências em 4,3% das amostras — achado no teste comparativo do ciclo 2, 2026-09-19).
  44. // Remove o nome específico, mantendo a estrutura da frase.
  45. function scrubNomeColega(text) {
  46. return text
  47. .replace(/(\bcolegas?\s+)[A-ZÀ-Ú][a-zà-úãõç]+,?\s*/gi, "$1")
  48. .replace(/[ \t]{2,}/g, " ");
  49. }
  50. function ordenarMensagens(mensagens) {
  51. return [...mensagens].sort((a, b) => {
  52. const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
  53. const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
  54. return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
  55. });
  56. }
  57. function mensagemTexto(m) {
  58. const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
  59. const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
  60. const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw)
  61. ? ""
  62. : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw);
  63. let texto;
  64. if (transcricao) {
  65. texto = m.Resposta === 1 ? transcricao : `[áudio] ${transcricao}`;
  66. } else if (m.Tipodemidia && m.Tipodemidia !== "text") {
  67. texto = `[mídia: ${m.Tipodemidia}]`;
  68. } else if (m.Resposta === 1) {
  69. texto = parseAtendenteBody(m.Body).texto;
  70. } else {
  71. texto = stripHtml(m.Body);
  72. }
  73. if (!texto) return null;
  74. texto = scrubTelefones(texto);
  75. if (m.Resposta === 1) texto = scrubNomeColega(texto);
  76. return texto;
  77. }
  78. export function conversaParaMessages(atendimento, mensagens) {
  79. const ordenadas = ordenarMensagens(mensagens);
  80. const primeiraBruta = ordenadas.find((m) => m.Resposta === 0 || m.Resposta === 1);
  81. const outbound = primeiraBruta?.Resposta === 1;
  82. const turnos = [];
  83. for (const m of ordenadas) {
  84. if (m.Resposta !== 0 && m.Resposta !== 1) continue;
  85. const texto = mensagemTexto(m);
  86. if (!texto) continue;
  87. const role = m.Resposta === 1 ? "assistant" : "user";
  88. const anterior = turnos[turnos.length - 1];
  89. if (anterior && anterior.role === role) anterior.content += `\n${texto}`;
  90. else turnos.push({ role, content: texto });
  91. }
  92. while (turnos.length && turnos[0].role !== "user") turnos.shift();
  93. while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
  94. if (outbound) {
  95. // atendimento iniciado pelo atendente (contato proativo): a mensagem de abertura que dá
  96. // o contexto real já foi descartada no trim acima, deixando o primeiro par cliente/atendente
  97. // órfão — ex.: cliente só responde "bom dia" e o atendente já confirma algo sem relação
  98. // visível com a saudação (achado no teste comparativo do ciclo 2, 2026-09-19). Descarta só
  99. // esse primeiro par (não o atendimento inteiro), mantendo a conversa real que vier depois.
  100. turnos.splice(0, 2);
  101. }
  102. if (turnos.length < 4) return null;
  103. return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
  104. }
  105. function shuffleDeterministico(arr) {
  106. let seed = 42;
  107. const rand = () => {
  108. seed = (seed * 1103515245 + 12345) % 2147483648;
  109. return seed / 2147483648;
  110. };
  111. const out = [...arr];
  112. for (let i = out.length - 1; i > 0; i -= 1) {
  113. const j = Math.floor(rand() * (i + 1));
  114. [out[i], out[j]] = [out[j], out[i]];
  115. }
  116. return out;
  117. }
  118. async function main() {
  119. const args = parseArgs(process.argv);
  120. const resolvidos = args.incluirParcial ? ["sim", "parcial"] : ["sim"];
  121. const avaliacoes = await AtendimentoAvaliacao.query()
  122. .where("Avaliavel", true)
  123. .where("ScoreAtendente", ">=", args.scoreMin)
  124. .whereIn("Resolvido", resolvidos)
  125. .whereNot("Sentimento", "negativo");
  126. console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
  127. const samples = [];
  128. let descartadosCurtos = 0;
  129. let descartadosLongos = 0;
  130. for (const av of avaliacoes) {
  131. const atendimento = await Atendimento.query()
  132. .findById(av.AtendimentoId)
  133. .withGraphFetched("mensagens")
  134. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
  135. if (!atendimento) continue;
  136. const mensagens = atendimento.mensagens ?? [];
  137. const messages = conversaParaMessages(atendimento, mensagens);
  138. if (!messages) {
  139. descartadosCurtos += 1;
  140. continue;
  141. }
  142. const chars = messages.reduce((n, m) => n + m.content.length, 0);
  143. if (chars > args.maxChars) {
  144. descartadosLongos += 1;
  145. continue;
  146. }
  147. samples.push({ codigo: atendimento.Codigo, messages });
  148. }
  149. console.log(`${samples.length} amostras válidas (${descartadosCurtos} curtas demais, ${descartadosLongos} longas demais)`);
  150. if (samples.length === 0) {
  151. console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
  152. process.exit(0);
  153. }
  154. const embaralhadas = shuffleDeterministico(samples);
  155. const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
  156. const valid = embaralhadas.slice(0, nValid);
  157. const train = embaralhadas.slice(nValid);
  158. fs.mkdirSync(args.out, { recursive: true });
  159. const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
  160. fs.writeFileSync(path.join(args.out, "train.jsonl"), toJsonl(train));
  161. fs.writeFileSync(path.join(args.out, "valid.jsonl"), toJsonl(valid));
  162. fs.writeFileSync(
  163. path.join(args.out, "manifest.json"),
  164. JSON.stringify(
  165. {
  166. geradoEm: new Date().toISOString(),
  167. filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
  168. train: train.length,
  169. valid: valid.length,
  170. protocolos: embaralhadas.map((s) => s.codigo)
  171. },
  172. null,
  173. 2
  174. )
  175. );
  176. console.log(`Exportado para ${args.out}: train.jsonl (${train.length}) + valid.jsonl (${valid.length})`);
  177. process.exit(0);
  178. }
  179. main().catch((e) => {
  180. console.error("ERRO:", e);
  181. process.exit(1);
  182. });