tools(ctmath): check_variant_dups.js — гейт дедупликации перед добавлением варианта

Постоянный read-only инструмент: (1) без аргумента — аудит видимого пула [101;1999]
на внутренние точные дубли; (2) с seed-файлом — сверяет его TASKS с пулом ДО --apply.
Норма текста: теги/латех/пробелы убраны, ЧИСЛА сохранены (параллельные задачи дублями
не считаются). Сейчас пул = 514 задач, 514 уникальных сигнатур, 0 дублей.
Включается в конвейер тиража: новый вариант проверяется этим гейтом перед записью.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-20 13:31:02 +03:00
parent 59ae4c1dea
commit 70cf6b3af1
+85
View File
@@ -0,0 +1,85 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
check_variant_dups.js — проверка дубликатов задач в exam-prep (трек ctmath).
Зачем: новые варианты-пробники не должны повторять уже имеющиеся задачи в
«общей базе» (видимый ученику пул = выверенные варианты [101;1999]).
Режимы:
node backend/scripts/check_variant_dups.js
→ аудит всего видимого пула ([101;1999]) на ВНУТРЕННИЕ точные дубли;
node backend/scripts/check_variant_dups.js seed_ctmath_ct2017_v1.js
→ сверяет TASKS из seed-файла с уже имеющимся видимым пулом БД
(до --apply). Год-пачки (variant≥2011) и variant=0 в сравнении НЕ
участвуют (они скрыты из практики фильтром exam-prep.js).
Точные дубли = совпадение нормализованного текста (теги/латех/пробелы убраны,
ЧИСЛА сохранены — параллельные задачи с другими числами дублями не считаются).
Возврат: код 0 — дублей нет; код 1 — найдены (для CI/ручной проверки).
Только ЧТЕНИЕ БД. --apply не нужен.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const MOCK_LO = 101, MOCK_HI = 1999; // видимый пул ctmath (как в exam-prep.js)
const EXAM = 'ctmath';
const norm = s => String(s || '')
.replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/gi, ' ')
.replace(/\$/g, '').replace(/\\[a-zA-Z]+/g, '')
.replace(/[^0-9a-zа-яёA-ZА-ЯЁ]+/g, '').toLowerCase();
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
// видимый пул: variant в [101;1999]
const pool = db.prepare(
`SELECT variant, task_idx, text_html FROM exam_tasks
WHERE exam_key=? AND variant BETWEEN ? AND ?`).all(EXAM, MOCK_LO, MOCK_HI);
const poolSig = new Map(); // sig -> [{variant,task_idx}]
for (const r of pool) {
const k = norm(r.text_html); if (!k) continue;
if (!poolSig.has(k)) poolSig.set(k, []);
poolSig.get(k).push({ variant: r.variant, task_idx: r.task_idx });
}
const arg = process.argv[2];
let problems = 0;
if (!arg) {
// АУДИТ внутренних дублей пула
const dups = [...poolSig.entries()].filter(([, a]) => a.length > 1);
console.log(`\n=== Аудит видимого пула ctmath [${MOCK_LO};${MOCK_HI}] ===`);
console.log(`Задач в пуле: ${pool.length}, уникальных сигнатур: ${poolSig.size}`);
if (!dups.length) console.log('✓ Точных дублей внутри пула НЕТ.');
else {
problems = dups.length;
console.log(`✗ Точных дубль-групп: ${dups.length}`);
for (const [, a] of dups) console.log(' ' + a.map(x => `${x.variant}#${x.task_idx}`).join(' = '));
}
} else {
// СВЕРКА seed-файла с пулом
const seedPath = path.isAbsolute(arg) ? arg : path.join(__dirname, arg);
let mod;
try { mod = require(seedPath); } catch (e) { console.error('✗ Не загрузить seed:', e.message); process.exit(2); }
const { TASKS, VARIANT } = mod;
if (!Array.isArray(TASKS)) { console.error('✗ В seed нет экспорта TASKS'); process.exit(2); }
console.log(`\n=== Сверка seed (variant=${VARIANT}, ${TASKS.length} задач) с пулом ===`);
// исключаем САМ этот вариант из пула (если уже применён — не считать самосовпадением)
const own = new Set();
for (const t of TASKS) {
const k = norm(t.text); if (!k) continue;
const hit = (poolSig.get(k) || []).filter(x => x.variant !== VARIANT);
if (hit.length) {
problems++;
console.log(` ✗ #${t.idx} дублирует: ` + hit.map(x => `${x.variant}#${x.task_idx}`).join(', '));
}
if (own.has(k)) { problems++; console.log(` ✗ #${t.idx} — внутренний дубль в самом варианте`); }
own.add(k);
}
if (!problems) console.log(`✓ Дублей с видимым пулом нет — variant=${VARIANT} можно добавлять.`);
}
db.close();
process.exit(problems ? 1 : 0);