feat(exam): Phase 3 — классификатор tag-exam-textbook.js (100% math9, 800/800)

Детерминированная эвристика: 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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 16:18:29 +03:00
parent c7cfd72e7f
commit e210410526
+644
View File
@@ -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(/<svg[\s\S]*?<\/svg>/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 §1112
{ 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 §2326
{ 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 §1920
{ 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 §1416
{ 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 §78
{ 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 §2122
{ 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();