exportarDatasetTreinamento.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. // Exporta atendimentos bem avaliados (estágio 1) como dataset de SFT em JSONL
  2. // no formato chat ({"messages":[...]}), aceito por mlx-lm, unsloth e axolotl.
  3. //
  4. // Uso: node scripts/exportarDatasetTreinamento.js [opções]
  5. // --score-min 8 nota mínima do atendente (default 8)
  6. // --incluir-parcial inclui Resolvido="parcial" além de "sim"
  7. // --max-chars 16000 descarta conversas maiores que isso (contexto de treino)
  8. // --valid-frac 0.1 fração para validação (default 0.1)
  9. // --out ../training/data diretório de saída (default training/data na raiz do repo)
  10. import fs from "node:fs";
  11. import path from "node:path";
  12. import { fileURLToPath } from "node:url";
  13. import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
  14. import { Atendimento } from "../src/models/Atendimento.model.js";
  15. import { stripHtml, parseAtendenteBody } from "../src/utils/atendimentoFormat.js";
  16. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  17. function parseArgs(argv) {
  18. const args = {
  19. scoreMin: 8,
  20. incluirParcial: false,
  21. maxChars: 16000,
  22. validFrac: 0.1,
  23. out: path.resolve(__dirname, "../../training/data")
  24. };
  25. for (let i = 2; i < argv.length; i += 1) {
  26. const a = argv[i];
  27. if (a === "--score-min") args.scoreMin = Number(argv[++i]);
  28. else if (a === "--incluir-parcial") args.incluirParcial = true;
  29. else if (a === "--max-chars") args.maxChars = Number(argv[++i]);
  30. else if (a === "--valid-frac") args.validFrac = Number(argv[++i]);
  31. else if (a === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
  32. }
  33. return args;
  34. }
  35. const SETOR_NOMES = {
  36. SUP: "suporte técnico",
  37. FIN: "financeiro",
  38. ATE: "atendimento geral",
  39. VEN: "vendas",
  40. POS: "pós-venda",
  41. PLA: "planos",
  42. SUI: "suporte interno"
  43. };
  44. function systemPrompt(setor) {
  45. const area = SETOR_NOMES[setor] ?? (setor ? setor.toLowerCase() : "atendimento ao cliente");
  46. return [
  47. `Você é um atendente do setor de ${area} da Star Internet, atendendo clientes pelo WhatsApp.`,
  48. "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessárias,",
  49. "explique os passos com clareza e conduza o atendimento até a solução ou encaminhamento correto.",
  50. "Responda em português brasileiro."
  51. ].join(" ");
  52. }
  53. // remove telefones do texto (privacidade); preserva números de protocolo/valores curtos
  54. function scrubTelefones(text) {
  55. return text
  56. .replace(/\+?55\s?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}/g, "[telefone]")
  57. .replace(/\(?\d{2}\)?\s9\d{4}[-\s]?\d{4}/g, "[telefone]");
  58. }
  59. function mensagemTexto(m) {
  60. const transcricao = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
  61. let texto;
  62. if (transcricao) {
  63. // áudio transcrito pelo ifbot: o Body é só o nome do arquivo
  64. texto = `[áudio] ${transcricao}`;
  65. } else if (m.Resposta === 1) {
  66. texto = parseAtendenteBody(m.Body).texto;
  67. } else {
  68. texto = stripHtml(m.Body);
  69. }
  70. if (!texto && m.Tipodemidia && m.Tipodemidia !== "text") {
  71. texto = `[mídia: ${m.Tipodemidia}]`;
  72. }
  73. return texto ? scrubTelefones(texto) : null;
  74. }
  75. // converte a conversa em turnos user/assistant: 0 = cliente (user), 1 = atendente (assistant);
  76. // mensagens de sistema (9) ficam de fora; consecutivas do mesmo papel são mescladas
  77. export function conversaParaMessages(atendimento, mensagens) {
  78. const ordenadas = [...mensagens].sort((a, b) => {
  79. const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
  80. const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
  81. return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
  82. });
  83. const turnos = [];
  84. for (const m of ordenadas) {
  85. if (m.Resposta !== 0 && m.Resposta !== 1) continue;
  86. const texto = mensagemTexto(m);
  87. if (!texto) continue;
  88. const role = m.Resposta === 1 ? "assistant" : "user";
  89. const anterior = turnos[turnos.length - 1];
  90. if (anterior && anterior.role === role) anterior.content += `\n${texto}`;
  91. else turnos.push({ role, content: texto });
  92. }
  93. // a amostra deve começar no cliente e terminar na resposta do atendente
  94. while (turnos.length && turnos[0].role !== "user") turnos.shift();
  95. while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
  96. if (turnos.length < 4) return null; // exige pelo menos 2 trocas reais
  97. return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
  98. }
  99. // embaralhamento determinístico para split train/valid reproduzível
  100. function shuffleDeterministico(arr) {
  101. let seed = 42;
  102. const rand = () => {
  103. seed = (seed * 1103515245 + 12345) % 2147483648;
  104. return seed / 2147483648;
  105. };
  106. const out = [...arr];
  107. for (let i = out.length - 1; i > 0; i -= 1) {
  108. const j = Math.floor(rand() * (i + 1));
  109. [out[i], out[j]] = [out[j], out[i]];
  110. }
  111. return out;
  112. }
  113. async function main() {
  114. const args = parseArgs(process.argv);
  115. const resolvidos = args.incluirParcial ? ["sim", "parcial"] : ["sim"];
  116. const avaliacoes = await AtendimentoAvaliacao.query()
  117. .where("Avaliavel", true)
  118. .where("ScoreAtendente", ">=", args.scoreMin)
  119. .whereIn("Resolvido", resolvidos)
  120. .whereNot("Sentimento", "negativo");
  121. console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
  122. const samples = [];
  123. let descartadosCurtos = 0;
  124. let descartadosLongos = 0;
  125. for (const av of avaliacoes) {
  126. const atendimento = await Atendimento.query()
  127. .findById(av.AtendimentoId)
  128. .withGraphFetched("mensagens")
  129. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
  130. if (!atendimento) continue;
  131. const messages = conversaParaMessages(atendimento, atendimento.mensagens ?? []);
  132. if (!messages) {
  133. descartadosCurtos += 1;
  134. continue;
  135. }
  136. const chars = messages.reduce((n, m) => n + m.content.length, 0);
  137. if (chars > args.maxChars) {
  138. descartadosLongos += 1;
  139. continue;
  140. }
  141. samples.push({ codigo: atendimento.Codigo, messages });
  142. }
  143. console.log(`${samples.length} amostras válidas (${descartadosCurtos} curtas demais, ${descartadosLongos} longas demais)`);
  144. if (samples.length === 0) {
  145. console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
  146. process.exit(0);
  147. }
  148. const embaralhadas = shuffleDeterministico(samples);
  149. const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
  150. const valid = embaralhadas.slice(0, nValid);
  151. const train = embaralhadas.slice(nValid);
  152. fs.mkdirSync(args.out, { recursive: true });
  153. const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
  154. fs.writeFileSync(path.join(args.out, "train.jsonl"), toJsonl(train));
  155. fs.writeFileSync(path.join(args.out, "valid.jsonl"), toJsonl(valid));
  156. fs.writeFileSync(
  157. path.join(args.out, "manifest.json"),
  158. JSON.stringify(
  159. {
  160. geradoEm: new Date().toISOString(),
  161. filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
  162. train: train.length,
  163. valid: valid.length,
  164. protocolos: embaralhadas.map((s) => s.codigo)
  165. },
  166. null,
  167. 2
  168. )
  169. );
  170. console.log(`Exportado para ${args.out}: train.jsonl (${train.length}) + valid.jsonl (${valid.length})`);
  171. process.exit(0);
  172. }
  173. main().catch((e) => {
  174. console.error("ERRO:", e);
  175. process.exit(1);
  176. });