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>
This commit is contained in:
@@ -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 += `<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}.`);
|
||||||
@@ -36,6 +36,29 @@ assignment-практика `mode='topic'`; колоды формул (`flashcar
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0a. Решение: ЦТ как ОТДЕЛЬНЫЙ модуль exam-prep (2026-06-15)
|
||||||
|
|
||||||
|
По решению пользователя ЦТ оформляется как **отдельный модуль** (как «Экзамен 9»): свой раздел
|
||||||
|
`/exam-prep/ctmath` с дашбордом, тренажёром по темам, пробниками на таймер, детектором слабых тем.
|
||||||
|
Это значит: применить трек+дерево тем (миграция **077**) и **перенести размеченные вопросы ЦТ-11 из
|
||||||
|
банка `questions` в `exam_tasks`** (exam_key='ctmath').
|
||||||
|
|
||||||
|
Конвертер: **`backend/scripts/seed_ctmath_exam_tasks.js`** (dry по умолчанию, запись `--apply`).
|
||||||
|
Правила сверены с exam-prep (агент-разведка): MC-метки кириллица `а,б,в,г,д`, `answer`=метка; open —
|
||||||
|
числовой/дробь/пара (иначе → `long` self-check); математика `\( \)`→`$`, `\[ \]`→`$$` (exam-prep KaTeX
|
||||||
|
знает только `$`/`$$`!); `subtopic`=slug из 077; `variant`=год; multi/multiple (radio несовместимо) — пропуск.
|
||||||
|
|
||||||
|
**Dry-run (2026-06-15):** 733 размеч. вопроса → **723 к вставке** (525 mc + 191 open + 7 long;
|
||||||
|
10 multi пропущено; 7 не-числовых → long). Делимитеры/метки/ответы корректны (проверено на выборке).
|
||||||
|
|
||||||
|
**Статус записи:** применение 077 и вставка 723 задач — это запись в живую БД; авто-режим её
|
||||||
|
заблокировал, ждём явной санкции пользователя. После applied: дать `content_access` (exam/ctmath классу)
|
||||||
|
+ ссылку в сайдбаре на `/exam-prep/ctmath`.
|
||||||
|
|
||||||
|
> ⚠️ Гоча: рендер exam-prep — ТОЛЬКО `$…$`/`$$…$$` (НЕ `\(…\)`). Конвертер это учитывает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Что уже есть (проверено чтением БД)
|
## 1. Что уже есть (проверено чтением БД)
|
||||||
|
|
||||||
| Таблица | Роль | Факт |
|
| Таблица | Роль | Факт |
|
||||||
|
|||||||
Reference in New Issue
Block a user