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, TRANSCRICAO_SENTINELS } 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(" "); } 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]"); } // um atendente real dominava tanto os exemplos de encaminhamento ("vou transferir pro colega // Jonas") que o modelo aprendia esse nome como resposta genérica de "pra quem eu transfiro" // (145 ocorrências em 4,3% das amostras — achado no teste comparativo do ciclo 2, 2026-09-19). // Remove o nome específico, mantendo a estrutura da frase. function scrubNomeColega(text) { return text .replace(/(\bcolegas?\s+)[A-ZÀ-Ú][a-zà-úãõç]+,?\s*/gi, "$1") .replace(/[ \t]{2,}/g, " "); } function ordenarMensagens(mensagens) { return [...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); }); } function mensagemTexto(m) { const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : ""; const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : ""; const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw) ? "" : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw); let texto; if (transcricao) { texto = m.Resposta === 1 ? transcricao : `[áudio] ${transcricao}`; } else if (m.Tipodemidia && m.Tipodemidia !== "text") { texto = `[mídia: ${m.Tipodemidia}]`; } else if (m.Resposta === 1) { texto = parseAtendenteBody(m.Body).texto; } else { texto = stripHtml(m.Body); } if (!texto) return null; texto = scrubTelefones(texto); if (m.Resposta === 1) texto = scrubNomeColega(texto); return texto; } export function conversaParaMessages(atendimento, mensagens) { const ordenadas = ordenarMensagens(mensagens); const primeiraBruta = ordenadas.find((m) => m.Resposta === 0 || m.Resposta === 1); const outbound = primeiraBruta?.Resposta === 1; 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 }); } while (turnos.length && turnos[0].role !== "user") turnos.shift(); while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop(); if (outbound) { // atendimento iniciado pelo atendente (contato proativo): a mensagem de abertura que dá // o contexto real já foi descartada no trim acima, deixando o primeiro par cliente/atendente // órfão — ex.: cliente só responde "bom dia" e o atendente já confirma algo sem relação // visível com a saudação (achado no teste comparativo do ciclo 2, 2026-09-19). Descarta só // esse primeiro par (não o atendimento inteiro), mantendo a conversa real que vier depois. turnos.splice(0, 2); } if (turnos.length < 4) return null; return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos]; } 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 mensagens = atendimento.mensagens ?? []; const messages = conversaParaMessages(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); });