exportarDatasetTreinamento.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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. // "por-turno" (default, ciclo 5+): várias amostras por atendimento, cada uma terminando num turno
  10. // diferente do atendente. "ultimo-turno" (ciclos 1-4): 1 amostra por atendimento terminando no
  11. // ÚLTIMO turno — mantido pra poder reproduzir os datasets antigos.
  12. const MODOS = ["por-turno", "ultimo-turno"];
  13. function parseArgs(argv) {
  14. const args = {
  15. scoreMin: 8,
  16. incluirParcial: false,
  17. maxChars: 16000,
  18. validFrac: 0.1,
  19. modo: "por-turno",
  20. alvosPorAtendimento: 2,
  21. minPalavrasAlvo: 8,
  22. fracTriviais: 0.15,
  23. maxAlvoRepetido: 30,
  24. out: path.resolve(__dirname, "../training/data")
  25. };
  26. for (let i = 2; i < argv.length; i += 1) {
  27. const a = argv[i];
  28. if (a === "--score-min") args.scoreMin = Number(argv[++i]);
  29. else if (a === "--incluir-parcial") args.incluirParcial = true;
  30. else if (a === "--max-chars") args.maxChars = Number(argv[++i]);
  31. else if (a === "--valid-frac") args.validFrac = Number(argv[++i]);
  32. else if (a === "--modo") args.modo = argv[++i];
  33. else if (a === "--alvos-por-atendimento") args.alvosPorAtendimento = Number(argv[++i]);
  34. else if (a === "--min-palavras-alvo") args.minPalavrasAlvo = Number(argv[++i]);
  35. else if (a === "--frac-triviais") args.fracTriviais = Number(argv[++i]);
  36. else if (a === "--max-alvo-repetido") args.maxAlvoRepetido = Number(argv[++i]);
  37. else if (a === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
  38. }
  39. if (!MODOS.includes(args.modo)) {
  40. throw new Error(`--modo inválido: "${args.modo}" (use ${MODOS.join(" ou ")})`);
  41. }
  42. if (!(args.alvosPorAtendimento >= 1) || !(args.fracTriviais >= 0 && args.fracTriviais < 1) || !(args.maxAlvoRepetido >= 1)) {
  43. throw new Error("--alvos-por-atendimento e --max-alvo-repetido devem ser >= 1; --frac-triviais deve estar em [0, 1)");
  44. }
  45. return args;
  46. }
  47. function systemPrompt(setor) {
  48. const area = SETOR_NOMES[setor] ?? (setor ? setor.toLowerCase() : "atendimento ao cliente");
  49. return [
  50. `Você é um atendente do setor de ${area} da Star Internet, atendendo clientes pelo WhatsApp.`,
  51. "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessárias,",
  52. "explique os passos com clareza e conduza o atendimento até a solução ou encaminhamento correto.",
  53. "Responda em português brasileiro."
  54. ].join(" ");
  55. }
  56. function scrubTelefones(text) {
  57. return text
  58. .replace(/\+?55\s?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}/g, "[telefone]")
  59. .replace(/\(?\d{2}\)?\s9\d{4}[-\s]?\d{4}/g, "[telefone]");
  60. }
  61. // um atendente real dominava tanto os exemplos de encaminhamento ("vou transferir pro colega
  62. // Jonas") que o modelo aprendia esse nome como resposta genérica de "pra quem eu transfiro"
  63. // (145 ocorrências em 4,3% das amostras — achado no teste comparativo do ciclo 2, 2026-09-19).
  64. // Remove o nome específico, mantendo a estrutura da frase.
  65. function scrubNomeColega(text) {
  66. return text
  67. .replace(/(\bcolegas?\s+)[A-ZÀ-Ú][a-zà-úãõç]+,?\s*/gi, "$1")
  68. .replace(/[ \t]{2,}/g, " ");
  69. }
  70. // distingue contato proativo genuíno do atendente (outreach comercial/acompanhamento) de
  71. // mensagens automáticas (triagem do bot, confirmação de protocolo) que também têm Resposta=1
  72. // na primeira linha — achado no teste comparativo do ciclo 3 (2026-09-22): só ~8% dos
  73. // atendimentos marcados "outbound" pela heurística antiga (Resposta===1 na 1a linha) eram
  74. // outreach real; o resto era inbound com a mensagem de abertura genuína não capturada.
  75. const OUTREACH_GENUINO_RE = /sou (a|o) \w+ d[oa]|acompanhamento da sua|referente [àa] sua solicita/i;
  76. // abertura sem conteúdo real (confirmação/ack solto, número puro tipo CPF/telefone/protocolo,
  77. // placeholder de mídia) — sinal de que a mensagem real do cliente não está na amostra; treinar
  78. // com isso ensina o modelo a responder vazio a qualquer coisa curta (mesmo achado do ciclo 3).
  79. const FRAGMENTO_RE = /^(ok|sim|n[ãa]o|certo|obrigad[oa]|blz|beleza|entendi|isso|valeu)[.,!]?$|^[\d\s.\-/]+$|^\[mídia:.*\]$/i;
  80. function isFragmentoSemContexto(texto) {
  81. return FRAGMENTO_RE.test(texto.trim());
  82. }
  83. function ordenarMensagens(mensagens) {
  84. return [...mensagens].sort((a, b) => {
  85. const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
  86. const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
  87. return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
  88. });
  89. }
  90. function mensagemTexto(m) {
  91. const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
  92. const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
  93. const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw)
  94. ? ""
  95. : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw);
  96. let texto;
  97. if (transcricao) {
  98. texto = m.Resposta === 1 ? transcricao : `[áudio] ${transcricao}`;
  99. } else if (m.Tipodemidia && m.Tipodemidia !== "text") {
  100. texto = `[mídia: ${m.Tipodemidia}]`;
  101. } else if (m.Resposta === 1) {
  102. texto = parseAtendenteBody(m.Body).texto;
  103. } else {
  104. texto = stripHtml(m.Body);
  105. }
  106. if (!texto) return null;
  107. texto = scrubTelefones(texto);
  108. if (m.Resposta === 1) texto = scrubNomeColega(texto);
  109. return texto;
  110. }
  111. export function conversaParaMessages(atendimento, mensagens) {
  112. const ordenadas = ordenarMensagens(mensagens);
  113. const primeiraBruta = ordenadas.find((m) => m.Resposta === 0 || m.Resposta === 1);
  114. const outboundGenuino =
  115. primeiraBruta?.Resposta === 1 && OUTREACH_GENUINO_RE.test(mensagemTexto(primeiraBruta) || "");
  116. const turnos = [];
  117. for (const m of ordenadas) {
  118. if (m.Resposta !== 0 && m.Resposta !== 1) continue;
  119. const texto = mensagemTexto(m);
  120. if (!texto) continue;
  121. const role = m.Resposta === 1 ? "assistant" : "user";
  122. const anterior = turnos[turnos.length - 1];
  123. if (anterior && anterior.role === role) anterior.content += `\n${texto}`;
  124. else turnos.push({ role, content: texto });
  125. }
  126. while (turnos.length && turnos[0].role !== "user") turnos.shift();
  127. while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
  128. if (outboundGenuino) {
  129. // contato proativo real do atendente (outreach comercial/acompanhamento): a mensagem de
  130. // abertura que dá o contexto real já foi descartada no trim acima, deixando o primeiro par
  131. // cliente/atendente órfão — ex.: cliente só responde "bom dia" e o atendente já confirma algo
  132. // sem relação visível com a saudação (achado no teste comparativo do ciclo 2, 2026-09-19).
  133. // Descarta só esse primeiro par (não o atendimento inteiro), mantendo a conversa real que
  134. // vier depois. Restrito a outreach genuíno (ver OUTREACH_GENUINO_RE) desde o ciclo 4: a
  135. // heurística antiga (Resposta===1 na 1a linha) capturava também triagem automática do bot e
  136. // confirmação automática de protocolo — nesses casos o splice removia a explicação real do
  137. // cliente em vez de um par órfão (achado no teste comparativo do ciclo 3, 2026-09-22).
  138. turnos.splice(0, 2);
  139. }
  140. // 1o turno do cliente sem conteúdo real (ack solto, número puro, mídia) — a mensagem real que
  141. // abriu o atendimento não está na amostra (comum em atendimentos inbound cuja abertura foi
  142. // consumida por triagem/roteamento do ifbot antes da criação do protocolo). Descarta a amostra
  143. // em vez de ensinar o modelo a responder vazio a qualquer abertura curta (achado do ciclo 3).
  144. if (turnos.length && turnos[0].role === "user" && isFragmentoSemContexto(turnos[0].content)) {
  145. return null;
  146. }
  147. if (turnos.length < 4) return null;
  148. return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
  149. }
  150. function shuffleDeterministico(arr) {
  151. let seed = 42;
  152. const rand = () => {
  153. seed = (seed * 1103515245 + 12345) % 2147483648;
  154. return seed / 2147483648;
  155. };
  156. const out = [...arr];
  157. for (let i = out.length - 1; i > 0; i -= 1) {
  158. const j = Math.floor(rand() * (i + 1));
  159. [out[i], out[j]] = [out[j], out[i]];
  160. }
  161. return out;
  162. }
  163. // PRNG com seed (mulberry32) — o LCG de shuffleDeterministico perde precisão acima de 2^53 e é
  164. // fraco pra sortear turnos por atendimento; ele é mantido intocado só pra reproduzir o modo
  165. // "ultimo-turno" bit a bit.
  166. function mulberry32(seed) {
  167. let a = seed >>> 0;
  168. return () => {
  169. a = (a + 0x6d2b79f5) | 0;
  170. let t = Math.imul(a ^ (a >>> 15), 1 | a);
  171. t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  172. return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  173. };
  174. }
  175. function seedDeTexto(texto) {
  176. let h = 2166136261;
  177. for (let i = 0; i < texto.length; i += 1) {
  178. h ^= texto.charCodeAt(i);
  179. h = Math.imul(h, 16777619);
  180. }
  181. return h >>> 0;
  182. }
  183. function shuffleSeeded(arr, seed) {
  184. const rand = mulberry32(seed);
  185. const out = [...arr];
  186. for (let i = out.length - 1; i > 0; i -= 1) {
  187. const j = Math.floor(rand() * (i + 1));
  188. [out[i], out[j]] = [out[j], out[i]];
  189. }
  190. return out;
  191. }
  192. // O mlx-lm com --mask-prompt só calcula loss no ÚLTIMO turno de cada amostra. Com 1 amostra por
  193. // atendimento terminando no último turno, o alvo virava quase sempre o fechamento ("Disponha!",
  194. // "De nada", "Certo" — mediana de 7 palavras, 43,6% com <=5) e o modelo aprendia a fechar
  195. // conversa em vez de resolver (achado no teste comparativo do ciclo 4, 2026-09-24).
  196. const FECHAMENTO_RE = /encerr(ar|ando)|disponha|qualquer d[uú]vida|[àa] disposi[çc][ãa]o|foi um prazer|de nada|imagina/i;
  197. const LIMITE_PALAVRAS_FECHAMENTO = 12;
  198. function contarPalavras(texto) {
  199. return texto.trim().split(/\s+/).filter(Boolean).length;
  200. }
  201. // "trivial" = ack/fechamento curto (poucas palavras, ou fraseado de fechamento em turno curto);
  202. // "substantivo" = turno em que o atendente de fato responde/conduz algo.
  203. function tipoDoAlvo(texto, minPalavrasAlvo) {
  204. const n = contarPalavras(texto);
  205. if (n < minPalavrasAlvo) return "trivial";
  206. if (n < LIMITE_PALAVRAS_FECHAMENTO && FECHAMENTO_RE.test(texto)) return "trivial";
  207. return "substantivo";
  208. }
  209. // Expande um atendimento (system + turnos user/assistant alternados, começando em user e
  210. // terminando em assistant) em amostras por-turno: cada uma é o prefixo da conversa até um turno do
  211. // atendente. Sorteia (por atendimento, determinístico pelo código) até `alvosPorAtendimento`
  212. // turnos substantivos; os triviais voltam todos e são amostrados globalmente por quem chama.
  213. export function amostrasPorTurno(codigo, messages, { alvosPorAtendimento, minPalavrasAlvo }) {
  214. const [sistema, ...turnos] = messages;
  215. const substantivos = [];
  216. const triviais = [];
  217. for (let i = 1; i < turnos.length; i += 1) {
  218. if (turnos[i].role !== "assistant") continue;
  219. const alvo = { codigo, messages: [sistema, ...turnos.slice(0, i + 1)] };
  220. (tipoDoAlvo(turnos[i].content, minPalavrasAlvo) === "substantivo" ? substantivos : triviais).push(alvo);
  221. }
  222. return {
  223. substantivas: shuffleSeeded(substantivos, seedDeTexto(codigo)).slice(0, alvosPorAtendimento),
  224. triviais
  225. };
  226. }
  227. function totalChars(messages) {
  228. return messages.reduce((n, m) => n + m.content.length, 0);
  229. }
  230. // Gera as amostras de um conjunto de atendimentos (train ou valid). Split por atendimento é feito
  231. // ANTES daqui, então nenhum atendimento aparece nos dois lados.
  232. function expandirConversas(conversas, args, seed) {
  233. const substantivasBrutas = [];
  234. const triviaisBrutos = [];
  235. for (const c of conversas) {
  236. const r = amostrasPorTurno(c.codigo, c.messages, args);
  237. substantivasBrutas.push(...r.substantivas);
  238. triviaisBrutos.push(...r.triviais);
  239. }
  240. // teto por alvo repetido: um mesmo texto de atendente (ex.: "Vou transferir seu atendimento",
  241. // "Certo, pagamento agendado…") com dezenas de ocorrências vira resposta padrão do modelo — mesmo
  242. // mecanismo do nome "Jonas" no ciclo 2. Chave = início normalizado do alvo, então variações
  243. // de um mesmo template contam juntas. A ordem já é embaralhada, então o corte não favorece
  244. // atendimentos específicos.
  245. let descartadosRepetidos = 0;
  246. const limitarRepetidos = (lista) => {
  247. const vistos = new Map();
  248. return lista.filter((s) => {
  249. const chave = s.messages[s.messages.length - 1].content.trim().toLowerCase().slice(0, 40);
  250. const n = vistos.get(chave) ?? 0;
  251. if (n >= args.maxAlvoRepetido) {
  252. descartadosRepetidos += 1;
  253. return false;
  254. }
  255. vistos.set(chave, n + 1);
  256. return true;
  257. });
  258. };
  259. const substantivas = limitarRepetidos(shuffleSeeded(substantivasBrutas, seed + 2));
  260. const triviais = limitarRepetidos(shuffleSeeded(triviaisBrutos, seed));
  261. const maxTriviais = Math.round((substantivas.length * args.fracTriviais) / (1 - args.fracTriviais));
  262. const triviaisSel = triviais.slice(0, maxTriviais);
  263. const dentroDoLimite = (s) => totalChars(s.messages) <= args.maxChars;
  264. const subst = substantivas.filter(dentroDoLimite);
  265. const triv = triviaisSel.filter(dentroDoLimite);
  266. return {
  267. amostras: shuffleSeeded([...subst, ...triv], seed + 1),
  268. nSubstantivas: subst.length,
  269. nTriviais: triv.length,
  270. descartadosLongos: substantivas.length - subst.length + triviaisSel.length - triv.length,
  271. descartadosRepetidos
  272. };
  273. }
  274. async function main() {
  275. const args = parseArgs(process.argv);
  276. const resolvidos = args.incluirParcial ? ["sim", "parcial"] : ["sim"];
  277. const avaliacoes = await AtendimentoAvaliacao.query()
  278. .where("Avaliavel", true)
  279. .where("ScoreAtendente", ">=", args.scoreMin)
  280. .whereIn("Resolvido", resolvidos)
  281. .whereNot("Sentimento", "negativo");
  282. console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
  283. // atendimentos já convertidos em {system, turnos...}; a expansão em amostras depende do --modo
  284. const conversas = [];
  285. // conta tanto turnos<4 quanto abertura fragmentada sem contexto (ver isFragmentoSemContexto)
  286. let descartadosCurtos = 0;
  287. let descartadosLongos = 0;
  288. for (const av of avaliacoes) {
  289. const atendimento = await Atendimento.query()
  290. .findById(av.AtendimentoId)
  291. .withGraphFetched("mensagens")
  292. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
  293. if (!atendimento) continue;
  294. const mensagens = atendimento.mensagens ?? [];
  295. const messages = conversaParaMessages(atendimento, mensagens);
  296. if (!messages) {
  297. descartadosCurtos += 1;
  298. continue;
  299. }
  300. // ultimo-turno: o atendimento inteiro é a amostra, então o teto de caracteres vale pra ele.
  301. // por-turno: o teto vale por amostra (prefixo), aplicado em expandirConversas.
  302. if (args.modo === "ultimo-turno" && totalChars(messages) > args.maxChars) {
  303. descartadosLongos += 1;
  304. continue;
  305. }
  306. conversas.push({ codigo: atendimento.Codigo, messages });
  307. }
  308. console.log(`${conversas.length} atendimentos válidos (${descartadosCurtos} curtos demais/sem contexto${args.modo === "ultimo-turno" ? `, ${descartadosLongos} longos demais` : ""})`);
  309. if (conversas.length === 0) {
  310. console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
  311. process.exit(0);
  312. }
  313. let train;
  314. let valid;
  315. let protocolosTrain;
  316. let protocolosValid;
  317. let resumoModo;
  318. if (args.modo === "ultimo-turno") {
  319. // legado (ciclos 1-4): 1 amostra por atendimento; embaralha e separa como antes, bit a bit
  320. const embaralhadas = shuffleDeterministico(conversas);
  321. const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
  322. valid = embaralhadas.slice(0, nValid);
  323. train = embaralhadas.slice(nValid);
  324. protocolosTrain = train.map((s) => s.codigo);
  325. protocolosValid = valid.map((s) => s.codigo);
  326. resumoModo = { atendimentosTrain: train.length, atendimentosValid: valid.length };
  327. } else {
  328. // por-turno: o split é por ATENDIMENTO (antes de expandir) — senão prefixos do mesmo
  329. // atendimento cairiam em train e valid, e a val loss mediria memorização
  330. const embaralhados = shuffleSeeded(conversas, 42);
  331. const nValid = Math.max(1, Math.round(embaralhados.length * args.validFrac));
  332. const convValid = embaralhados.slice(0, nValid);
  333. const convTrain = embaralhados.slice(nValid);
  334. const t = expandirConversas(convTrain, args, 42);
  335. const v = expandirConversas(convValid, args, 43);
  336. train = t.amostras;
  337. valid = v.amostras;
  338. protocolosTrain = convTrain.map((c) => c.codigo);
  339. protocolosValid = convValid.map((c) => c.codigo);
  340. resumoModo = {
  341. atendimentosTrain: convTrain.length,
  342. atendimentosValid: convValid.length,
  343. substantivasTrain: t.nSubstantivas,
  344. triviaisTrain: t.nTriviais,
  345. substantivasValid: v.nSubstantivas,
  346. triviaisValid: v.nTriviais,
  347. descartadosLongos: t.descartadosLongos + v.descartadosLongos,
  348. descartadosRepetidos: t.descartadosRepetidos + v.descartadosRepetidos
  349. };
  350. console.log(
  351. `train: ${convTrain.length} atendimentos -> ${train.length} amostras (${t.nSubstantivas} substantivas + ${t.nTriviais} triviais) | ` +
  352. `valid: ${convValid.length} atendimentos -> ${valid.length} amostras (${v.nSubstantivas} + ${v.nTriviais}) | ` +
  353. `${resumoModo.descartadosLongos} descartadas por --max-chars, ${resumoModo.descartadosRepetidos} por --max-alvo-repetido`
  354. );
  355. }
  356. fs.mkdirSync(args.out, { recursive: true });
  357. const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
  358. fs.writeFileSync(path.join(args.out, "train.jsonl"), toJsonl(train));
  359. fs.writeFileSync(path.join(args.out, "valid.jsonl"), toJsonl(valid));
  360. fs.writeFileSync(
  361. path.join(args.out, "manifest.json"),
  362. JSON.stringify(
  363. {
  364. geradoEm: new Date().toISOString(),
  365. filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
  366. modo: args.modo,
  367. ...(args.modo === "por-turno" && {
  368. alvosPorAtendimento: args.alvosPorAtendimento,
  369. minPalavrasAlvo: args.minPalavrasAlvo,
  370. fracTriviais: args.fracTriviais,
  371. maxAlvoRepetido: args.maxAlvoRepetido
  372. }),
  373. train: train.length,
  374. valid: valid.length,
  375. ...resumoModo,
  376. // por atendimento (não por amostra) — permite auditar que nenhum protocolo está nos dois lados
  377. protocolosTrain,
  378. protocolosValid
  379. },
  380. null,
  381. 2
  382. )
  383. );
  384. console.log(`Exportado para ${args.out}: train.jsonl (${train.length}) + valid.jsonl (${valid.length})`);
  385. process.exit(0);
  386. }
  387. main().catch((e) => {
  388. console.error("ERRO:", e);
  389. process.exit(1);
  390. });