diff --git a/backend/scripts/seed_ctmath_exam_tasks.js b/backend/scripts/seed_ctmath_exam_tasks.js new file mode 100644 index 0000000..f8956c7 --- /dev/null +++ b/backend/scripts/seed_ctmath_exam_tasks.js @@ -0,0 +1,149 @@ +'use strict'; +/* + * Конвертер: размеченные вопросы ЦТ-11 из банка `questions` (subject_id=3) + * → `exam_tasks` для отдельного модуля exam-prep (exam_key='ctmath'). + * + * По умолчанию DRY (только чтение, печать выборки и статистики). + * Запись ТОЛЬКО с флагом --apply (и только если применён трек 077). + * node backend/scripts/seed_ctmath_exam_tasks.js # dry: выборка+статистика + * node backend/scripts/seed_ctmath_exam_tasks.js --apply # запись + * + * Правила (сверены с exam-prep, см. plans/ct-math/BUILD_ON_QUESTIONS.md): + * - тип: single/true_false → 'mc'; fill-blank/short_answer → 'open' (если ответ + * числовой/дробь/пара) иначе 'long'; multi/multiple → пропуск (exam-prep mc = radio). + * - opts_json: [["а","html"],...] кириллические метки; answer(mc)=метка верного. + * - answer(open): очищенный числовой/дробь/пара; проверка на клиенте численная. + * - математика: \( \) → $ , \[ \] → $$ (exam-prep KaTeX знает только $/$$). + * - subtopic = slug из exam_topics(077); difficulty 1..3; variant=year. + */ +const db = require('../src/db/db'); +const APPLY = process.argv.includes('--apply'); +const MATH_ID = 3, EXAM_KEY = 'ctmath'; +const LABELS = ['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з']; + +// flat topic name → [section slug, subtopic slug] (slugs из миграции 077) +const TOPIC_MAP = { + 'Теория чисел': ['numbers', 'num-divisibility'], + 'Арифметика и степени': ['expressions', 'expr-powers-roots'], + 'Квадратные уравнения': ['equations', 'eq-quadratic'], + 'Тригонометрия': ['trigonometry', 'trig-identities'], + 'Тригонометрические уравнения':['trigonometry', 'trig-equations'], + 'Прогрессии': ['word-sequences', 'seq-progressions'], + 'Словесные задачи': ['word-sequences', 'word-problems'], + 'Неравенства': ['equations', 'eq-rational'], + 'Уравнения': ['equations', 'eq-rational'], + 'Функции': ['functions', 'fn-properties'], + 'Логарифмы': ['equations', 'eq-logarithmic'], + 'Показательные неравенства': ['equations', 'eq-exponential'], + 'Геометрия': ['planimetry', 'plan-triangles'], + 'Стереометрия': ['stereometry', 'ster-basics'], + 'Окружность и круг': ['planimetry', 'plan-circle'], + 'Числовые промежутки': ['equations', 'eq-linear'], + 'Подобные фигуры': ['planimetry', 'plan-quadrilaterals'], + 'Парабола': ['functions', 'fn-graphs'], + 'Статистика и диаграммы': ['advanced', 'adv-combined'], +}; + +// \( \) → $ ; \[ \] → $$ (replacement-функции, чтобы $ не интерпретировался) +function conv(s) { + return String(s || '') + .replace(/\\\(/g, () => '$').replace(/\\\)/g, () => '$') + .replace(/\\\[/g, () => '$$').replace(/\\\]/g, () => '$$'); +} +// численная проверяемость ответа (зеркало answer-check.js exam-prep) +function isNumericAnswer(s) { + if (s == null) return false; + const t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.'); + if (/^-?\d+(?:\.\d+)?$/.test(t)) return true; // число + if (/^-?\d+(?:\.\d+)?\/-?\d+(?:\.\d+)?$/.test(t)) return true; // дробь + const parts = String(s).replace(/\$/g, '').split(/[;]|\sи\s/).map(x => x.trim()).filter(Boolean); + if (parts.length === 2 && parts.every(p => /^-?\d+(?:[.,]\d+)?(?:\/-?\d+)?$/.test(p.replace(/\s/g, '')))) return true; // пара + return false; +} +function cleanAnswer(s) { return String(s || '').trim().replace(/\$/g, '').replace(/\s+/g, ' ').trim(); } + +const rows = db.prepare(` + SELECT q.id, q.text, q.type, q.difficulty, q.year, q.explanation, q.image, q.correct_text, + COALESCE(t.name,'') AS topic_name + FROM questions q LEFT JOIN topics t ON t.id = q.topic_id + WHERE q.subject_id = ? AND q.topic_id IS NOT NULL + AND (q.source_type IS NULL OR q.source_type <> 'экзамен 9') + ORDER BY q.year, q.id +`).all(MATH_ID); + +const optStmt = db.prepare('SELECT text, is_correct, order_index FROM options WHERE question_id=? ORDER BY order_index, id'); +const out = []; +const stat = { mc: 0, open: 0, long: 0, skip_multi: 0, skip_notopic: 0, skip_noopts: 0, open_demoted_long: 0 }; +const perVariant = {}; + +for (const q of rows) { + const map = TOPIC_MAP[q.topic_name]; + if (!map) { stat.skip_notopic++; continue; } + const [topic, subtopic] = map; + const opts = optStmt.all(q.id); + const variant = q.year || 0; + let task_type, opts_json = null, answer = null; + + if (q.type === 'single' || q.type === 'true_false') { + if (!opts.length) { stat.skip_noopts++; continue; } + task_type = 'mc'; + opts_json = JSON.stringify(opts.map((o, i) => [LABELS[i] || String(i + 1), conv(o.text)])); + const ci = opts.findIndex(o => o.is_correct); + answer = ci >= 0 ? (LABELS[ci] || String(ci + 1)) : null; + stat.mc++; + } else if (q.type === 'fill-blank' || q.type === 'short_answer') { + const corr = (opts.find(o => o.is_correct) || {}).text || q.correct_text || ''; + if (isNumericAnswer(corr)) { task_type = 'open'; answer = cleanAnswer(corr); stat.open++; } + else { task_type = 'long'; answer = null; stat.long++; stat.open_demoted_long++; } + } else { // multi / multiple + stat.skip_multi++; continue; + } + + // solution_html (NOT NULL): объяснение + строка ответа + let sol = conv(q.explanation || ''); + if (answer && task_type !== 'long') sol += `