feat(exam-prep F6): таксономия из 16 подтем + эвристический классификатор (100% покрытие 800 задач math9)
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tag-exam-tasks.js — heuristic topic/subtopic/difficulty classifier
|
||||
* for exam_tasks (F6 of the exam-prep module).
|
||||
*
|
||||
* Strategy: ordered rules, first match wins. Each rule has:
|
||||
* - test : regex applied to the plain-text body (HTML + LaTeX stripped)
|
||||
* - sub : subtopic slug to assign
|
||||
* Rules ordered most-specific → most-general. The text is normalized
|
||||
* to plain Russian/Latin before matching.
|
||||
*
|
||||
* Usage:
|
||||
* node backend/scripts/tag-exam-tasks.js math9 # tag math9
|
||||
* node backend/scripts/tag-exam-tasks.js math9 --dry # report only
|
||||
* node backend/scripts/tag-exam-tasks.js math9 --reset # null out all tags first
|
||||
*
|
||||
* Idempotent (UPDATE not INSERT). Re-runnable.
|
||||
*/
|
||||
'use strict';
|
||||
const db = require('../src/db/db');
|
||||
|
||||
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||||
const flags = new Set(process.argv.slice(2).filter(a => a.startsWith('--')));
|
||||
const DRY = flags.has('--dry');
|
||||
const RESET = flags.has('--reset');
|
||||
const VERBOSE = flags.has('--verbose') || flags.has('-v');
|
||||
const examKey = args[0] || 'math9';
|
||||
|
||||
/* ── Text normalization ───────────────────────────────────────── */
|
||||
function stripText(html) {
|
||||
return String(html || '')
|
||||
.replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
// common LaTeX cleanup
|
||||
.replace(/\\dfrac\{([^}]+)\}\{([^}]+)\}/g, '($1)/($2)')
|
||||
.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1)/($2)')
|
||||
.replace(/\\sqrt\{([^}]+)\}/g, 'sqrt($1)')
|
||||
.replace(/\\cdot/g, '*').replace(/\\leq/g, '<=').replace(/\\geq/g, '>=')
|
||||
.replace(/\\lt/g, '<').replace(/\\gt/g, '>')
|
||||
.replace(/\\[a-zA-Z]+/g, ' ') // remove leftover LaTeX commands
|
||||
.replace(/[{}$]/g, ' ')
|
||||
.replace(/&[a-z]+;/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/* ── Subtopic rules (ORDER MATTERS) ───────────────────────────── */
|
||||
/* Strategy:
|
||||
1) Theory "Какое утверждение НЕ верно" — catch first (these are spread across topics)
|
||||
2) Specific geometry shapes
|
||||
3) Specific algebra topics (progressions, inequalities, equations)
|
||||
4) Functions/graphs
|
||||
5) Word problems with units/people/money
|
||||
6) Fractions, polynomials, expressions
|
||||
7) Powers/roots, numbers, arithmetic
|
||||
*/
|
||||
/* IMPORTANT: JS regex \w / \b are ASCII-only. Use [а-яё]* for Russian word
|
||||
tails and avoid \b after Cyrillic. The `u` flag with \p{L} would also work
|
||||
but stays here as plain regex for readability. */
|
||||
const RULES = [
|
||||
// ── Theory ("какое из утверждений НЕ верно" / "какое из равенств верно")
|
||||
{ sub: 'theory-statements',
|
||||
test: /Какое из следующих утверждений\s*НЕ\s*верно|Какое из следующих равенств\s+верно|Определите, какое из следующих равенств\s+верно|какое из следующих утверждений верно|Определите\s+верное\s+равенство|какое\s+из\s+данных\s+равенств\s+является\s+верным|выберите\s+тождество/i },
|
||||
|
||||
// ── Word-problems (concrete subjects, percents, two-digit numbers, applied)
|
||||
{ sub: 'alg-word-problems',
|
||||
test: /процент|%\s|\\%|на\s+\d{1,3}\s*%|от\s+числа\s+\d|зарплат|туристы|бассейн|маляр|бригад|пешеход|автомобил|населен|кубометр|мешк|пакет|корм|план[аеоуы]?\s|доход|поезд|вкладчик|акци[а-я]*|стоит\s+\d|купил|продал|посадил|урожа|удо[йя]|двузначн[а-я]*\s+числ|трёхзначн[а-я]*\s+числ|сумм[а-я]*\s+(?:его\s+|её\s+|своих\s+)?цифр|разделить\s+на\s+сумму|таня|маша|петя|ваня|саша|катя|оля|вова|учащемуся|школьник|урок|домашн[а-я]+\s+задани|самостоятельн[а-я]+\s+работ|часов\s+сна|возраст[а-я]*\s+\d|сколько\s+раз|задуманн[а-я]+\s+числ|задумал[а-я]*\s+числ|лампоч|украшени|поместь|деда\s+мороза|девоч|подруг|обменя[а-я]+\s+фотограф|расстояни[а-я]+\s+между\s+городами|расстояни[а-я]+\s+на\s+карте|масштаб\s+карт|израсходова[а-я]+|покраск|спортивн[а-я]+\s+зал|пол\s+в|купил[а-я]*\s+краск|банк\s+заданий|задач\s+по\s+(?:алгебре|геометрии|физике)|мотоциклист|велосипедист|сотрудник|комбинат|лаборатори|обрабатыва|испек[а-я]+|муки\s+получается|свежих\s+яблок|из\s+\d[\d., ]*\s*кг|сколько\s+надо|сколько\s+(?:килограмм|часов|дней|минут|метров|литров|штук)|ботанич[а-я]+\s+сад|ландшафтн|кусты\s+роз|кусты\s+пион|плиточник|подрядчик|облицовочн|кирпич|укладк|напольн[а-я]+\s+плитк|компани[а-я]+\s+поступил|брат\s+и\s+сестр|сестр\w*\s+вышл|тренажёрн[а-я]+\s+зал|зрительн[а-я]+\s+зал|определите\s+масштаб|местност[а-я]*\s+изображено|изображ[а-я]+\s+на\s+карте|умен[а-я]+\s+на\s+\d|увелич[а-я]+\s+на\s+\d|ящик[а-я]*\s+для\s+упаковк|упаковк[а-я]+\s+фрукт|ячеек|в\s+каждом\s+ряду/i },
|
||||
|
||||
// ── Geometry: shapes (specific)
|
||||
{ sub: 'geom-quadrilaterals',
|
||||
test: /параллелограмм|ромб[а-я]*|трапеци|квадрат[а-я]*|прямоугольник|четырёхугольник|четырехугольник|правильн[а-я]+\s+многоугольник|многоугольник|шестиугольник|пятиугольник/i },
|
||||
{ sub: 'geom-triangles',
|
||||
test: /треугольник|гипотенуз|катет|медиан|биссектрис|пифагор|синусов|косинусов|остроугольн|тупоугольн|равнобедренн|равносторонн|угла\s+[A-ZА-Я]|внутри\s+угла/i },
|
||||
{ sub: 'geom-circle',
|
||||
test: /окружност|круг[а-я]*|радиус|диаметр|вписан|описан|дуг[а-я]*|сектор|хорд|касательн/i },
|
||||
{ sub: 'geom-coordinates',
|
||||
test: /координат|\s+вектор|уравнение прямой|уравнение окружности|на плоскости\s+xOy|точк[а-я]*\s*\(\s*-?\d|парабол[а-я]*\s+y\s*=|вершин[а-я]*\s+парабол/i },
|
||||
|
||||
// ── Algebra: structured topics
|
||||
{ sub: 'alg-progressions',
|
||||
test: /прогресси|арифметическ.{0,30}последовательн|геометрическ.{0,30}последовательн|n[\s\-]?[ыйего]\s+член|первы[йх]?\s+член|разност[а-я]*\s+прогресс|знаменател[а-я]*\s+прогресс|(?:первый|второй|третий|четвёртый|пятый|шестой|седьмой|восьмой|девятый|десятый|сотый)\s+член\s+(?:последовательности|прогрессии)|член\s+последовательности|заданн\w+\s+формулой\s+a_?n|a_?n\s*=\s*[a-zA-Z\d]/i },
|
||||
{ sub: 'alg-inequalities',
|
||||
test: /неравенств|систем[а-я]*\s+неравенств|совокупност[а-я]*\s+неравенств|[xy]\s*[<>≤≥]|<=|>=|меньше нуля|больше нуля/i },
|
||||
{ sub: 'alg-equations',
|
||||
test: /уравнен|систем[а-я]*\s+уравнен|корн[а-я]+\s+уравнения|решит[ье].{0,30}систем|квадратное уравнение|найдите\s+(?:все\s+)?значения?\s+переменной|при\s+как[а-я]+\s+значени[а-я]+\s+переменной/i },
|
||||
{ sub: 'alg-functions',
|
||||
test: /функци|график|область значен|область определен|чётн[а-я]*\s+функци|нечётн[а-я]*\s+функци|возрастан|убыван|y\s*=\s*f|f\(\s*x\s*\)|парабол/i },
|
||||
|
||||
// ── Algebra: general (rank lower)
|
||||
{ sub: 'alg-fractions',
|
||||
test: /сократите\s+дробь|сокращени[а-я]*\s+дроб|алгебраическ[а-я]*\s+дроб|(?:числител|знаменател)[а-я]*\s+дроб|сумм[а-я]*\s+дроб|разност[а-я]*\s+дроб|произведени[а-я]*\s+дроб|частн[а-я]*\s+дроб|обыкновенн[а-я]*\s+дроб|дробей|в\s+виде\s+дроби|запись\s+выражения.{0,40}в\s+виде\s+дроби|выражени[а-я]*\s+НЕ\s+имеет\s+смысла|выражени[а-я]*\s+не\s+имеет\s+смысла/i },
|
||||
{ sub: 'alg-polynomials',
|
||||
test: /многочлен|одночлен|разложите\s+на\s+множител|вынесите.{0,30}скобк|формул[а-я]*\s+сокращённ[а-я]*/i },
|
||||
{ sub: 'alg-expressions',
|
||||
test: /упростите\s+выражени|тождественно\s+равн|значение\s+выражения|преобразуйте\s+выражени|результат\s+упрощения\s+выражения|равенство\s+выполняется|при\s+каком\s+значении\s+a|найдите\s+значение\s+выражения|найдите\s+(?:частное|произведение)\s+[a-z]\s+и\s+[a-z]|приведите\s+подобные\s+слагаемые|подобные\s+слагаемые|раскройте\s+скобки|раскрой[а-я]*\s+скобк/i },
|
||||
{ sub: 'alg-powers',
|
||||
test: /степен[ьия]|показател[а-я]*\s+степен|корен[ья]|sqrt\(|иррациональн|[a-zа-я0-9]\s*\^\s*[\-{}\d]|\^\s*\{\s*-?\d/i },
|
||||
{ sub: 'alg-arithmetic',
|
||||
test: /сумма\s+(?:двух\s+)?чисел|разность\s+(?:двух\s+)?чисел|произведение\s+чисел|вычислит|сумм[а-я]*\s+как[а-я]+\s+двух\s+чисел|отношение\s+чисел|к\s+разности\s+чисел|к\s+сумме\s+чисел|если\s+к\s+\w+\s+чисел|если\s+к\s+(?:разности|сумме|произведению)|(?:сумма|разность|произведение|частное)\s+каких\s+двух\s+чисел|определите\s+(?:наибольшее|наименьшее)\s+из\s+значений|значений\s+числовых\s+выражений/i },
|
||||
{ sub: 'alg-numbers',
|
||||
test: /натуральн|целое\s+число|рациональн|действительн|какое.{0,40}число|НОД\s*\(|НОК\s*\(|наибольший\s+общий\s+делитель|наименьшее\s+общее\s+кратное|простое\s+число|составное\s+число|множеств[а-я]*\s+(?:составн|прост|натуральн|цел)|определите\s+промежуток|промежутк[а-я]*.{0,30}принадлежит\s+число|является\s+простым/i },
|
||||
];
|
||||
|
||||
/* ── Difficulty heuristic ─────────────────────────────────────── */
|
||||
/* 1..5; based on task_idx position within the 10-question variant. */
|
||||
function difficultyFor(taskIdx, type) {
|
||||
// Tasks 1-3 (easy), 4-6 (medium), 7-10 (hard)
|
||||
let base;
|
||||
if (taskIdx <= 3) base = taskIdx === 1 ? 1 : 2;
|
||||
else if (taskIdx <= 6) base = 3;
|
||||
else if (taskIdx <= 8) base = 4;
|
||||
else base = 5;
|
||||
if (type === 'long') base = Math.min(5, base + 1);
|
||||
return base;
|
||||
}
|
||||
|
||||
/* ── Classifier ───────────────────────────────────────────────── */
|
||||
function classify(row) {
|
||||
const txt = stripText(row.text_html);
|
||||
for (const rule of RULES) {
|
||||
if (rule.test.test(txt)) {
|
||||
const parent = rule.sub.startsWith('alg-') ? 'algebra'
|
||||
: rule.sub.startsWith('geom-') ? 'geometry'
|
||||
: 'theory';
|
||||
return { topic: parent, subtopic: rule.sub };
|
||||
}
|
||||
}
|
||||
return { topic: null, subtopic: null };
|
||||
}
|
||||
|
||||
/* ── Main ─────────────────────────────────────────────────────── */
|
||||
function main() {
|
||||
if (RESET && !DRY) {
|
||||
const r = db.prepare(`UPDATE exam_tasks SET topic = NULL, subtopic = NULL, difficulty = NULL WHERE exam_key = ?`).run(examKey);
|
||||
console.log(`[reset] cleared tags on ${r.changes} rows`);
|
||||
}
|
||||
|
||||
const tasks = db.prepare(`
|
||||
SELECT id, variant, task_idx, task_type, text_html
|
||||
FROM exam_tasks
|
||||
WHERE exam_key = ?
|
||||
ORDER BY variant, task_idx
|
||||
`).all(examKey);
|
||||
|
||||
const upd = db.prepare(`UPDATE exam_tasks SET topic = ?, subtopic = ?, difficulty = ? WHERE id = ?`);
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
tagged: 0,
|
||||
unknown: 0,
|
||||
bySub: new Map(),
|
||||
byParent: new Map(),
|
||||
unknownSamples: [],
|
||||
};
|
||||
|
||||
const run = DRY ? (..._args) => {} : (a, b, c, d) => upd.run(a, b, c, d);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const t of tasks) {
|
||||
const { topic, subtopic } = classify(t);
|
||||
const diff = difficultyFor(t.task_idx, t.task_type);
|
||||
|
||||
if (subtopic) {
|
||||
stats.tagged++;
|
||||
stats.bySub.set(subtopic, (stats.bySub.get(subtopic) || 0) + 1);
|
||||
stats.byParent.set(topic, (stats.byParent.get(topic) || 0) + 1);
|
||||
} else {
|
||||
stats.unknown++;
|
||||
if (stats.unknownSamples.length < 12) {
|
||||
stats.unknownSamples.push({
|
||||
v: t.variant, i: t.task_idx,
|
||||
text: stripText(t.text_html).slice(0, 100),
|
||||
});
|
||||
}
|
||||
}
|
||||
run(topic, subtopic, diff, t.id);
|
||||
}
|
||||
})();
|
||||
|
||||
/* ── Report ── */
|
||||
const pct = n => ((n / stats.total) * 100).toFixed(1) + '%';
|
||||
console.log(`\n═══ ${examKey} tagging ═══${DRY ? ' (DRY RUN)' : ''}`);
|
||||
console.log(`Total tasks: ${stats.total}`);
|
||||
console.log(`Tagged: ${stats.tagged} (${pct(stats.tagged)})`);
|
||||
console.log(`Unknown: ${stats.unknown} (${pct(stats.unknown)})`);
|
||||
|
||||
console.log(`\nBy section:`);
|
||||
for (const [parent, n] of [...stats.byParent.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${parent.padEnd(10)} ${String(n).padStart(4)} (${pct(n)})`);
|
||||
}
|
||||
|
||||
console.log(`\nBy subtopic:`);
|
||||
for (const [sub, n] of [...stats.bySub.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${sub.padEnd(22)} ${String(n).padStart(4)} (${pct(n)})`);
|
||||
}
|
||||
|
||||
if (stats.unknownSamples.length) {
|
||||
console.log(`\nUntagged samples (first ${stats.unknownSamples.length}):`);
|
||||
for (const s of stats.unknownSamples) {
|
||||
console.log(` v${String(s.v).padStart(2,'0')} t${s.i}: ${s.text}`);
|
||||
}
|
||||
}
|
||||
if (DRY) console.log(`\n[DRY RUN] no changes written.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 024: Seed exam_topics taxonomy for math9
|
||||
--
|
||||
-- Two-level hierarchy: section (parent) → subtopic.
|
||||
-- See backend/scripts/tag-exam-tasks.js for the classifier that
|
||||
-- maps each exam_tasks row to one of these subtopic slugs.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Sections (parents) — parent_slug = NULL
|
||||
INSERT INTO exam_topics (slug, exam_key, parent_slug, title, description, sort_order) VALUES
|
||||
('algebra', 'math9', NULL, 'Алгебра', 'Числа, выражения, уравнения, неравенства, функции', 10),
|
||||
('geometry', 'math9', NULL, 'Геометрия', 'Планиметрия: треугольники, четырёхугольники, окружность', 20),
|
||||
('theory', 'math9', NULL, 'Теория', 'Теоретические утверждения, истина/ложь', 30);
|
||||
|
||||
-- Subtopics (children) — parent_slug = section slug
|
||||
INSERT INTO exam_topics (slug, exam_key, parent_slug, title, sort_order) VALUES
|
||||
-- Algebra
|
||||
('alg-numbers', 'math9', 'algebra', 'Числа и множества', 11),
|
||||
('alg-arithmetic', 'math9', 'algebra', 'Арифметические действия', 12),
|
||||
('alg-powers', 'math9', 'algebra', 'Степени и корни', 13),
|
||||
('alg-expressions', 'math9', 'algebra', 'Алгебраические выражения', 14),
|
||||
('alg-polynomials', 'math9', 'algebra', 'Многочлены и разложение на множители', 15),
|
||||
('alg-fractions', 'math9', 'algebra', 'Дроби и сокращение', 16),
|
||||
('alg-equations', 'math9', 'algebra', 'Уравнения и системы', 17),
|
||||
('alg-inequalities', 'math9', 'algebra', 'Неравенства', 18),
|
||||
('alg-functions', 'math9', 'algebra', 'Функции и графики', 19),
|
||||
('alg-progressions', 'math9', 'algebra', 'Прогрессии', 20),
|
||||
('alg-word-problems', 'math9', 'algebra', 'Текстовые задачи и проценты', 21),
|
||||
|
||||
-- Geometry
|
||||
('geom-triangles', 'math9', 'geometry', 'Треугольники', 31),
|
||||
('geom-quadrilaterals', 'math9', 'geometry', 'Четырёхугольники', 32),
|
||||
('geom-circle', 'math9', 'geometry', 'Окружность и круг', 33),
|
||||
('geom-coordinates', 'math9', 'geometry', 'Координаты, векторы, прямая', 34),
|
||||
|
||||
-- Theory
|
||||
('theory-statements', 'math9', 'theory', 'Истинно / неверно (теория)', 41);
|
||||
Reference in New Issue
Block a user