exportarDatasetTreinamento.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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. // remove telefones do texto (privacidade); preserva números de protocolo/valores curtos
  37. function scrubTelefones(text) {
  38. return text
  39. .replace(/\+?55\s?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}/g, "[telefone]")
  40. .replace(/\(?\d{2}\)?\s9\d{4}[-\s]?\d{4}/g, "[telefone]");
  41. }
  42. function mensagemTexto(m) {
  43. const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
  44. const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
  45. // ifbot cobre só ~22% dos áudios de atendente; o fallback Whisper local cobre a maior
  46. // parte do resto (~95% combinado) — sem checar os dois, a maioria dos áudios cai no
  47. // placeholder `[mídia: ...]` mesmo já tendo transcrição disponível no banco.
  48. const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw)
  49. ? ""
  50. : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw);
  51. let texto;
  52. if (transcricao) {
  53. // áudio transcrito (ifbot ou fallback Whisper local): o Body é só o nome do arquivo
  54. texto = `[áudio] ${transcricao}`;
  55. } else if (m.Tipodemidia && m.Tipodemidia !== "text") {
  56. // Body de mídia sem transcrição é só o caminho do arquivo (ex.: /data/aud/x.mp3), não texto
  57. // real — sempre usa o placeholder aqui, em vez de só quando o stripHtml sair vazio.
  58. texto = `[mídia: ${m.Tipodemidia}]`;
  59. } else if (m.Resposta === 1) {
  60. texto = parseAtendenteBody(m.Body).texto;
  61. } else {
  62. texto = stripHtml(m.Body);
  63. }
  64. return texto ? scrubTelefones(texto) : null;
  65. }
  66. // converte a conversa em turnos user/assistant: 0 = cliente (user), 1 = atendente (assistant);
  67. // mensagens de sistema (9) ficam de fora; consecutivas do mesmo papel são mescladas
  68. export function conversaParaMessages(atendimento, mensagens) {
  69. const ordenadas = [...mensagens].sort((a, b) => {
  70. const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
  71. const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
  72. return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
  73. });
  74. const turnos = [];
  75. for (const m of ordenadas) {
  76. if (m.Resposta !== 0 && m.Resposta !== 1) continue;
  77. const texto = mensagemTexto(m);
  78. if (!texto) continue;
  79. const role = m.Resposta === 1 ? "assistant" : "user";
  80. const anterior = turnos[turnos.length - 1];
  81. if (anterior && anterior.role === role) anterior.content += `\n${texto}`;
  82. else turnos.push({ role, content: texto });
  83. }
  84. // a amostra deve começar no cliente e terminar na resposta do atendente
  85. while (turnos.length && turnos[0].role !== "user") turnos.shift();
  86. while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
  87. if (turnos.length < 4) return null; // exige pelo menos 2 trocas reais
  88. return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
  89. }
  90. // embaralhamento determinístico para split train/valid reproduzível
  91. function shuffleDeterministico(arr) {
  92. let seed = 42;
  93. const rand = () => {
  94. seed = (seed * 1103515245 + 12345) % 2147483648;
  95. return seed / 2147483648;
  96. };
  97. const out = [...arr];
  98. for (let i = out.length - 1; i > 0; i -= 1) {
  99. const j = Math.floor(rand() * (i + 1));
  100. [out[i], out[j]] = [out[j], out[i]];
  101. }
  102. return out;
  103. }
  104. async function main() {
  105. const args = parseArgs(process.argv);
  106. const resolvidos = args.incluirParcial ? ["sim", "parcial"] : ["sim"];
  107. const avaliacoes = await AtendimentoAvaliacao.query()
  108. .where("Avaliavel", true)
  109. .where("ScoreAtendente", ">=", args.scoreMin)
  110. .whereIn("Resolvido", resolvidos)
  111. .whereNot("Sentimento", "negativo");
  112. console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
  113. const samples = [];
  114. let descartadosCurtos = 0;
  115. let descartadosLongos = 0;
  116. for (const av of avaliacoes) {
  117. const atendimento = await Atendimento.query()
  118. .findById(av.AtendimentoId)
  119. .withGraphFetched("mensagens")
  120. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
  121. if (!atendimento) continue;
  122. const messages = conversaParaMessages(atendimento, atendimento.mensagens ?? []);
  123. if (!messages) {
  124. descartadosCurtos += 1;
  125. continue;
  126. }
  127. const chars = messages.reduce((n, m) => n + m.content.length, 0);
  128. if (chars > args.maxChars) {
  129. descartadosLongos += 1;
  130. continue;
  131. }
  132. samples.push({ codigo: atendimento.Codigo, messages });
  133. }
  134. console.log(`${samples.length} amostras válidas (${descartadosCurtos} curtas demais, ${descartadosLongos} longas demais)`);
  135. if (samples.length === 0) {
  136. console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
  137. process.exit(0);
  138. }
  139. const embaralhadas = shuffleDeterministico(samples);
  140. const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
  141. const valid = embaralhadas.slice(0, nValid);
  142. const train = embaralhadas.slice(nValid);
  143. fs.mkdirSync(args.out, { recursive: true });
  144. const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
  145. fs.writeFileSync(path.join(args.out, "train.jsonl"), toJsonl(train));
  146. fs.writeFileSync(path.join(args.out, "valid.jsonl"), toJsonl(valid));
  147. fs.writeFileSync(
  148. path.join(args.out, "manifest.json"),
  149. JSON.stringify(
  150. {
  151. geradoEm: new Date().toISOString(),
  152. filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
  153. train: train.length,
  154. valid: valid.length,
  155. protocolos: embaralhadas.map((s) => s.codigo)
  156. },
  157. null,
  158. 2
  159. )
  160. );
  161. console.log(`Exportado para ${args.out}: train.jsonl (${train.length}) + valid.jsonl (${valid.length})`);
  162. process.exit(0);
  163. }
  164. main().catch((e) => {
  165. console.error("ERRO:", e);
  166. process.exit(1);
  167. });