|
@@ -8,12 +8,22 @@ import { SETOR_NOMES } from "../src/config/setores.js";
|
|
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
|
|
|
|
|
+// "por-turno" (default, ciclo 5+): várias amostras por atendimento, cada uma terminando num turno
|
|
|
|
|
+// diferente do atendente. "ultimo-turno" (ciclos 1-4): 1 amostra por atendimento terminando no
|
|
|
|
|
+// ÚLTIMO turno — mantido pra poder reproduzir os datasets antigos.
|
|
|
|
|
+const MODOS = ["por-turno", "ultimo-turno"];
|
|
|
|
|
+
|
|
|
function parseArgs(argv) {
|
|
function parseArgs(argv) {
|
|
|
const args = {
|
|
const args = {
|
|
|
scoreMin: 8,
|
|
scoreMin: 8,
|
|
|
incluirParcial: false,
|
|
incluirParcial: false,
|
|
|
maxChars: 16000,
|
|
maxChars: 16000,
|
|
|
validFrac: 0.1,
|
|
validFrac: 0.1,
|
|
|
|
|
+ modo: "por-turno",
|
|
|
|
|
+ alvosPorAtendimento: 2,
|
|
|
|
|
+ minPalavrasAlvo: 8,
|
|
|
|
|
+ fracTriviais: 0.15,
|
|
|
|
|
+ maxAlvoRepetido: 30,
|
|
|
out: path.resolve(__dirname, "../training/data")
|
|
out: path.resolve(__dirname, "../training/data")
|
|
|
};
|
|
};
|
|
|
for (let i = 2; i < argv.length; i += 1) {
|
|
for (let i = 2; i < argv.length; i += 1) {
|
|
@@ -22,8 +32,19 @@ function parseArgs(argv) {
|
|
|
else if (a === "--incluir-parcial") args.incluirParcial = true;
|
|
else if (a === "--incluir-parcial") args.incluirParcial = true;
|
|
|
else if (a === "--max-chars") args.maxChars = Number(argv[++i]);
|
|
else if (a === "--max-chars") args.maxChars = Number(argv[++i]);
|
|
|
else if (a === "--valid-frac") args.validFrac = Number(argv[++i]);
|
|
else if (a === "--valid-frac") args.validFrac = Number(argv[++i]);
|
|
|
|
|
+ else if (a === "--modo") args.modo = argv[++i];
|
|
|
|
|
+ else if (a === "--alvos-por-atendimento") args.alvosPorAtendimento = Number(argv[++i]);
|
|
|
|
|
+ else if (a === "--min-palavras-alvo") args.minPalavrasAlvo = Number(argv[++i]);
|
|
|
|
|
+ else if (a === "--frac-triviais") args.fracTriviais = Number(argv[++i]);
|
|
|
|
|
+ else if (a === "--max-alvo-repetido") args.maxAlvoRepetido = Number(argv[++i]);
|
|
|
else if (a === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
|
|
else if (a === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
|
|
|
}
|
|
}
|
|
|
|
|
+ if (!MODOS.includes(args.modo)) {
|
|
|
|
|
+ throw new Error(`--modo inválido: "${args.modo}" (use ${MODOS.join(" ou ")})`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!(args.alvosPorAtendimento >= 1) || !(args.fracTriviais >= 0 && args.fracTriviais < 1) || !(args.maxAlvoRepetido >= 1)) {
|
|
|
|
|
+ throw new Error("--alvos-por-atendimento e --max-alvo-repetido devem ser >= 1; --frac-triviais deve estar em [0, 1)");
|
|
|
|
|
+ }
|
|
|
return args;
|
|
return args;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -53,6 +74,21 @@ function scrubNomeColega(text) {
|
|
|
.replace(/[ \t]{2,}/g, " ");
|
|
.replace(/[ \t]{2,}/g, " ");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// distingue contato proativo genuíno do atendente (outreach comercial/acompanhamento) de
|
|
|
|
|
+// mensagens automáticas (triagem do bot, confirmação de protocolo) que também têm Resposta=1
|
|
|
|
|
+// na primeira linha — achado no teste comparativo do ciclo 3 (2026-09-22): só ~8% dos
|
|
|
|
|
+// atendimentos marcados "outbound" pela heurística antiga (Resposta===1 na 1a linha) eram
|
|
|
|
|
+// outreach real; o resto era inbound com a mensagem de abertura genuína não capturada.
|
|
|
|
|
+const OUTREACH_GENUINO_RE = /sou (a|o) \w+ d[oa]|acompanhamento da sua|referente [àa] sua solicita/i;
|
|
|
|
|
+
|
|
|
|
|
+// abertura sem conteúdo real (confirmação/ack solto, número puro tipo CPF/telefone/protocolo,
|
|
|
|
|
+// placeholder de mídia) — sinal de que a mensagem real do cliente não está na amostra; treinar
|
|
|
|
|
+// com isso ensina o modelo a responder vazio a qualquer coisa curta (mesmo achado do ciclo 3).
|
|
|
|
|
+const FRAGMENTO_RE = /^(ok|sim|n[ãa]o|certo|obrigad[oa]|blz|beleza|entendi|isso|valeu)[.,!]?$|^[\d\s.\-/]+$|^\[mídia:.*\]$/i;
|
|
|
|
|
+function isFragmentoSemContexto(texto) {
|
|
|
|
|
+ return FRAGMENTO_RE.test(texto.trim());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function ordenarMensagens(mensagens) {
|
|
function ordenarMensagens(mensagens) {
|
|
|
return [...mensagens].sort((a, b) => {
|
|
return [...mensagens].sort((a, b) => {
|
|
|
const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
|
|
const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
|
|
@@ -90,7 +126,8 @@ function mensagemTexto(m) {
|
|
|
export function conversaParaMessages(atendimento, mensagens) {
|
|
export function conversaParaMessages(atendimento, mensagens) {
|
|
|
const ordenadas = ordenarMensagens(mensagens);
|
|
const ordenadas = ordenarMensagens(mensagens);
|
|
|
const primeiraBruta = ordenadas.find((m) => m.Resposta === 0 || m.Resposta === 1);
|
|
const primeiraBruta = ordenadas.find((m) => m.Resposta === 0 || m.Resposta === 1);
|
|
|
- const outbound = primeiraBruta?.Resposta === 1;
|
|
|
|
|
|
|
+ const outboundGenuino =
|
|
|
|
|
+ primeiraBruta?.Resposta === 1 && OUTREACH_GENUINO_RE.test(mensagemTexto(primeiraBruta) || "");
|
|
|
|
|
|
|
|
const turnos = [];
|
|
const turnos = [];
|
|
|
for (const m of ordenadas) {
|
|
for (const m of ordenadas) {
|
|
@@ -106,15 +143,27 @@ export function conversaParaMessages(atendimento, mensagens) {
|
|
|
while (turnos.length && turnos[0].role !== "user") turnos.shift();
|
|
while (turnos.length && turnos[0].role !== "user") turnos.shift();
|
|
|
while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
|
|
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.
|
|
|
|
|
|
|
+ if (outboundGenuino) {
|
|
|
|
|
+ // contato proativo real do atendente (outreach comercial/acompanhamento): 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. Restrito a outreach genuíno (ver OUTREACH_GENUINO_RE) desde o ciclo 4: a
|
|
|
|
|
+ // heurística antiga (Resposta===1 na 1a linha) capturava também triagem automática do bot e
|
|
|
|
|
+ // confirmação automática de protocolo — nesses casos o splice removia a explicação real do
|
|
|
|
|
+ // cliente em vez de um par órfão (achado no teste comparativo do ciclo 3, 2026-09-22).
|
|
|
turnos.splice(0, 2);
|
|
turnos.splice(0, 2);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 1o turno do cliente sem conteúdo real (ack solto, número puro, mídia) — a mensagem real que
|
|
|
|
|
+ // abriu o atendimento não está na amostra (comum em atendimentos inbound cuja abertura foi
|
|
|
|
|
+ // consumida por triagem/roteamento do ifbot antes da criação do protocolo). Descarta a amostra
|
|
|
|
|
+ // em vez de ensinar o modelo a responder vazio a qualquer abertura curta (achado do ciclo 3).
|
|
|
|
|
+ if (turnos.length && turnos[0].role === "user" && isFragmentoSemContexto(turnos[0].content)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if (turnos.length < 4) return null;
|
|
if (turnos.length < 4) return null;
|
|
|
|
|
|
|
|
return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
|
|
return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
|
|
@@ -134,6 +183,129 @@ function shuffleDeterministico(arr) {
|
|
|
return out;
|
|
return out;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// PRNG com seed (mulberry32) — o LCG de shuffleDeterministico perde precisão acima de 2^53 e é
|
|
|
|
|
+// fraco pra sortear turnos por atendimento; ele é mantido intocado só pra reproduzir o modo
|
|
|
|
|
+// "ultimo-turno" bit a bit.
|
|
|
|
|
+function mulberry32(seed) {
|
|
|
|
|
+ let a = seed >>> 0;
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ a = (a + 0x6d2b79f5) | 0;
|
|
|
|
|
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
|
|
|
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
|
|
|
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function seedDeTexto(texto) {
|
|
|
|
|
+ let h = 2166136261;
|
|
|
|
|
+ for (let i = 0; i < texto.length; i += 1) {
|
|
|
|
|
+ h ^= texto.charCodeAt(i);
|
|
|
|
|
+ h = Math.imul(h, 16777619);
|
|
|
|
|
+ }
|
|
|
|
|
+ return h >>> 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function shuffleSeeded(arr, seed) {
|
|
|
|
|
+ const rand = mulberry32(seed);
|
|
|
|
|
+ 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;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// O mlx-lm com --mask-prompt só calcula loss no ÚLTIMO turno de cada amostra. Com 1 amostra por
|
|
|
|
|
+// atendimento terminando no último turno, o alvo virava quase sempre o fechamento ("Disponha!",
|
|
|
|
|
+// "De nada", "Certo" — mediana de 7 palavras, 43,6% com <=5) e o modelo aprendia a fechar
|
|
|
|
|
+// conversa em vez de resolver (achado no teste comparativo do ciclo 4, 2026-09-24).
|
|
|
|
|
+const FECHAMENTO_RE = /encerr(ar|ando)|disponha|qualquer d[uú]vida|[àa] disposi[çc][ãa]o|foi um prazer|de nada|imagina/i;
|
|
|
|
|
+const LIMITE_PALAVRAS_FECHAMENTO = 12;
|
|
|
|
|
+
|
|
|
|
|
+function contarPalavras(texto) {
|
|
|
|
|
+ return texto.trim().split(/\s+/).filter(Boolean).length;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// "trivial" = ack/fechamento curto (poucas palavras, ou fraseado de fechamento em turno curto);
|
|
|
|
|
+// "substantivo" = turno em que o atendente de fato responde/conduz algo.
|
|
|
|
|
+function tipoDoAlvo(texto, minPalavrasAlvo) {
|
|
|
|
|
+ const n = contarPalavras(texto);
|
|
|
|
|
+ if (n < minPalavrasAlvo) return "trivial";
|
|
|
|
|
+ if (n < LIMITE_PALAVRAS_FECHAMENTO && FECHAMENTO_RE.test(texto)) return "trivial";
|
|
|
|
|
+ return "substantivo";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Expande um atendimento (system + turnos user/assistant alternados, começando em user e
|
|
|
|
|
+// terminando em assistant) em amostras por-turno: cada uma é o prefixo da conversa até um turno do
|
|
|
|
|
+// atendente. Sorteia (por atendimento, determinístico pelo código) até `alvosPorAtendimento`
|
|
|
|
|
+// turnos substantivos; os triviais voltam todos e são amostrados globalmente por quem chama.
|
|
|
|
|
+export function amostrasPorTurno(codigo, messages, { alvosPorAtendimento, minPalavrasAlvo }) {
|
|
|
|
|
+ const [sistema, ...turnos] = messages;
|
|
|
|
|
+ const substantivos = [];
|
|
|
|
|
+ const triviais = [];
|
|
|
|
|
+ for (let i = 1; i < turnos.length; i += 1) {
|
|
|
|
|
+ if (turnos[i].role !== "assistant") continue;
|
|
|
|
|
+ const alvo = { codigo, messages: [sistema, ...turnos.slice(0, i + 1)] };
|
|
|
|
|
+ (tipoDoAlvo(turnos[i].content, minPalavrasAlvo) === "substantivo" ? substantivos : triviais).push(alvo);
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ substantivas: shuffleSeeded(substantivos, seedDeTexto(codigo)).slice(0, alvosPorAtendimento),
|
|
|
|
|
+ triviais
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function totalChars(messages) {
|
|
|
|
|
+ return messages.reduce((n, m) => n + m.content.length, 0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Gera as amostras de um conjunto de atendimentos (train ou valid). Split por atendimento é feito
|
|
|
|
|
+// ANTES daqui, então nenhum atendimento aparece nos dois lados.
|
|
|
|
|
+function expandirConversas(conversas, args, seed) {
|
|
|
|
|
+ const substantivasBrutas = [];
|
|
|
|
|
+ const triviaisBrutos = [];
|
|
|
|
|
+ for (const c of conversas) {
|
|
|
|
|
+ const r = amostrasPorTurno(c.codigo, c.messages, args);
|
|
|
|
|
+ substantivasBrutas.push(...r.substantivas);
|
|
|
|
|
+ triviaisBrutos.push(...r.triviais);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // teto por alvo repetido: um mesmo texto de atendente (ex.: "Vou transferir seu atendimento",
|
|
|
|
|
+ // "Certo, pagamento agendado…") com dezenas de ocorrências vira resposta padrão do modelo — mesmo
|
|
|
|
|
+ // mecanismo do nome "Jonas" no ciclo 2. Chave = início normalizado do alvo, então variações
|
|
|
|
|
+ // de um mesmo template contam juntas. A ordem já é embaralhada, então o corte não favorece
|
|
|
|
|
+ // atendimentos específicos.
|
|
|
|
|
+ let descartadosRepetidos = 0;
|
|
|
|
|
+ const limitarRepetidos = (lista) => {
|
|
|
|
|
+ const vistos = new Map();
|
|
|
|
|
+ return lista.filter((s) => {
|
|
|
|
|
+ const chave = s.messages[s.messages.length - 1].content.trim().toLowerCase().slice(0, 40);
|
|
|
|
|
+ const n = vistos.get(chave) ?? 0;
|
|
|
|
|
+ if (n >= args.maxAlvoRepetido) {
|
|
|
|
|
+ descartadosRepetidos += 1;
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ vistos.set(chave, n + 1);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const substantivas = limitarRepetidos(shuffleSeeded(substantivasBrutas, seed + 2));
|
|
|
|
|
+ const triviais = limitarRepetidos(shuffleSeeded(triviaisBrutos, seed));
|
|
|
|
|
+ const maxTriviais = Math.round((substantivas.length * args.fracTriviais) / (1 - args.fracTriviais));
|
|
|
|
|
+ const triviaisSel = triviais.slice(0, maxTriviais);
|
|
|
|
|
+
|
|
|
|
|
+ const dentroDoLimite = (s) => totalChars(s.messages) <= args.maxChars;
|
|
|
|
|
+ const subst = substantivas.filter(dentroDoLimite);
|
|
|
|
|
+ const triv = triviaisSel.filter(dentroDoLimite);
|
|
|
|
|
+ return {
|
|
|
|
|
+ amostras: shuffleSeeded([...subst, ...triv], seed + 1),
|
|
|
|
|
+ nSubstantivas: subst.length,
|
|
|
|
|
+ nTriviais: triv.length,
|
|
|
|
|
+ descartadosLongos: substantivas.length - subst.length + triviaisSel.length - triv.length,
|
|
|
|
|
+ descartadosRepetidos
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function main() {
|
|
async function main() {
|
|
|
const args = parseArgs(process.argv);
|
|
const args = parseArgs(process.argv);
|
|
|
|
|
|
|
@@ -146,7 +318,9 @@ async function main() {
|
|
|
|
|
|
|
|
console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
|
|
console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
|
|
|
|
|
|
|
|
- const samples = [];
|
|
|
|
|
|
|
+ // atendimentos já convertidos em {system, turnos...}; a expansão em amostras depende do --modo
|
|
|
|
|
+ const conversas = [];
|
|
|
|
|
+ // conta tanto turnos<4 quanto abertura fragmentada sem contexto (ver isFragmentoSemContexto)
|
|
|
let descartadosCurtos = 0;
|
|
let descartadosCurtos = 0;
|
|
|
let descartadosLongos = 0;
|
|
let descartadosLongos = 0;
|
|
|
|
|
|
|
@@ -163,24 +337,65 @@ async function main() {
|
|
|
descartadosCurtos += 1;
|
|
descartadosCurtos += 1;
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- const chars = messages.reduce((n, m) => n + m.content.length, 0);
|
|
|
|
|
- if (chars > args.maxChars) {
|
|
|
|
|
|
|
+ // ultimo-turno: o atendimento inteiro é a amostra, então o teto de caracteres vale pra ele.
|
|
|
|
|
+ // por-turno: o teto vale por amostra (prefixo), aplicado em expandirConversas.
|
|
|
|
|
+ if (args.modo === "ultimo-turno" && totalChars(messages) > args.maxChars) {
|
|
|
descartadosLongos += 1;
|
|
descartadosLongos += 1;
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- samples.push({ codigo: atendimento.Codigo, messages });
|
|
|
|
|
|
|
+ conversas.push({ codigo: atendimento.Codigo, messages });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- console.log(`${samples.length} amostras válidas (${descartadosCurtos} curtas demais, ${descartadosLongos} longas demais)`);
|
|
|
|
|
- if (samples.length === 0) {
|
|
|
|
|
|
|
+ console.log(`${conversas.length} atendimentos válidos (${descartadosCurtos} curtos demais/sem contexto${args.modo === "ultimo-turno" ? `, ${descartadosLongos} longos demais` : ""})`);
|
|
|
|
|
+ if (conversas.length === 0) {
|
|
|
console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
|
|
console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
|
|
|
process.exit(0);
|
|
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);
|
|
|
|
|
|
|
+ let train;
|
|
|
|
|
+ let valid;
|
|
|
|
|
+ let protocolosTrain;
|
|
|
|
|
+ let protocolosValid;
|
|
|
|
|
+ let resumoModo;
|
|
|
|
|
+
|
|
|
|
|
+ if (args.modo === "ultimo-turno") {
|
|
|
|
|
+ // legado (ciclos 1-4): 1 amostra por atendimento; embaralha e separa como antes, bit a bit
|
|
|
|
|
+ const embaralhadas = shuffleDeterministico(conversas);
|
|
|
|
|
+ const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
|
|
|
|
|
+ valid = embaralhadas.slice(0, nValid);
|
|
|
|
|
+ train = embaralhadas.slice(nValid);
|
|
|
|
|
+ protocolosTrain = train.map((s) => s.codigo);
|
|
|
|
|
+ protocolosValid = valid.map((s) => s.codigo);
|
|
|
|
|
+ resumoModo = { atendimentosTrain: train.length, atendimentosValid: valid.length };
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // por-turno: o split é por ATENDIMENTO (antes de expandir) — senão prefixos do mesmo
|
|
|
|
|
+ // atendimento cairiam em train e valid, e a val loss mediria memorização
|
|
|
|
|
+ const embaralhados = shuffleSeeded(conversas, 42);
|
|
|
|
|
+ const nValid = Math.max(1, Math.round(embaralhados.length * args.validFrac));
|
|
|
|
|
+ const convValid = embaralhados.slice(0, nValid);
|
|
|
|
|
+ const convTrain = embaralhados.slice(nValid);
|
|
|
|
|
+ const t = expandirConversas(convTrain, args, 42);
|
|
|
|
|
+ const v = expandirConversas(convValid, args, 43);
|
|
|
|
|
+ train = t.amostras;
|
|
|
|
|
+ valid = v.amostras;
|
|
|
|
|
+ protocolosTrain = convTrain.map((c) => c.codigo);
|
|
|
|
|
+ protocolosValid = convValid.map((c) => c.codigo);
|
|
|
|
|
+ resumoModo = {
|
|
|
|
|
+ atendimentosTrain: convTrain.length,
|
|
|
|
|
+ atendimentosValid: convValid.length,
|
|
|
|
|
+ substantivasTrain: t.nSubstantivas,
|
|
|
|
|
+ triviaisTrain: t.nTriviais,
|
|
|
|
|
+ substantivasValid: v.nSubstantivas,
|
|
|
|
|
+ triviaisValid: v.nTriviais,
|
|
|
|
|
+ descartadosLongos: t.descartadosLongos + v.descartadosLongos,
|
|
|
|
|
+ descartadosRepetidos: t.descartadosRepetidos + v.descartadosRepetidos
|
|
|
|
|
+ };
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ `train: ${convTrain.length} atendimentos -> ${train.length} amostras (${t.nSubstantivas} substantivas + ${t.nTriviais} triviais) | ` +
|
|
|
|
|
+ `valid: ${convValid.length} atendimentos -> ${valid.length} amostras (${v.nSubstantivas} + ${v.nTriviais}) | ` +
|
|
|
|
|
+ `${resumoModo.descartadosLongos} descartadas por --max-chars, ${resumoModo.descartadosRepetidos} por --max-alvo-repetido`
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
fs.mkdirSync(args.out, { recursive: true });
|
|
fs.mkdirSync(args.out, { recursive: true });
|
|
|
const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
|
|
const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
|
|
@@ -192,9 +407,19 @@ async function main() {
|
|
|
{
|
|
{
|
|
|
geradoEm: new Date().toISOString(),
|
|
geradoEm: new Date().toISOString(),
|
|
|
filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
|
|
filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
|
|
|
|
|
+ modo: args.modo,
|
|
|
|
|
+ ...(args.modo === "por-turno" && {
|
|
|
|
|
+ alvosPorAtendimento: args.alvosPorAtendimento,
|
|
|
|
|
+ minPalavrasAlvo: args.minPalavrasAlvo,
|
|
|
|
|
+ fracTriviais: args.fracTriviais,
|
|
|
|
|
+ maxAlvoRepetido: args.maxAlvoRepetido
|
|
|
|
|
+ }),
|
|
|
train: train.length,
|
|
train: train.length,
|
|
|
valid: valid.length,
|
|
valid: valid.length,
|
|
|
- protocolos: embaralhadas.map((s) => s.codigo)
|
|
|
|
|
|
|
+ ...resumoModo,
|
|
|
|
|
+ // por atendimento (não por amostra) — permite auditar que nenhum protocolo está nos dois lados
|
|
|
|
|
+ protocolosTrain,
|
|
|
|
|
+ protocolosValid
|
|
|
},
|
|
},
|
|
|
null,
|
|
null,
|
|
|
2
|
|
2
|