Files
Learn_System/backend/scripts/import-exam9.js
T
Maxim Dolgolyov 31a51956b6 feat: exam9 — назначение варианта как ДЗ + импорт нечётных в банк
Импорт 40 нечётных вариантов (v01, v03, ..., v79) в банк вопросов:
- 400 questions с allow_html=1, source_type='экзамен 9', year=2025
- 540 options (single-choice) + correct_text (short_answer)
- 40 tests (по 1 на вариант), title="Экзамен 9 — Вариант N"
- exam9_variant_tests маппинг для назначения

Назначение варианта как ДЗ на /exam9 (для учителей/админов):
- Кнопка «Назначить как ДЗ» под заголовком варианта (только если test_id есть)
- Модалка выбора классов + опциональный deadline
- POST /api/assignments/bulk с test_id из exam9_variant_tests

Поддержка HTML/SVG в вопросах банка через флаг questions.allow_html:
- Миграция 003: ALTER TABLE questions ADD COLUMN allow_html
- sessionController: SELECT возвращают allow_html и image
- test-run.html: рендер q.text и opt.text как HTML при allow_html=1
- test-result.html: то же для explanation и opt.text
- KaTeX: добавлены $...$ и $$...$$ delimiters в обеих страницах

Бонус-фикс: bulkSchema требовал class_id (single), контроллер ждёт
class_ids (array). Существующий вызов из classes.html был сломан;
исправлено вместе.

Команда: node backend/scripts/import-exam9.js  (--all для всех 80)
2026-05-16 13:13:06 +03:00

