diff --git a/backend/scripts/tag-exam-tasks.js b/backend/scripts/tag-exam-tasks.js new file mode 100644 index 0000000..468f425 --- /dev/null +++ b/backend/scripts/tag-exam-tasks.js @@ -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(//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(); diff --git a/backend/src/db/migrations/024_exam_topics_seed.sql b/backend/src/db/migrations/024_exam_topics_seed.sql new file mode 100644 index 0000000..2bbf1cd --- /dev/null +++ b/backend/src/db/migrations/024_exam_topics_seed.sql @@ -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);