import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js"; import { AtendimentoGoldenLabel } from "../src/models/AtendimentoGoldenLabel.model.js"; import { SETOR_NOMES, setorAvaliavel } from "../src/config/setores.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const APLICAR = process.argv.includes("--apply"); const RESET = process.argv.includes("--reset"); const tamanhoIdx = process.argv.indexOf("--tamanho"); const TAMANHO = tamanhoIdx > -1 ? Number(process.argv[tamanhoIdx + 1]) : 40; const minimoIdx = process.argv.indexOf("--minimo"); const MINIMO = minimoIdx > -1 ? Number(process.argv[minimoIdx + 1]) : 3; const expectIdx = process.argv.indexOf("--expect-count"); const EXPECT_COUNT = expectIdx > -1 ? Number(process.argv[expectIdx + 1]) : null; const SETORES = Object.keys(SETOR_NOMES).filter(setorAvaliavel); const FAIXAS_SCORE = [ { nome: "baixa", min: 1, max: 4 }, { nome: "media", min: 5, max: 7 }, { nome: "alta", min: 8, max: 10 } ]; function distribuirComLimite(quantidade, chaves, limitePorChave) { const alocacao = Object.fromEntries(chaves.map((c) => [c, 0])); let restante = quantidade; let pendentes = chaves.filter((c) => limitePorChave[c] > 0); while (restante > 0 && pendentes.length > 0) { const fatia = Math.max(1, Math.floor(restante / pendentes.length)); const proximaRodada = []; for (const c of pendentes) { if (restante <= 0) break; const espaco = limitePorChave[c] - alocacao[c]; if (espaco <= 0) continue; const add = Math.min(fatia, espaco, restante); alocacao[c] += add; restante -= add; if (alocacao[c] < limitePorChave[c]) proximaRodada.push(c); } if (proximaRodada.length === pendentes.length && proximaRodada.every((c) => limitePorChave[c] - alocacao[c] < 1)) break; pendentes = proximaRodada; } return alocacao; } async function volumePorSetor() { const linhas = await AtendimentoAvaliacao.query() .where("Avaliavel", true) .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId") .whereIn("a.Setor", SETORES) .groupBy("a.Setor") .select("a.Setor") .count("atendimento_avaliacoes.AtendimentoId as total"); const volume = Object.fromEntries(SETORES.map((s) => [s, 0])); for (const l of linhas) volume[l.Setor] = Number(l.total); return volume; } async function jaAmostradoPorSetor() { const linhas = await AtendimentoGoldenLabel.query() .join("atendimentos as a", "a.Id", "atendimento_golden_labels.AtendimentoId") .whereIn("a.Setor", SETORES) .groupBy("a.Setor") .select("a.Setor") .count("atendimento_golden_labels.AtendimentoId as total"); const jaAmostrado = Object.fromEntries(SETORES.map((s) => [s, 0])); for (const l of linhas) jaAmostrado[l.Setor] = Number(l.total); return jaAmostrado; } function calcularAlocacao(volume) { const alocacao = Object.fromEntries(SETORES.map((s) => [s, Math.min(MINIMO, volume[s])])); let restante = TAMANHO - Object.values(alocacao).reduce((a, b) => a + b, 0); if (restante < 0) restante = 0; const volumeTotal = SETORES.reduce((a, s) => a + volume[s], 0) || 1; const fracoes = SETORES.map((s) => { const proporcional = (volume[s] / volumeTotal) * restante; const inteiro = Math.floor(proporcional); return { setor: s, inteiro, resto: proporcional - inteiro }; }); for (const f of fracoes) alocacao[f.setor] += f.inteiro; let distribuido = fracoes.reduce((a, f) => a + f.inteiro, 0); let sobra = restante - distribuido; fracoes.sort((a, b) => b.resto - a.resto); for (const f of fracoes) { if (sobra <= 0) break; if (alocacao[f.setor] >= volume[f.setor]) continue; alocacao[f.setor] += 1; sobra -= 1; } for (const s of SETORES) alocacao[s] = Math.min(alocacao[s], volume[s]); return alocacao; } function baseQuery(setor, faixa) { const jaNaAmostra = AtendimentoGoldenLabel.query().select("AtendimentoId"); let query = AtendimentoAvaliacao.query() .where("Avaliavel", true) .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId") .where("a.Setor", setor) .whereNotIn("atendimento_avaliacoes.AtendimentoId", jaNaAmostra); if (faixa) query = query.whereBetween("atendimento_avaliacoes.ScoreAtendente", [faixa.min, faixa.max]); return query; } async function volumePorFaixa(setor) { const volume = {}; for (const f of FAIXAS_SCORE) { const { c } = await baseQuery(setor, f).count("* as c").first(); volume[f.nome] = Number(c); } return volume; } async function sortearPorSetor(setor, quantidade) { if (quantidade <= 0) return []; const volumeFaixas = await volumePorFaixa(setor); const alocacaoFaixas = distribuirComLimite( quantidade, FAIXAS_SCORE.map((f) => f.nome), volumeFaixas ); const resultado = []; for (const f of FAIXAS_SCORE) { const qtd = alocacaoFaixas[f.nome]; if (qtd <= 0) continue; let query = baseQuery(setor, f) .orderByRaw("RAND()") .limit(qtd) .select( "atendimento_avaliacoes.AtendimentoId as AtendimentoId", "a.Codigo as Codigo", "a.Setor as Setor", "atendimento_avaliacoes.ScoreAtendente as ScoreAtendente" ); if (resultado.length) query = query.whereNotIn("atendimento_avaliacoes.AtendimentoId", resultado.map((r) => r.AtendimentoId)); resultado.push(...(await query)); } return resultado; } async function resetarAmostra() { const existentes = await AtendimentoGoldenLabel.query(); console.log(`[gerar-amostra-golden-set] --reset: descartando ${existentes.length} linhas existentes (backup antes de apagar).`); const backupDir = path.resolve(__dirname, "logs"); fs.mkdirSync(backupDir, { recursive: true }); const backupPath = path.join(backupDir, `reset-golden-set-${new Date().toISOString().replace(/[:.]/g, "-")}.json`); fs.writeFileSync(backupPath, JSON.stringify(existentes, null, 2)); console.log(`[gerar-amostra-golden-set] backup da amostra descartada salvo em ${backupPath}`); if (APLICAR && existentes.length > 0) { await AtendimentoGoldenLabel.query().delete(); console.log("[gerar-amostra-golden-set] linhas apagadas."); } } async function main() { if (RESET) await resetarAmostra(); const volume = await volumePorSetor(); const jaAmostrado = RESET ? Object.fromEntries(SETORES.map((s) => [s, 0])) : await jaAmostradoPorSetor(); const alocacao = calcularAlocacao(volume); console.log(`[gerar-amostra-golden-set] alvo total: ${TAMANHO} | mínimo por setor: ${MINIMO}`); console.log("[gerar-amostra-golden-set] volume disponível / já amostrado / alocação-alvo por setor:"); for (const s of SETORES) { console.log(` ${s}: volume=${volume[s]} | já amostrado=${jaAmostrado[s]} | alocação-alvo=${alocacao[s]}`); } const amostraFinal = []; const faltantePorSetor = {}; for (const s of SETORES) { const faltam = Math.max(0, alocacao[s] - jaAmostrado[s]); faltantePorSetor[s] = faltam; if (faltam > 0) { const sorteados = await sortearPorSetor(s, faltam); amostraFinal.push(...sorteados); } } console.log(`[gerar-amostra-golden-set] ${amostraFinal.length} atendimentos novos serão adicionados à amostra.`); if (EXPECT_COUNT !== null && amostraFinal.length !== EXPECT_COUNT) { console.error(`[gerar-amostra-golden-set] abortando: esperava ${EXPECT_COUNT}, achei ${amostraFinal.length}.`); process.exit(1); } const backupDir = path.resolve(__dirname, "logs"); fs.mkdirSync(backupDir, { recursive: true }); const backupPath = path.join(backupDir, `gerar-amostra-golden-set-${new Date().toISOString().replace(/[:.]/g, "-")}.json`); fs.writeFileSync( backupPath, JSON.stringify( { config: { tamanho: TAMANHO, minimo: MINIMO }, volumePorSetor: volume, jaAmostradoPorSetor: jaAmostrado, alocacaoAlvoPorSetor: alocacao, faltantePorSetor, amostraFinal }, null, 2 ) ); console.log(`[gerar-amostra-golden-set] detalhe do sorteio salvo em ${backupPath}`); if (!APLICAR) { console.log("[gerar-amostra-golden-set] dry-run (nenhuma alteração feita). Use --apply para gravar."); process.exit(0); } if (amostraFinal.length === 0) { console.log("[gerar-amostra-golden-set] nada para inserir."); process.exit(0); } for (const { AtendimentoId } of amostraFinal) { await AtendimentoGoldenLabel.query().insert({ AtendimentoId, Status: "pendente" }); } console.log(`[gerar-amostra-golden-set] ${amostraFinal.length} linhas inseridas com Status='pendente'.`); process.exit(0); } main().catch((err) => { console.error("[gerar-amostra-golden-set] falha fatal:", err); process.exit(1); });