gerarAmostraGoldenSet.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  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 { AtendimentoGoldenLabel } from "../src/models/AtendimentoGoldenLabel.model.js";
  6. import { SETOR_NOMES, setorAvaliavel } from "../src/config/setores.js";
  7. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  8. const APLICAR = process.argv.includes("--apply");
  9. const RESET = process.argv.includes("--reset");
  10. const tamanhoIdx = process.argv.indexOf("--tamanho");
  11. const TAMANHO = tamanhoIdx > -1 ? Number(process.argv[tamanhoIdx + 1]) : 40;
  12. const minimoIdx = process.argv.indexOf("--minimo");
  13. const MINIMO = minimoIdx > -1 ? Number(process.argv[minimoIdx + 1]) : 3;
  14. const expectIdx = process.argv.indexOf("--expect-count");
  15. const EXPECT_COUNT = expectIdx > -1 ? Number(process.argv[expectIdx + 1]) : null;
  16. const SETORES = Object.keys(SETOR_NOMES).filter(setorAvaliavel);
  17. const FAIXAS_SCORE = [
  18. { nome: "baixa", min: 1, max: 4 },
  19. { nome: "media", min: 5, max: 7 },
  20. { nome: "alta", min: 8, max: 10 }
  21. ];
  22. function distribuirComLimite(quantidade, chaves, limitePorChave) {
  23. const alocacao = Object.fromEntries(chaves.map((c) => [c, 0]));
  24. let restante = quantidade;
  25. let pendentes = chaves.filter((c) => limitePorChave[c] > 0);
  26. while (restante > 0 && pendentes.length > 0) {
  27. const fatia = Math.max(1, Math.floor(restante / pendentes.length));
  28. const proximaRodada = [];
  29. for (const c of pendentes) {
  30. if (restante <= 0) break;
  31. const espaco = limitePorChave[c] - alocacao[c];
  32. if (espaco <= 0) continue;
  33. const add = Math.min(fatia, espaco, restante);
  34. alocacao[c] += add;
  35. restante -= add;
  36. if (alocacao[c] < limitePorChave[c]) proximaRodada.push(c);
  37. }
  38. if (proximaRodada.length === pendentes.length && proximaRodada.every((c) => limitePorChave[c] - alocacao[c] < 1)) break;
  39. pendentes = proximaRodada;
  40. }
  41. return alocacao;
  42. }
  43. async function volumePorSetor() {
  44. const linhas = await AtendimentoAvaliacao.query()
  45. .where("Avaliavel", true)
  46. .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
  47. .whereIn("a.Setor", SETORES)
  48. .groupBy("a.Setor")
  49. .select("a.Setor")
  50. .count("atendimento_avaliacoes.AtendimentoId as total");
  51. const volume = Object.fromEntries(SETORES.map((s) => [s, 0]));
  52. for (const l of linhas) volume[l.Setor] = Number(l.total);
  53. return volume;
  54. }
  55. async function jaAmostradoPorSetor() {
  56. const linhas = await AtendimentoGoldenLabel.query()
  57. .join("atendimentos as a", "a.Id", "atendimento_golden_labels.AtendimentoId")
  58. .whereIn("a.Setor", SETORES)
  59. .groupBy("a.Setor")
  60. .select("a.Setor")
  61. .count("atendimento_golden_labels.AtendimentoId as total");
  62. const jaAmostrado = Object.fromEntries(SETORES.map((s) => [s, 0]));
  63. for (const l of linhas) jaAmostrado[l.Setor] = Number(l.total);
  64. return jaAmostrado;
  65. }
  66. function calcularAlocacao(volume) {
  67. const alocacao = Object.fromEntries(SETORES.map((s) => [s, Math.min(MINIMO, volume[s])]));
  68. let restante = TAMANHO - Object.values(alocacao).reduce((a, b) => a + b, 0);
  69. if (restante < 0) restante = 0;
  70. const volumeTotal = SETORES.reduce((a, s) => a + volume[s], 0) || 1;
  71. const fracoes = SETORES.map((s) => {
  72. const proporcional = (volume[s] / volumeTotal) * restante;
  73. const inteiro = Math.floor(proporcional);
  74. return { setor: s, inteiro, resto: proporcional - inteiro };
  75. });
  76. for (const f of fracoes) alocacao[f.setor] += f.inteiro;
  77. let distribuido = fracoes.reduce((a, f) => a + f.inteiro, 0);
  78. let sobra = restante - distribuido;
  79. fracoes.sort((a, b) => b.resto - a.resto);
  80. for (const f of fracoes) {
  81. if (sobra <= 0) break;
  82. if (alocacao[f.setor] >= volume[f.setor]) continue;
  83. alocacao[f.setor] += 1;
  84. sobra -= 1;
  85. }
  86. for (const s of SETORES) alocacao[s] = Math.min(alocacao[s], volume[s]);
  87. return alocacao;
  88. }
  89. function baseQuery(setor, faixa) {
  90. const jaNaAmostra = AtendimentoGoldenLabel.query().select("AtendimentoId");
  91. let query = AtendimentoAvaliacao.query()
  92. .where("Avaliavel", true)
  93. .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
  94. .where("a.Setor", setor)
  95. .whereNotIn("atendimento_avaliacoes.AtendimentoId", jaNaAmostra);
  96. if (faixa) query = query.whereBetween("atendimento_avaliacoes.ScoreAtendente", [faixa.min, faixa.max]);
  97. return query;
  98. }
  99. async function volumePorFaixa(setor) {
  100. const volume = {};
  101. for (const f of FAIXAS_SCORE) {
  102. const { c } = await baseQuery(setor, f).count("* as c").first();
  103. volume[f.nome] = Number(c);
  104. }
  105. return volume;
  106. }
  107. async function sortearPorSetor(setor, quantidade) {
  108. if (quantidade <= 0) return [];
  109. const volumeFaixas = await volumePorFaixa(setor);
  110. const alocacaoFaixas = distribuirComLimite(
  111. quantidade,
  112. FAIXAS_SCORE.map((f) => f.nome),
  113. volumeFaixas
  114. );
  115. const resultado = [];
  116. for (const f of FAIXAS_SCORE) {
  117. const qtd = alocacaoFaixas[f.nome];
  118. if (qtd <= 0) continue;
  119. let query = baseQuery(setor, f)
  120. .orderByRaw("RAND()")
  121. .limit(qtd)
  122. .select(
  123. "atendimento_avaliacoes.AtendimentoId as AtendimentoId",
  124. "a.Codigo as Codigo",
  125. "a.Setor as Setor",
  126. "atendimento_avaliacoes.ScoreAtendente as ScoreAtendente"
  127. );
  128. if (resultado.length) query = query.whereNotIn("atendimento_avaliacoes.AtendimentoId", resultado.map((r) => r.AtendimentoId));
  129. resultado.push(...(await query));
  130. }
  131. return resultado;
  132. }
  133. async function resetarAmostra() {
  134. const existentes = await AtendimentoGoldenLabel.query();
  135. console.log(`[gerar-amostra-golden-set] --reset: descartando ${existentes.length} linhas existentes (backup antes de apagar).`);
  136. const backupDir = path.resolve(__dirname, "logs");
  137. fs.mkdirSync(backupDir, { recursive: true });
  138. const backupPath = path.join(backupDir, `reset-golden-set-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
  139. fs.writeFileSync(backupPath, JSON.stringify(existentes, null, 2));
  140. console.log(`[gerar-amostra-golden-set] backup da amostra descartada salvo em ${backupPath}`);
  141. if (APLICAR && existentes.length > 0) {
  142. await AtendimentoGoldenLabel.query().delete();
  143. console.log("[gerar-amostra-golden-set] linhas apagadas.");
  144. }
  145. }
  146. async function main() {
  147. if (RESET) await resetarAmostra();
  148. const volume = await volumePorSetor();
  149. const jaAmostrado = RESET ? Object.fromEntries(SETORES.map((s) => [s, 0])) : await jaAmostradoPorSetor();
  150. const alocacao = calcularAlocacao(volume);
  151. console.log(`[gerar-amostra-golden-set] alvo total: ${TAMANHO} | mínimo por setor: ${MINIMO}`);
  152. console.log("[gerar-amostra-golden-set] volume disponível / já amostrado / alocação-alvo por setor:");
  153. for (const s of SETORES) {
  154. console.log(` ${s}: volume=${volume[s]} | já amostrado=${jaAmostrado[s]} | alocação-alvo=${alocacao[s]}`);
  155. }
  156. const amostraFinal = [];
  157. const faltantePorSetor = {};
  158. for (const s of SETORES) {
  159. const faltam = Math.max(0, alocacao[s] - jaAmostrado[s]);
  160. faltantePorSetor[s] = faltam;
  161. if (faltam > 0) {
  162. const sorteados = await sortearPorSetor(s, faltam);
  163. amostraFinal.push(...sorteados);
  164. }
  165. }
  166. console.log(`[gerar-amostra-golden-set] ${amostraFinal.length} atendimentos novos serão adicionados à amostra.`);
  167. if (EXPECT_COUNT !== null && amostraFinal.length !== EXPECT_COUNT) {
  168. console.error(`[gerar-amostra-golden-set] abortando: esperava ${EXPECT_COUNT}, achei ${amostraFinal.length}.`);
  169. process.exit(1);
  170. }
  171. const backupDir = path.resolve(__dirname, "logs");
  172. fs.mkdirSync(backupDir, { recursive: true });
  173. const backupPath = path.join(backupDir, `gerar-amostra-golden-set-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
  174. fs.writeFileSync(
  175. backupPath,
  176. JSON.stringify(
  177. {
  178. config: { tamanho: TAMANHO, minimo: MINIMO },
  179. volumePorSetor: volume,
  180. jaAmostradoPorSetor: jaAmostrado,
  181. alocacaoAlvoPorSetor: alocacao,
  182. faltantePorSetor,
  183. amostraFinal
  184. },
  185. null,
  186. 2
  187. )
  188. );
  189. console.log(`[gerar-amostra-golden-set] detalhe do sorteio salvo em ${backupPath}`);
  190. if (!APLICAR) {
  191. console.log("[gerar-amostra-golden-set] dry-run (nenhuma alteração feita). Use --apply para gravar.");
  192. process.exit(0);
  193. }
  194. if (amostraFinal.length === 0) {
  195. console.log("[gerar-amostra-golden-set] nada para inserir.");
  196. process.exit(0);
  197. }
  198. for (const { AtendimentoId } of amostraFinal) {
  199. await AtendimentoGoldenLabel.query().insert({ AtendimentoId, Status: "pendente" });
  200. }
  201. console.log(`[gerar-amostra-golden-set] ${amostraFinal.length} linhas inseridas com Status='pendente'.`);
  202. process.exit(0);
  203. }
  204. main().catch((err) => {
  205. console.error("[gerar-amostra-golden-set] falha fatal:", err);
  206. process.exit(1);
  207. });