From e210410526553bc1be2a2f3e280ee15ca5848ad9 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 16:18:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(exam):=20Phase=203=20=E2=80=94=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=81=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20tag-exam-textbook.js=20(100%=20math9,=20800/800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Детерминированная эвристика: subtopic → кандидатные §, keyword-scoring по тексту. Карта subtopic→primary § по PLAN.md. Флаги: --exam, --dry-run, --report. Результат: 800 задач math9 размечены без единого null (algebra-8-ch2#8 и др.). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/tag-exam-textbook.js | 644 +++++++++++++++++++++++++++ 1 file changed, 644 insertions(+) create mode 100644 backend/scripts/tag-exam-textbook.js diff --git a/backend/scripts/tag-exam-textbook.js b/backend/scripts/tag-exam-textbook.js new file mode 100644 index 0000000..e5288b6 --- /dev/null +++ b/backend/scripts/tag-exam-textbook.js @@ -0,0 +1,644 @@ +#!/usr/bin/env node +/** + * tag-exam-textbook.js — heuristic per-task textbook-paragraph classifier + * for exam_tasks (math9 and future exams). + * + * For each task: + * 1. subtopic → candidate chapters/paragraphs (primary map from PLAN.md) + * 2. keyword scoring on stripped text → best § match + * 3. fallback: primary § for the subtopic + * + * Writes exam_tasks.textbook_slug / textbook_paragraph. + * + * Usage: + * node backend/scripts/tag-exam-textbook.js --exam math9 # tag live DB + * node backend/scripts/tag-exam-textbook.js --exam math9 --dry-run # no writes + * node backend/scripts/tag-exam-textbook.js --exam math9 --report # show distribution + * node backend/scripts/tag-exam-textbook.js --exam math9 --dry-run --report + * + * Idempotent: overwrites existing textbook_slug/paragraph on every run. + */ +'use strict'; + +const path = require('path'); +const db = require('../src/db/db'); + +const args = process.argv.slice(2); +const flags = new Set(args.filter(a => a.startsWith('--'))); +const vals = args.filter(a => !a.startsWith('--')); + +const DRY = flags.has('--dry-run') || flags.has('--dry'); +const REPORT = flags.has('--report'); +const VERBOSE = flags.has('--verbose') || flags.has('-v'); + +// --exam math9 or positional first arg +let examKey = 'math9'; +const examIdx = args.indexOf('--exam'); +if (examIdx !== -1 && args[examIdx + 1]) { + examKey = args[examIdx + 1]; +} else if (vals.length) { + examKey = vals[0]; +} + +/* ── Taxonomy ─────────────────────────────────────────────────── */ +const taxonomy = require('./data/g9_textbook_sections.json'); + +// Build lookup: book -> [ {chapter_slug, para_id, num, title} ] +// and flat: chapter_slug+para_id -> para number (integer) +const byBook = new Map(); +const paraNum = new Map(); // key: `${chapter_slug}:${para_id}` -> integer N + +for (const sec of taxonomy) { + if (!sec.para_id) continue; // math-5/6 engine-based, skip for slug lookup + const n = parseInt((sec.num || '').replace(/[^\d]/g, ''), 10); + if (!isNaN(n)) { + paraNum.set(`${sec.chapter_slug}:${sec.para_id}`, n); + } + if (!byBook.has(sec.book)) byBook.set(sec.book, []); + byBook.get(sec.book).push(sec); +} + +/* Helper: resolve (chapter_slug, para_id) → integer paragraph number. + For math-5/6 (engine-based, no para_id in JSON) we use para_id directly + parsed as an integer if it's like "p7" → 7. */ +function paraToInt(chapter_slug, para_id) { + const key = `${chapter_slug}:${para_id}`; + if (paraNum.has(key)) return paraNum.get(key); + // fallback: parse "pN" → N + const m = String(para_id || '').match(/^p(\d+)$/); + return m ? parseInt(m[1], 10) : null; +} + +/* ── Text normalization ───────────────────────────────────────── */ +function stripText(html) { + return String(html || '') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .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, ' ') + .replace(/[{}$]/g, ' ') + .replace(/&[a-z]+;/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/* ── Candidate rules per subtopic ─────────────────────────────── */ +/* + Each entry: { slug, paragraph, keywords: [regexp,...], boost } + Scoring: sum of keyword matches (each match = 1 point + boost if set). + The entry with the highest score wins; ties: first (most specific first). + Fallback: first entry (primary §). +*/ + +const SUBTOPIC_RULES = { + + /* ── alg-numbers ──────────────────────────────────────────── */ + 'alg-numbers': [ + // Rational numbers → math-6-ch4 + { slug: 'math-6-ch4', paragraph: null, + kw: [/рациональн|отрицательн|положительн|противоположн|модул[ья]/i] }, + // Natural numbers, divisibility → math-5-ch1 + { slug: 'math-5-ch1', paragraph: null, + kw: [/натуральн|НОД|НОК|делитель|кратное|простое\s+число|составное/i] }, + // Sets / intervals → algebra-8-ch1 §5 + { slug: 'algebra-8-ch1', paragraph: 5, + kw: [/промежуток|объединение|пересечение|принадлежит\s+числ/i] }, + // fallback + { slug: 'math-6-ch4', paragraph: null, kw: [] }, + ], + + /* ── alg-arithmetic ──────────────────────────────────────── */ + 'alg-arithmetic': [ + // Decimals → math-6-ch1 + { slug: 'math-6-ch1', paragraph: null, + kw: [/десятичн[ая]\s+дроб|запятой/i] }, + // Fractions (ordinary) → math-5-ch3 + { slug: 'math-5-ch3', paragraph: null, + kw: [/обыкновенн[ая]\s+дроб|числитель|знаменатель.*не.*алгебр/i] }, + // Calculation → math-5-ch2 + { slug: 'math-5-ch2', paragraph: null, + kw: [/вычисл|выражени[ея]\s+со\s+скобк|порядок\s+действий/i] }, + // fallback + { slug: 'math-6-ch1', paragraph: null, kw: [] }, + ], + + /* ── alg-powers ──────────────────────────────────────────── */ + 'alg-powers': [ + // Standard form → algebra-7-ch1 §3 + { slug: 'algebra-7-ch1', paragraph: 3, + kw: [/стандартн[ый]*\s+вид|A\s*[·x*]\s*10\^?\s*[\-{n]|\d+\s*[·*]\s*10\^/i] }, + // Integer exponent → algebra-7-ch1 §2 + { slug: 'algebra-7-ch1', paragraph: 2, + kw: [/целый?\s+показател|отрицательн[ый]*\s+показател|степен[ьи]\s+с\s+(?:целым|отрицательным)/i] }, + // Square root → algebra-8-ch1 §1 + { slug: 'algebra-8-ch1', paragraph: 1, + kw: [/квадратн[ый]*\s+корен|арифметическ[ий]*\s+корен|извлеч[её]нь?|sqrt\(/i] }, + // Sqrt properties → algebra-8-ch1 §3 + { slug: 'algebra-8-ch1', paragraph: 3, + kw: [/свойств[ао]\s+(?:квадратных\s+)?корн[ей]|упрост[иь].*sqrt|вынеси.*за\s+знак|внес[иь].*под\s+знак/i] }, + // Sqrt application → algebra-8-ch1 §4 + { slug: 'algebra-8-ch1', paragraph: 4, + kw: [/приблиз[иь].*корн|применен[ие]\s+свойств\s+корн/i] }, + // Natural exponent → algebra-7-ch1 §1 (fallback to ch1) + { slug: 'algebra-7-ch1', paragraph: 1, + kw: [/натуральн[ый]*\s+показател|степен[ьи]\s+числа\s+[a-z]/i] }, + // fallback + { slug: 'algebra-7-ch1', paragraph: 1, kw: [] }, + ], + + /* ── alg-expressions ─────────────────────────────────────── */ + 'alg-expressions': [ + // Rational expression transform → algebra-9-ch1 §5 + { slug: 'algebra-9-ch1', paragraph: 5, + kw: [/рациональн[ое]*\s+выражени|преобразован[ие]\s+выражени|упростите\s+выражени|тождественно\s+равн/i] }, + // Variables / substitution → algebra-7-ch2 §4 + { slug: 'algebra-7-ch2', paragraph: 4, + kw: [/с\s+переменн|числовое\s+значение\s+при|значение\s+выражения\s+при\s+[a-z]/i] }, + // Identity → algebra-7-ch2 §5 + { slug: 'algebra-7-ch2', paragraph: 5, + kw: [/тождество|тождественн/i] }, + // fallback + { slug: 'algebra-9-ch1', paragraph: 5, kw: [] }, + ], + + /* ── alg-polynomials ─────────────────────────────────────── */ + 'alg-polynomials': [ + // Factoring → algebra-7-ch2 §14 + { slug: 'algebra-7-ch2', paragraph: 14, + kw: [/разложите\s+на\s+множител|вынесите.*за\s+скобк|разложение.*множител/i] }, + // Difference of squares → algebra-7-ch2 §13 + { slug: 'algebra-7-ch2', paragraph: 13, + kw: [/разность\s+квадрат|a\s*[\-−]\s*b.*a\s*\+\s*b/i] }, + // Square of sum/diff → algebra-7-ch2 §12 + { slug: 'algebra-7-ch2', paragraph: 12, + kw: [/квадрат\s+сумм|квадрат\s+разност|формул[а-я]*\s+сокращённ/i] }, + // Multiply polynomials → algebra-7-ch2 §11 + { slug: 'algebra-7-ch2', paragraph: 11, + kw: [/перемнож[иь]|умножени[ея]\s+многочлен|произведени[ея]\s+многочлен/i] }, + // Monomial operations → algebra-7-ch2 §7 + { slug: 'algebra-7-ch2', paragraph: 7, + kw: [/одночлен.*умн|умн.*одночлен|одночлен.*дел|действия\s+с\s+одночлен/i] }, + // Polynomial standard form → algebra-7-ch2 §8 + { slug: 'algebra-7-ch2', paragraph: 8, + kw: [/многочлен[.!,\s]|стандартн[ый]*\s+вид.*многочлен|степень\s+многочлен/i] }, + // Monomial standard form → algebra-7-ch2 §6 + { slug: 'algebra-7-ch2', paragraph: 6, + kw: [/одночлен[.!,\s]|стандартн[ый]*\s+вид.*одночлен|степень\s+одночлен/i] }, + // Add/subtract polynomials → algebra-7-ch2 §9 + { slug: 'algebra-7-ch2', paragraph: 9, + kw: [/сложени[ея]\s+многочлен|вычитани[ея]\s+многочлен|подобные\s+слагаем/i] }, + // Divide/multiply by monomial → algebra-7-ch2 §10 + { slug: 'algebra-7-ch2', paragraph: 10, + kw: [/умножени[ея].*одночлен|деление.*одночлен|на\s+одночлен/i] }, + // quadratic trinomial factoring → algebra-8-ch2 §10 + { slug: 'algebra-8-ch2', paragraph: 10, + kw: [/квадратн[ый]*\s+трёхчлен|разложени[ея].*трёхчлен/i] }, + // fallback + { slug: 'algebra-7-ch2', paragraph: 14, kw: [] }, + ], + + /* ── alg-fractions ───────────────────────────────────────── */ + 'alg-fractions': [ + // Rational fraction transform → algebra-9-ch1 §5 + { slug: 'algebra-9-ch1', paragraph: 5, + kw: [/преобразован[ие]\s+(?:рациональн[ых]?\s+)?выражени|упростите|тождественно\s+равн/i] }, + // Multiply/divide rational → algebra-9-ch1 §4 + { slug: 'algebra-9-ch1', paragraph: 4, + kw: [/умножени[ея]\s+(?:и\s+делени[ея]\s+)?(?:рациональн[ых]?\s+)?дроб|делени[ея]\s+дроб|произведени[ея].*дроб|частное.*дроб/i] }, + // Add/subtract rational → algebra-9-ch1 §3 + { slug: 'algebra-9-ch1', paragraph: 3, + kw: [/сложени[ея]\s+(?:и\s+вычитани[ея]\s+)?(?:рациональн[ых]?\s+)?дроб|вычитани[ея]\s+дроб|сумм[а-я]*.*дроб/i] }, + // Main property of fraction → algebra-9-ch1 §2 + { slug: 'algebra-9-ch1', paragraph: 2, + kw: [/основное\s+свойство\s+дроб|сократите\s+дробь|сокращени[ея]\s+дроб/i] }, + // Rational fraction definition → algebra-9-ch1 §1 + { slug: 'algebra-9-ch1', paragraph: 1, + kw: [/рациональная\s+дробь|область.*допустим|ОДЗ|не\s+имеет\s+смысла/i] }, + // Ordinary fractions → math-6-ch1 (decimals/ordinary) + { slug: 'math-5-ch3', paragraph: null, + kw: [/обыкновенн[ая]\s+дроб|числитель\s+и\s+знаменатель(?!\s+дроб)/i] }, + // fallback + { slug: 'algebra-9-ch1', paragraph: 3, kw: [] }, + ], + + /* ── alg-equations ───────────────────────────────────────── */ + 'alg-equations': [ + // Fractional-rational equations → algebra-9-ch3 §10 + { slug: 'algebra-9-ch3', paragraph: 10, + kw: [/дробно[-\s]?рациональн[ое]*\s+уравнен|дроб.*знаменател.*уравнен|уравнени[ея].*с\s+дроб/i] }, + // Systems of nonlinear → algebra-9-ch3 §11 + { slug: 'algebra-9-ch3', paragraph: 11, + kw: [/систем[а-я]*\s+нелинейн|нелинейн[ая]*\s+систем/i] }, + // Systems of linear (method) → algebra-7-ch4 §24 + { slug: 'algebra-7-ch4', paragraph: 24, + kw: [/метод\s+(?:подстановки|сложени[ея])|подстановк[и]*\s+(?:в\s+)?систем|способ[а-я]*\s+решени[ея]\s+систем/i] }, + // Systems of linear (definition) → algebra-7-ch4 §23 + { slug: 'algebra-7-ch4', paragraph: 23, + kw: [/систем[а-я]*\s+(?:линейных\s+)?уравнен|решит[ье]?\s+систем/i] }, + // Quadratic (reduced to) → algebra-8-ch2 §12 + { slug: 'algebra-8-ch2', paragraph: 12, + kw: [/(?:целые|целое|рациональн[ое]*)\s+уравнен[ие]*.*(?:сводящ|квадратн)|сводящ[ееся]*\s+к\s+квадратн/i] }, + // Quadratic by Vieta → algebra-8-ch2 §9 + { slug: 'algebra-8-ch2', paragraph: 9, + kw: [/теорема\s+Виета|Виета|по\s+формуле\s+Виета|сумм[а-я]*\s+корн[ей].*произведен|произведен.*корн[ей].*сумм/i] }, + // Quadratic by discriminant → algebra-8-ch2 §8 + { slug: 'algebra-8-ch2', paragraph: 8, + kw: [/дискриминант|формул[а-я]*\s+корн[ей]\s+квадратн|D\s*=\s*b\^2\s*-\s*4ac/i] }, + // Incomplete quadratic → algebra-8-ch2 §7 + { slug: 'algebra-8-ch2', paragraph: 7, + kw: [/неполн[ое]*\s+квадратн[ое]*\s+уравнен|квадратное\s+уравнение(?!\s+.*(?:Виета|дискриминант))/i] }, + // Linear equation → algebra-7-ch3 §15 + { slug: 'algebra-7-ch3', paragraph: 15, + kw: [/линейное\s+уравнение\s+с\s+одной|линейн[ое]*\s+уравнени[ею]/i] }, + // fallback: quadratic by discriminant (most common in math9) + { slug: 'algebra-8-ch2', paragraph: 8, kw: [] }, + ], + + /* ── alg-inequalities ────────────────────────────────────── */ + 'alg-inequalities': [ + // Fractional-rational inequalities → algebra-8-ch3 §18 + { slug: 'algebra-8-ch3', paragraph: 18, + kw: [/дробно[-\s]?рациональн[ое]*\s+неравенств|дроб.*знаменател.*неравенств/i] }, + // Method of intervals → algebra-9-ch3 §13 (primary) or algebra-8-ch3 §17 + { slug: 'algebra-9-ch3', paragraph: 13, + kw: [/метод\s+интервалов|знак[а-я]*\s+на\s+(?:каждом|промежутке)\s+(?:каждого\s+)?промежутк|числовая\s+ось.*интервал/i] }, + { slug: 'algebra-8-ch3', paragraph: 17, + kw: [/квадратное\s+неравенств|квадратн[а-я]+\s+неравенств|ax\^2|a\s*x\^2/i] }, + // Systems of linear inequalities → algebra-8-ch3 §16 (or algebra-7-ch3 §18) + { slug: 'algebra-8-ch3', paragraph: 16, + kw: [/систем[а-я]*\s+неравенств|совокупност[а-я]*\s+неравенств|двойное\s+неравенств/i] }, + // Linear inequalities → algebra-8-ch3 §15 + { slug: 'algebra-8-ch3', paragraph: 15, + kw: [/линейн[ое]*\s+неравенств|linear.*неравенств/i] }, + // Numeric inequalities props → algebra-8-ch3 §13 + { slug: 'algebra-8-ch3', paragraph: 13, + kw: [/свойств[а-я]*\s+неравенств|числовы[ех]*\s+неравенств/i] }, + // fallback + { slug: 'algebra-8-ch3', paragraph: 17, kw: [] }, + ], + + /* ── alg-functions ───────────────────────────────────────── */ + 'alg-functions': [ + // Graph shifts → algebra-9-ch2 §9 + { slug: 'algebra-9-ch2', paragraph: 9, + kw: [/сдвиг[и]*\s+график|параллельн[ый]*\s+перенос\s+график|y\s*=\s*f\(\s*x\s*(?:[+\-]|−)|y\s*=\s*f\(\s*x\s*\)\s*(?:[+\-]|−)/i] }, + // Even/odd → algebra-9-ch2 §8 + { slug: 'algebra-9-ch2', paragraph: 8, + kw: [/чётн[а-я]*\s+функци[и]*|нечётн[а-я]*\s+функци[и]*/i] }, + // Properties (monotone, zeros) → algebra-9-ch2 §7 + { slug: 'algebra-9-ch2', paragraph: 7, + kw: [/свойств[а-я]*\s+функци[и]*|возрастан[ие]*|убыван[ие]*|нул[иь]\s+функци[и]*|наибольшее\s+значение\s+функци/i] }, + // Function definition → algebra-9-ch2 §6 + { slug: 'algebra-9-ch2', paragraph: 6, + kw: [/область\s+(?:определения|значений)|функция\s+числового\s+аргумент|область\s+допустим/i] }, + // Linear function → algebra-7-ch3 §20 + { slug: 'algebra-7-ch3', paragraph: 20, + kw: [/линейная\s+функция|y\s*=\s*kx\s*(?:[+\-]|−)\s*b|прямая.*пропорциональн|k\s*>\s*0.*возрастает/i] }, + // Function basics → algebra-7-ch3 §19 + { slug: 'algebra-7-ch3', paragraph: 19, + kw: [/понятие\s+функции|аргумент[а-я]*\s+и\s+значени[ея]|f\(\s*[a-z]\s*\)\s*=|соответств[ие]*.*каждому.*значению/i] }, + // Parabola → algebra-9-ch2 §7 (properties) or §9 (shifts) + { slug: 'algebra-9-ch2', paragraph: 9, + kw: [/параболы?\s+y\s*=.*\(\s*x|вершин[а-я]*\s+параболы?/i] }, + // fallback + { slug: 'algebra-9-ch2', paragraph: 6, kw: [] }, + ], + + /* ── alg-progressions ────────────────────────────────────── */ + 'alg-progressions': [ + // Infinite geometric (|q|<1) → algebra-9-ch4 §19 + { slug: 'algebra-9-ch4', paragraph: 19, + kw: [/бесконечно\s+убывающ|сумм[а-я]*\s+бесконечн|знаменател[ья]*\s*<\s*1|\|q\|\s*<\s*1/i] }, + // Sum of geometric → algebra-9-ch4 §18 + { slug: 'algebra-9-ch4', paragraph: 18, + kw: [/сумм[а-я]*\s+(?:первых\s+n\s+членов\s+)?геометрическ|S_n.*геометрическ|геометрическ.*S_n/i] }, + // Geometric progression → algebra-9-ch4 §17 + { slug: 'algebra-9-ch4', paragraph: 17, + kw: [/геометрическ[а-я]*\s+прогресс|знаменатель\s+прогресс|знаменател[ья]*\s+геометрическ|q\s*=\s*(?:[b-z]|\d)/i] }, + // Sum of arithmetic → algebra-9-ch4 §16 + { slug: 'algebra-9-ch4', paragraph: 16, + kw: [/сумм[а-я]*\s+(?:первых\s+n\s+членов\s+)?арифметическ|S_n.*арифметическ|арифметическ.*S_n/i] }, + // Arithmetic progression → algebra-9-ch4 §15 + { slug: 'algebra-9-ch4', paragraph: 15, + kw: [/арифметическ[а-я]*\s+прогресс|разность\s+прогресс|разност[ья]*\s+арифметическ|d\s*=\s*(?:[b-z]|\d)/i] }, + // Sequence → algebra-9-ch4 §14 + { slug: 'algebra-9-ch4', paragraph: 14, + kw: [/числовая?\s+последовательност|формул[а-я]*\s+(?:общего\s+)?члена|a_n\s*=|заданной\s+формулой/i] }, + // fallback: arithmetic (most common) + { slug: 'algebra-9-ch4', paragraph: 15, kw: [] }, + ], + + /* ── alg-word-problems ───────────────────────────────────── */ + 'alg-word-problems': [ + // Percents/proportions → math-6-ch2 + { slug: 'math-6-ch2', paragraph: null, + kw: [/процент|%|пропорци[иья]/i] }, + // System of equations → algebra-7-ch4 §25 + { slug: 'algebra-7-ch4', paragraph: 25, + kw: [/систем[а-я]*.*задач|задач.*систем|двое\s+рабочих|две\s+бригады|двое\s+работн/i] }, + // Quadratic equation → algebra-8-ch2 §11 + { slug: 'algebra-8-ch2', paragraph: 11, + kw: [/квадратн[ое]*\s+уравнени[ею]\s+(?:для\s+)?задач|задач.*квадратн|квадратн.*задач|площадь.*прямоугольника.*уравнен/i] }, + // Linear equation text problems → algebra-7-ch3 §16 + { slug: 'algebra-7-ch3', paragraph: 16, + kw: [/линейн[ое]*\s+уравнени[ею]\s+(?:для\s+)?задач|текстов[ые]*\s+задач.*уравнени/i] }, + // Scale/map → math-6-ch2 + { slug: 'math-6-ch2', paragraph: null, + kw: [/масштаб|карт[ае]\s+изображ/i] }, + // fallback: percentages (most common in word problems) + { slug: 'math-6-ch2', paragraph: null, kw: [] }, + ], + + /* ── geom-triangles ──────────────────────────────────────── */ + 'geom-triangles': [ + // Trig (sin/cos law) in any triangle → geometry-9-ch3 + { slug: 'geometry-9-ch3', paragraph: 10, + kw: [/теорема\s+синусов|law\s+of\s+sine/i] }, + { slug: 'geometry-9-ch3', paragraph: 11, + kw: [/теорема\s+косинусов|law\s+of\s+cosine/i] }, + { slug: 'geometry-9-ch3', paragraph: 12, + kw: [/формула\s+Герона|решени[ея]\s+треугольника/i] }, + // Right triangle trig → geometry-9-ch1 + { slug: 'geometry-9-ch1', paragraph: 2, + kw: [/прямоугольн[ый]*\s+треугольник.*(?:sin|cos|tg|ctg|катет|гипотенуз)|(?:sin|cos|tg|ctg).*прямоугольн[ый]*\s+треугольник/i] }, + { slug: 'geometry-9-ch1', paragraph: 1, + kw: [/\bsin\s+[A-ZА-Я]|\bcos\s+[A-ZА-Я]|\btg\s+[A-ZА-Я]|\bctg\s+[A-ZА-Я]|синус|косинус|тангенс|котангенс/i] }, + { slug: 'geometry-9-ch1', paragraph: 5, + kw: [/площадь.*синус|S\s*=\s*(?:\()?[0-9.]*?[ab1]/i] }, + // Similar triangles → geometry-8-ch3 + { slug: 'geometry-8-ch3', paragraph: 3, + kw: [/подобн[ы]*\s+треугольник|коэффициент\s+подобия|отношение.*сторон.*подобн/i] }, + { slug: 'geometry-8-ch3', paragraph: 5, + kw: [/первый\s+признак\s+подобия/i] }, + { slug: 'geometry-8-ch3', paragraph: 6, + kw: [/второй\s+признак\s+подобия/i] }, + { slug: 'geometry-8-ch3', paragraph: 7, + kw: [/третий\s+признак\s+подобия/i] }, + // Pythagorean theorem → geometry-8-ch2 §11 + { slug: 'geometry-8-ch2', paragraph: 11, + kw: [/пифагор|a\^2\s*[+\-]\s*b\^2|гипотенузы?\^2/i] }, + // Isosceles triangle → geometry-7-ch2 §11–12 + { slug: 'geometry-7-ch2', paragraph: 11, + kw: [/равнобедренн[ый]*\s+треугольник|основание\s+и\s+боковые\s+стороны/i] }, + // Triangle congruence → geometry-7-ch2 §9 + { slug: 'geometry-7-ch2', paragraph: 9, + kw: [/признаки?\s+равенства\s+треугольников|первый\s+признак\s+равенства|второй\s+признак\s+равенства/i] }, + { slug: 'geometry-7-ch2', paragraph: 13, + kw: [/третий\s+признак\s+равенства\s+треугольников/i] }, + // Right triangle special → geometry-7-ch4 §23–26 + { slug: 'geometry-7-ch4', paragraph: 23, + kw: [/прямоугольн[ый]*\s+треугольник(?!.*подобн|.*пифагор|.*sin|.*cos)/i] }, + { slug: 'geometry-7-ch4', paragraph: 26, + kw: [/катет.*30|угол\s+30|30°/i] }, + // Median, altitude, bisector → geometry-7-ch2 §10 + { slug: 'geometry-7-ch2', paragraph: 10, + kw: [/высота\s+треугольника|медиана\s+треугольника|биссектриса\s+треугольника/i] }, + // Triangle angles sum → geometry-7-ch4 §19–20 + { slug: 'geometry-7-ch4', paragraph: 19, + kw: [/сумма\s+углов\s+треугольника/i] }, + { slug: 'geometry-7-ch4', paragraph: 20, + kw: [/внешний\s+угол\s+треугольника/i] }, + // fallback + { slug: 'geometry-7-ch2', paragraph: 9, kw: [] }, + ], + + /* ── geom-quadrilaterals ─────────────────────────────────── */ + 'geom-quadrilaterals': [ + // Inscribed/circumscribed quadrilaterals → geometry-9-ch2 §9 + { slug: 'geometry-9-ch2', paragraph: 9, + kw: [/вписанн[ый]*\s+четырёхугольник|описанн[ый]*\s+четырёхугольник|вписан.*четырёхуголь/i] }, + // Regular polygon → geometry-9-ch4 + { slug: 'geometry-9-ch4', paragraph: 13, + kw: [/правильн[ый]*\s+(?:многоугольник|шестиугольник|пятиугольник|восьмиугольник)/i] }, + // Trapezoid → geometry-8-ch1 §14–16 + { slug: 'geometry-8-ch1', paragraph: 14, + kw: [/трапеци[ия]|средняя\s+линия\s+трапеци/i] }, + { slug: 'geometry-8-ch1', paragraph: 15, + kw: [/равнобедренн[ая]*\s+трапеци[ия]\s+(?:свойств|признак)/i] }, + // Rhombus → geometry-8-ch1 §9 + { slug: 'geometry-8-ch1', paragraph: 9, + kw: [/ромб[а-я]*(?:\s|,|\.)/i] }, + // Rectangle → geometry-8-ch1 §7 + { slug: 'geometry-8-ch1', paragraph: 7, + kw: [/прямоугольник[а-я]*(?:\s+свойств|.*диагональ)/i] }, + // Square → geometry-8-ch1 §10 + { slug: 'geometry-8-ch1', paragraph: 10, + kw: [/квадрат[а-я]*\s+(?:свойств|признак|ABCD|со\s+стороной)/i] }, + // Parallelogram properties → geometry-8-ch1 §5 + { slug: 'geometry-8-ch1', paragraph: 5, + kw: [/свойства?\s+параллелограмма/i] }, + // Parallelogram signs → geometry-8-ch1 §6 + { slug: 'geometry-8-ch1', paragraph: 6, + kw: [/признаки?\s+параллелограмма/i] }, + // Parallelogram definition → geometry-8-ch1 §4 + { slug: 'geometry-8-ch1', paragraph: 4, + kw: [/параллелограмм/i] }, + // Sum of angles polygon → geometry-8-ch1 §2 + { slug: 'geometry-8-ch1', paragraph: 2, + kw: [/сумма\s+углов.*многоугольника|сумм[а-я]*\s+внутренних\s+углов/i] }, + // fallback + { slug: 'geometry-8-ch1', paragraph: 4, kw: [] }, + ], + + /* ── geom-circle ─────────────────────────────────────────── */ + 'geom-circle': [ + // Circumference and area of circle → geometry-9-ch4 §16 + { slug: 'geometry-9-ch4', paragraph: 16, + kw: [/длина\s+окружности|площадь\s+круга|2\s*\*?\s*pi\s*\*?\s*r|π\s*r\^?2/i] }, + // Inscribed/circumscribed circles in triangles → geometry-9-ch2 §7–8 + { slug: 'geometry-9-ch2', paragraph: 7, + kw: [/описанн[ая]*\s+окружность.*треугольн|вписанн[ая]*\s+окружность.*треугольн/i] }, + { slug: 'geometry-9-ch2', paragraph: 8, + kw: [/окружност[ьи]*\s+прямоугольного\s+треугольника/i] }, + // Inscribed angle on diameter → geometry-8-ch4 §11 + { slug: 'geometry-8-ch4', paragraph: 11, + kw: [/вписанн[ый]*\s+угол.*диаметр|угол.*полуокружн|90°.*диаметр|опирается.*диаметр/i] }, + // Inscribed angle property → geometry-8-ch4 §9 + { slug: 'geometry-8-ch4', paragraph: 9, + kw: [/свойство\s+вписанного\s+угла|вписанн[ый]*\s+угол\s+равен\s+половине/i] }, + // Central angle → geometry-8-ch4 §8 + { slug: 'geometry-8-ch4', paragraph: 8, + kw: [/центральн[ый]*\s+угол|градусная\s+мера\s+дуги/i] }, + // Tangent from point → geometry-8-ch4 §3 + { slug: 'geometry-8-ch4', paragraph: 3, + kw: [/касательн[ые]*\s+из\s+(?:одной\s+)?точки|от\s+внешней\s+точки.*касательн/i] }, + // Tangent recognition → geometry-8-ch4 §1 + { slug: 'geometry-8-ch4', paragraph: 1, + kw: [/признак\s+касательной|касательная.*перпендикулярна\s+радиусу/i] }, + // Chord properties → geometry-8-ch4 §15 + { slug: 'geometry-8-ch4', paragraph: 15, + kw: [/пересекающихся\s+хорд|произведен.*хорд/i] }, + // Tangent-chord angle → geometry-8-ch4 §12 + { slug: 'geometry-8-ch4', paragraph: 12, + kw: [/угол\s+между\s+касательной\s+и\s+хордой/i] }, + // Circle (basic, g7) → geometry-7-ch1 §4 + { slug: 'geometry-7-ch1', paragraph: 4, + kw: [/окружность\s+и\s+круг|радиус\s+и\s+диаметр(?!\s+.*центральн)/i] }, + // fallback + { slug: 'geometry-8-ch4', paragraph: 8, kw: [] }, + ], + + /* ── geom-coordinates ────────────────────────────────────── */ + 'geom-coordinates': [ + // Circle equation → algebra-9-ch3 §12 + { slug: 'algebra-9-ch3', paragraph: 12, + kw: [/уравнение\s+окружности|\(x\s*[-−]\s*a\)\^2\s*[+\-]\s*\(y\s*[-−]\s*b\)\^2|центр\s+окружности.*уравнен/i] }, + // Coordinate plane → math-6-ch5 + { slug: 'math-6-ch5', paragraph: null, + kw: [/координатная\s+плоскость|ось\s+(?:OX|OY|абсцисс|ординат)|XOY/i] }, + // Vectors / linear equation → algebra-7-ch4 §21–22 + { slug: 'algebra-7-ch4', paragraph: 22, + kw: [/уравнение\s+прямой|ax\s*\+\s*by\s*=\s*c|линейное\s+уравнение.*двумя\s+переменными.*график/i] }, + { slug: 'algebra-7-ch4', paragraph: 21, + kw: [/линейное\s+уравнение\s+с\s+двумя\s+переменными/i] }, + // fallback + { slug: 'algebra-9-ch3', paragraph: 12, kw: [] }, + ], + + /* ── theory-statements ───────────────────────────────────── */ + 'theory-statements': [ + // Try to categorise by content — same keywords as main subtopics + { slug: 'algebra-9-ch4', paragraph: 15, + kw: [/прогресс|последовательност/i] }, + { slug: 'algebra-8-ch3', paragraph: 17, + kw: [/неравенств/i] }, + { slug: 'algebra-8-ch2', paragraph: 8, + kw: [/уравнен/i] }, + { slug: 'algebra-9-ch2', paragraph: 7, + kw: [/функц|график/i] }, + { slug: 'geometry-8-ch4', paragraph: 8, + kw: [/окружност|дуг|вписанн|касательн|хорд/i] }, + { slug: 'geometry-8-ch1', paragraph: 4, + kw: [/параллелограмм|трапеци|ромб|прямоугольник/i] }, + { slug: 'geometry-7-ch2', paragraph: 9, + kw: [/треугольник|подобн|пифагор|синус|косинус/i] }, + { slug: 'algebra-9-ch1', paragraph: 5, + kw: [/дробь|алгебраическ|многочлен/i] }, + // If no match → null (leave for subtopic fallback in controller) + { slug: null, paragraph: null, kw: [] }, + ], +}; + +/* ── Classifier ───────────────────────────────────────────── */ +function classify(task) { + const subtopic = task.subtopic; + if (!subtopic) return { slug: null, para: null }; + + const rules = SUBTOPIC_RULES[subtopic]; + if (!rules || !rules.length) return { slug: null, para: null }; + + const txt = stripText(task.text_html); + + let bestScore = -1; + let bestRule = rules[rules.length - 1]; // last = fallback + + for (const rule of rules) { + if (!rule.kw.length) continue; // skip fallback entries in scoring + let score = 0; + for (const re of rule.kw) { + if (re.test(txt)) score++; + } + if (score > bestScore) { + bestScore = score; + bestRule = rule; + } + } + + // If nothing matched (bestScore == -1 and bestRule is fallback), use first entry (primary) + if (bestScore < 0) { + bestRule = rules[rules.length - 1]; // explicit fallback + } + + return { slug: bestRule.slug, para: bestRule.paragraph }; +} + +/* ── Main ─────────────────────────────────────────────────── */ +function main() { + const tasks = db.prepare(` + SELECT id, variant, task_idx, task_type, subtopic, text_html + FROM exam_tasks + WHERE exam_key = ? + ORDER BY variant, task_idx + `).all(examKey); + + if (!tasks.length) { + console.error(`No tasks found for exam_key='${examKey}'. Check DB.`); + process.exit(1); + } + + const upd = db.prepare(` + UPDATE exam_tasks + SET textbook_slug = ?, textbook_paragraph = ? + WHERE id = ? + `); + + const stats = { + total: tasks.length, + tagged: 0, + null_slug: 0, + bySub: new Map(), + bySlug: new Map(), + }; + + const updates = []; + for (const t of tasks) { + const { slug, para } = classify(t); + updates.push({ id: t.id, slug, para, subtopic: t.subtopic }); + if (slug) { + stats.tagged++; + const key = `${slug}#${para ?? 'hub'}`; + stats.bySlug.set(key, (stats.bySlug.get(key) || 0) + 1); + } else { + stats.null_slug++; + } + if (t.subtopic) { + if (!stats.bySub.has(t.subtopic)) stats.bySub.set(t.subtopic, { tagged: 0, null: 0 }); + const s = stats.bySub.get(t.subtopic); + if (slug) s.tagged++; else s.null++; + } + } + + if (!DRY) { + db.transaction(() => { + for (const u of updates) upd.run(u.slug, u.para, u.id); + })(); + } + + if (VERBOSE) { + for (const u of updates) { + console.log(` id=${u.id} subtopic=${u.subtopic} -> ${u.slug}#${u.para}`); + } + } + + /* ── Report ── */ + const pct = n => ((n / stats.total) * 100).toFixed(1) + '%'; + if (REPORT || true) { // always print summary + console.log(`\n=== ${examKey} textbook-tagging${DRY ? ' (DRY RUN)' : ''} ===`); + console.log(`Total tasks : ${stats.total}`); + console.log(`Tagged : ${stats.tagged} (${pct(stats.tagged)})`); + console.log(`Null/hub : ${stats.null_slug} (${pct(stats.null_slug)})`); + + console.log(`\nBy subtopic:`); + for (const [sub, s] of [...stats.bySub.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const tot = s.tagged + s.null; + console.log(` ${sub.padEnd(22)} tagged=${String(s.tagged).padStart(4)}/${tot} null=${s.null}`); + } + + if (REPORT) { + console.log(`\nBy chapter#paragraph (top 40):`); + const sorted = [...stats.bySlug.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40); + for (const [key, n] of sorted) { + console.log(` ${key.padEnd(35)} ${String(n).padStart(4)} (${pct(n)})`); + } + } + } + + if (DRY) console.log(`\n[DRY RUN] No changes written.`); + else console.log(`\nDone. ${stats.tagged} tasks tagged, ${stats.null_slug} left without slug.`); +} + +main();