// Exporta atendimentos bem avaliados (estágio 1) como dataset de SFT em JSONL // no formato chat ({"messages":[...]}), aceito por mlx-lm, unsloth e axolotl. // // Uso: node scripts/exportarDatasetTreinamento.js [opções] // --score-min 8 nota mínima do atendente (default 8) // --incluir-parcial inclui Resolvido="parcial" além de "sim" // --max-chars 16000 descarta conversas maiores que isso (contexto de treino) // --valid-frac 0.1 fração para validação (default 0.1) // --out ../training/data diretório de saída (default backend/training/data) 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 } 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(" "); } // remove telefones do texto (privacidade); preserva números de protocolo/valores curtos 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]"); } function mensagemTexto(m) { const transcricao = typeof m.Transcricao === "string" ? m.Transcricao.trim() : ""; let texto; if (transcricao) { // áudio transcrito pelo ifbot: o Body é só o nome do arquivo texto = `[áudio] ${transcricao}`; } else if (m.Resposta === 1) { texto = parseAtendenteBody(m.Body).texto; } else { texto = stripHtml(m.Body); } if (!texto && m.Tipodemidia && m.Tipodemidia !== "text") { texto = `[mídia: ${m.Tipodemidia}]`; } return texto ? scrubTelefones(texto) : null; } // converte a conversa em turnos user/assistant: 0 = cliente (user), 1 = atendente (assistant); // mensagens de sistema (9) ficam de fora; consecutivas do mesmo papel são mescladas export function conversaParaMessages(atendimento, mensagens) { const ordenadas = [...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); }); 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 }); } // a amostra deve começar no cliente e terminar na resposta do atendente while (turnos.length && turnos[0].role !== "user") turnos.shift(); while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop(); if (turnos.length < 4) return null; // exige pelo menos 2 trocas reais return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos]; } // embaralhamento determinístico para split train/valid reproduzível 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 messages = conversaParaMessages(atendimento, 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); });