feat(ct-math): диагностический тест из реальных вопросов банка (tests.id=164)

- backend/scripts/seed_ctmath_diagnostic.js — идемпотентный сбор ОДНОГО test
  «Диагностика ЦЭ/ЦТ — Математика» из размеченных вопросов ЦТ-11 (в осн. 2024):
  5 single (базовые) + 10 fill-blank (средние/сложные), по 1 на ключевую тему.
  Новых вопросов не авторит. Применён: test id=164, 15 вопросов, лимит 40 мин.
  Выдать = assignment с test_id=164.
- BUILD_ON_QUESTIONS.md / README: отметка о готовой диагностике, статус.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-14 22:16:27 +03:00
parent c3816baf99
commit 228bd885ed
3 changed files with 100 additions and 4 deletions
+93
View File
@@ -0,0 +1,93 @@
'use strict';
/*
* Входная диагностика для курса «ЦЭ/ЦТ — Математика».
* Собирает ОДИН test из РЕАЛЬНЫХ размеченных вопросов ЦТ-11 (banks 20112024):
* по 1 заданию на ключевую тему, смесь уровней (single 🟢 → fill-blank 🔴).
* Новых вопросов НЕ авторит — только группирует существующие.
* ИДЕМПОТЕНТЕН: если test с таким title есть — не дублирует.
* Запуск: node backend/scripts/seed_ctmath_diagnostic.js (применить)
* node backend/scripts/seed_ctmath_diagnostic.js --dry (показать выбор)
*/
const db = require('../src/db/db');
const DRY = process.argv.includes('--dry');
const MATH_ID = 3;
const TITLE = 'Диагностика ЦЭ/ЦТ — Математика';
const DESC = 'Входная диагностика: задания по ключевым темам (от базовых до сложных) для определения уровня и приоритетных тем подготовки к ЦЭ/ЦТ.';
// Слоты: тема (по имени) + предпочтительный тип + уровень-зонд.
// Исключаем набор year=2025 («Экзамен 9»): берём только размеченные ЦТ-11 (year<=2024).
const SLOTS = [
['Теория чисел', 'single', 'base'],
['Арифметика и степени', 'single', 'base'],
['Квадратные уравнения', 'single', 'base'],
['Тригонометрия', 'single', 'base'],
['Числовые промежутки', 'single', 'base'],
['Словесные задачи', 'fill-blank', 'mid'],
['Прогрессии', 'fill-blank', 'mid'],
['Функции', 'fill-blank', 'mid'],
['Геометрия', 'fill-blank', 'mid'],
['Окружность и круг', 'single', 'mid'],
['Стереометрия', 'fill-blank', 'mid'],
['Логарифмы', 'fill-blank', 'hard'],
['Неравенства', 'fill-blank', 'hard'],
['Уравнения', 'fill-blank', 'hard'],
['Показательные неравенства','fill-blank', 'hard'],
];
function topicId(name) {
const r = db.prepare('SELECT id FROM topics WHERE subject_id=? AND LOWER(name)=LOWER(?)').get(MATH_ID, name);
return r && r.id;
}
function adminId() {
const u = db.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get()
|| db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
return u && u.id;
}
// Кандидаты по теме: сперва предпочт. тип, потом любой; только размеченные ЦТ-11 (year<=2024 или not null),
// исключая набор «Экзамен 9» (source_type='экзамен 9'); 2024 в приоритете, затем свежие.
function candidates(tid, type) {
const order = "ORDER BY (year=2024) DESC, year DESC, id";
const base = `SELECT id, type, year, substr(text,1,70) AS t FROM questions
WHERE subject_id=${MATH_ID} AND topic_id=${tid}
AND (source_type IS NULL OR source_type <> 'экзамен 9')`;
const pref = db.prepare(`${base} AND type=? ${order} LIMIT 8`).all(type);
const any = db.prepare(`${base} ${order} LIMIT 8`).all();
// предпочт. тип впереди, затем остальные (для фолбэка)
const seen = new Set(pref.map(r => r.id));
return [...pref, ...any.filter(r => !seen.has(r.id))];
}
const used = new Set();
const picks = [];
for (const [name, type, level] of SLOTS) {
const tid = topicId(name);
if (!tid) { console.log(` [skip] нет темы: ${name}`); continue; }
const cand = candidates(tid, type).find(r => !used.has(r.id));
if (!cand) { console.log(` [skip] нет вопросов: ${name}`); continue; }
used.add(cand.id);
picks.push({ name, level, ...cand });
}
console.log(DRY ? '[DRY-RUN] выбранные вопросы диагностики:' : '[APPLY] диагностика:');
const mark = { base: 'базовый', mid: 'средний', hard: 'сложный' };
picks.forEach((p, i) => console.log(
` ${String(i + 1).padStart(2)}. [${mark[p.level]}] ${p.name} | qid ${p.id} (${p.type}, ${p.year || '—'}) — ${p.t.replace(/\s+/g, ' ')}`
));
console.log(`\nВсего отобрано: ${picks.length} заданий.`);
const existing = db.prepare("SELECT id FROM tests WHERE subject_slug='math' AND title=?").get(TITLE);
if (existing) {
console.log(`\nТест «${TITLE}» уже существует (id ${existing.id}) — не дублирую.`);
} else if (DRY) {
console.log(`\nDRY-RUN: тест НЕ создан. Будет создан с ${picks.length} вопросами.`);
} else {
const by = adminId();
const testId = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?,?,?,?,?,?)'
).run(TITLE, 'math', DESC, 1, 40, by).lastInsertRowid;
const ins = db.prepare('INSERT INTO test_questions (test_id, question_id, order_index) VALUES (?,?,?)');
picks.forEach((p, i) => ins.run(testId, p.id, i));
console.log(`\nСоздан тест «${TITLE}» (id ${testId}, ${picks.length} вопросов, лимит 40 мин).`);
console.log('Выдать классу/ученику: assignment с test_id=' + testId + ' (mode неважен, test_id перекрывает выбор).');
}