187 lines
7.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.
#!/usr/bin/env node
/**
* import-exam9.js — imports ODD variants from /frontend/js/exam9/variants/ into question bank.
*
* Usage:
* node backend/scripts/import-exam9.js # imports v01, v03, ..., v79
* node backend/scripts/import-exam9.js --all # imports ALL 80 variants
*
* Idempotent: drops existing exam9 tests/questions before re-import (uses exam9_variant_tests map).
*
* For each variant V it creates:
* - 1 row in tests (title="Экзамен 9 — Вариант V", subject_slug='math', show_answers=1)
* - 1 row in exam9_variant_tests (V → test.id)
* - 10 rows in questions (one per task; allow_html=1, explanation=sol)
* - For tasks with opts: rows in options (5 per task, 1 marked correct)
* - For tasks without opts: questions.type='short_answer', correct_text=parsed from sol
* - 10 rows in test_questions (linking)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const db = require('../src/db/db');
const SUBJECT_ID = 3; // math (per import-content.js mapping)
const VARIANTS_DIR = path.join(__dirname, '../../frontend/js/exam9/variants');
/* ── Parse answer from sol — supports both <div class="sol-ans">…</div> patterns ─ */
function parseAnswer(sol) {
if (!sol) return null;
const m = sol.match(/<div class="sol-ans">([\s\S]*?)<\/div>/);
if (!m) return null;
// Remove "Ответ:" prefix and HTML entities/spaces
let raw = m[1].replace(/<[^>]+>/g, '').replace(/&ensp;|&nbsp;/g, ' ').trim();
raw = raw.replace(/^Ответ[:\s]*/i, '').trim();
return raw;
}
/* ── For OPTS tasks: parse the answer letter (а/б/в/г/д) ─ */
function parseAnswerLetter(sol) {
const ans = parseAnswer(sol);
if (!ans) return null;
const m = ans.match(/^([а-дa-e])\s*[\)\.]/i);
return m ? m[1].toLowerCase() : null;
}
/* ── Find or create an admin user to own the imported tests ─ */
function findCreatedBy() {
const admin = db.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get();
if (admin) return admin.id;
const sys = db.prepare("SELECT id FROM users WHERE email='system@learnspace' OR email='admin@learnspace' LIMIT 1").get();
if (sys) return sys.id;
throw new Error('No admin user found — create one first via npm run seed');
}
/* ── Idempotent cleanup ─ */
function cleanupExistingExam9() {
const oldTestIds = db.prepare('SELECT test_id FROM exam9_variant_tests').all().map(r => r.test_id);
if (!oldTestIds.length) return 0;
// Delete questions linked only to exam9 tests (avoid touching shared questions)
const placeholders = oldTestIds.map(() => '?').join(',');
const orphanQuestions = db.prepare(`
SELECT DISTINCT q.id FROM questions q
JOIN test_questions tq ON tq.question_id = q.id
WHERE tq.test_id IN (${placeholders})
AND NOT EXISTS (
SELECT 1 FROM test_questions tq2
WHERE tq2.question_id = q.id
AND tq2.test_id NOT IN (${placeholders})
)
`).all(...oldTestIds, ...oldTestIds).map(r => r.id);
db.transaction(() => {
// Tests cascade-delete test_questions and exam9_variant_tests rows
db.prepare(`DELETE FROM tests WHERE id IN (${placeholders})`).run(...oldTestIds);
// Now delete orphaned questions (their options cascade)
if (orphanQuestions.length) {
const qph = orphanQuestions.map(() => '?').join(',');
db.prepare(`DELETE FROM questions WHERE id IN (${qph})`).run(...orphanQuestions);
}
})();
return oldTestIds.length;
}
/* ── Load a variant from its .js file via Function constructor ─ */
function loadVariant(n) {
const nn = String(n).padStart(2, '0');
const src = fs.readFileSync(path.join(VARIANTS_DIR, `v${nn}.js`), 'utf8');
const scope = {};
new Function('VARIANTS', src)(scope);
return scope[n];
}
/* ── Import a single variant ─ */
function importVariant(n, createdBy) {
const v = loadVariant(n);
if (!v || !Array.isArray(v.tasks) || !v.tasks.length)
throw new Error(`Variant ${n}: malformed (no tasks)`);
const testTitle = `Экзамен 9 — Вариант ${n}`;
const desc = `Экзаменационный вариант №${n} (математика, 9 класс). ${v.tasks.length} задач с разбором.`;
return db.transaction(() => {
const { lastInsertRowid: testId } = db.prepare(
`INSERT INTO tests (title, subject_slug, description, created_by, show_answers, time_limit)
VALUES (?, 'math', ?, ?, 1, NULL)`
).run(testTitle, desc, createdBy);
db.prepare('INSERT INTO exam9_variant_tests (variant, test_id) VALUES (?, ?)').run(n, testId);
const insQ = db.prepare(
`INSERT INTO questions (subject_id, text, type, correct_text, explanation, allow_html, source_type, year)
VALUES (?, ?, ?, ?, ?, 1, 'экзамен 9', 2025)`
);
const insOpt = db.prepare(
'INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?,?,?,?)'
);
const insTQ = db.prepare(
'INSERT INTO test_questions (test_id, question_id, order_index) VALUES (?,?,?)'
);
let questionCount = 0, optsCount = 0;
v.tasks.forEach((t, i) => {
const textWithFigure = t.figure ? `${t.text}<div class="task-figure" style="margin-top:12px">${t.figure}</div>` : t.text;
const explanation = t.sol || '';
if (t.opts && t.opts.length) {
const correctLetter = parseAnswerLetter(t.sol);
const { lastInsertRowid: qid } = insQ.run(
SUBJECT_ID, textWithFigure, 'single', null, explanation
);
t.opts.forEach(([letter, optText], idx) => {
const isCorrect = correctLetter && letter.toLowerCase() === correctLetter ? 1 : 0;
insOpt.run(qid, optText, isCorrect, idx);
optsCount++;
});
insTQ.run(testId, qid, i);
questionCount++;
} else {
const correctText = parseAnswer(t.sol);
const { lastInsertRowid: qid } = insQ.run(
SUBJECT_ID, textWithFigure, 'short_answer', correctText, explanation
);
insTQ.run(testId, qid, i);
questionCount++;
}
});
return { testId, questionCount, optsCount };
})();
}
/* ── Main ─ */
function main() {
const all = process.argv.includes('--all');
const variantNumbers = all
? Array.from({ length: 80 }, (_, i) => i + 1)
: Array.from({ length: 40 }, (_, i) => i * 2 + 1); // 1, 3, 5, ..., 79
console.log(`[exam9-import] Target: ${variantNumbers.length} variants (${all ? 'ALL' : 'ODD'})`);
const createdBy = findCreatedBy();
console.log(`[exam9-import] Owner user_id: ${createdBy}`);
const cleaned = cleanupExistingExam9();
if (cleaned) console.log(`[exam9-import] Cleaned ${cleaned} existing exam9 test(s)`);
let totalQ = 0, totalOpts = 0, errors = 0;
for (const n of variantNumbers) {
try {
const { questionCount, optsCount, testId } = importVariant(n, createdBy);
console.log(` v${String(n).padStart(2, '0')} → test #${testId} · ${questionCount} q · ${optsCount} opts`);
totalQ += questionCount;
totalOpts += optsCount;
} catch (e) {
console.error(` v${String(n).padStart(2, '0')} FAILED: ${e.message}`);
errors++;
}
}
console.log(`\n[exam9-import] Done: ${variantNumbers.length - errors}/${variantNumbers.length} variants, ${totalQ} questions, ${totalOpts} options`);
if (errors) process.exit(2);
}
main();