Files
Learn_System/backend/scripts/seed_ctmath_exam_tasks.js
T
Maxim Dolgolyov fd26efca53 feat(ct-math): конвертер questions→exam_tasks для отдельного модуля ctmath (dry-готов)
- backend/scripts/seed_ctmath_exam_tasks.js — переносит размеченные вопросы
  ЦТ-11 из банка questions в exam_tasks (exam_key='ctmath') для отдельного
  модуля exam-prep. Dry по умолчанию, запись только с --apply.
  Правила сверены с exam-prep: MC-метки кириллица а..д (answer=метка);
  open числовой/дробь/пара иначе long; делимитеры \( \)→$, \[ \]→$$;
  subtopic=slug из 077; variant=год; multi/multiple пропуск.
  Dry-run: 733 вопроса → 723 (525 mc + 191 open + 7 long), выборка корректна.
- BUILD_ON_QUESTIONS.md: решение «ЦТ = отдельный модуль» + план + dry-результат.

Запись в БД (применение 077 + вставка 723) — ожидает явной санкции пользователя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:56:43 +03:00

150 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 += `<div class="sol-ans">Ответ: ${task_type === 'mc' ? answer + ')' : '$' + answer + '$'}</div>`;
if (!sol.trim()) sol = '<div class="sol-ans">См. решение в источнике.</div>';
const figure = q.image ? `<img src="${String(q.image)}" alt="" loading="lazy" style="max-width:100%">` : null;
perVariant[variant] = (perVariant[variant] || 0) + 1;
out.push({
exam_key: EXAM_KEY, variant, task_idx: perVariant[variant],
task_type, text_html: conv(q.text), figure_html: figure, opts_json, answer,
solution_html: sol, topic, subtopic, difficulty: q.difficulty || 1, _qid: q.id, _tn: q.topic_name,
});
}
console.log(APPLY ? '[APPLY]' : '[DRY-RUN]', `вход: ${rows.length} размеченных вопросов; к вставке: ${out.length}`);
console.log('Статистика типов:', JSON.stringify(stat));
console.log('Заданий по годам (variant):', JSON.stringify(perVariant));
const bySub = {};
out.forEach(o => { bySub[o.subtopic] = (bySub[o.subtopic] || 0) + 1; });
console.log('По подтемам:', JSON.stringify(bySub));
console.log('\n— Выборка (по одному mc / open / long) —');
for (const tp of ['mc', 'open', 'long']) {
const s = out.find(o => o.task_type === tp);
if (!s) continue;
console.log(`\n[${tp}] qid=${s._qid} тема="${s._tn}" → ${s.topic}/${s.subtopic} variant=${s.variant} diff=${s.difficulty}`);
console.log(' text :', s.text_html.replace(/\s+/g, ' ').slice(0, 160));
if (s.opts_json) console.log(' opts :', s.opts_json.slice(0, 200));
console.log(' answ :', s.answer);
console.log(' sol :', s.solution_html.replace(/\s+/g, ' ').slice(0, 140));
}
if (!APPLY) { console.log('\nDRY-RUN: запись НЕ выполнялась. Для записи: --apply (после применения миграции 077).'); process.exit(0); }
// ── APPLY ──
const track = db.prepare("SELECT exam_key FROM exam_tracks WHERE exam_key=?").get(EXAM_KEY);
if (!track) { console.error('Нет трека ctmath (миграция 077 не применена). Сначала примените 077.'); process.exit(1); }
const already = db.prepare("SELECT COUNT(*) c FROM exam_tasks WHERE exam_key=?").get(EXAM_KEY).c;
if (already > 0) { console.error(`В exam_tasks уже есть ${already} задач ctmath — повторная вставка отменена (избегаем дублей).`); process.exit(1); }
const ins = db.prepare(`INSERT INTO exam_tasks
(exam_key,variant,task_idx,task_type,text_html,figure_html,opts_json,answer,solution_html,topic,subtopic,difficulty)
VALUES (@exam_key,@variant,@task_idx,@task_type,@text_html,@figure_html,@opts_json,@answer,@solution_html,@topic,@subtopic,@difficulty)`);
let n = 0;
for (const o of out) { ins.run({ exam_key:o.exam_key, variant:o.variant, task_idx:o.task_idx, task_type:o.task_type, text_html:o.text_html, figure_html:o.figure_html, opts_json:o.opts_json, answer:o.answer, solution_html:o.solution_html, topic:o.topic, subtopic:o.subtopic, difficulty:o.difficulty }); n++; }
// обновим метаданные трека
const variants = Object.keys(perVariant).length;
db.prepare("UPDATE exam_tracks SET variants_count=? WHERE exam_key=?").run(variants, EXAM_KEY);
console.log(`\nВставлено ${n} задач в exam_tasks (ctmath). variants_count=${variants}.`);