diff --git a/backend/package-lock.json b/backend/package-lock.json index fe31dfd..d0a9014 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", + "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "sharp": "^0.34.5", @@ -530,6 +531,12 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1255,6 +1262,18 @@ "node": ">=0.12.0" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 1870ec2..7d48806 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "seed": "node src/db/seed.js", "seed:permissions": "node src/db/seed-permissions.js", "lint:routes": "node scripts/check-route-auth.js", + "import:content": "node scripts/import-content.js", "test": "node --test tests/*.test.js" }, "dependencies": { @@ -18,6 +19,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", + "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "sharp": "^0.34.5", diff --git a/backend/scripts/import-content.js b/backend/scripts/import-content.js new file mode 100644 index 0000000..81b3ef8 --- /dev/null +++ b/backend/scripts/import-content.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * import-content.js — imports question collections from YAML manifests. + * + * Usage: + * npm run import:content -- ../content/phys/ct-2024.yaml + * + * YAML format: content/README.md + * + * Topic aliases (subject=phys): + * kinem=29, dynam=30, cons=31, mol=32, thermo=33, electro=34, + * dc=35, magnet=36, emf=37, optics=38, quantum=39, waves=40 + * + * For subjects without predefined aliases, or for additional topics, + * add entries to SUBJECT_TOPIC_MAP below, or use full topic name strings + * as topic keys (they will be looked up / created automatically). + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const db = require('../src/db/db'); + +/* ── Subject → topic alias → topic name (for get-or-create lookup) ────── */ +const SUBJECT_ID_MAP = { bio: 1, chem: 2, math: 3, phys: 4 }; + +const SUBJECT_TOPIC_NAMES = { + phys: { + kinem: 'Кинематика', + dynam: 'Динамика', + cons: 'Законы сохранения', + mol: 'Молекулярная физика', + thermo: 'Термодинамика', + electro: 'Электростатика', + dc: 'Постоянный ток', + magnet: 'Магнетизм', + emf: 'Электромагнитная индукция', + optics: 'Оптика', + quantum: 'Квантовая и ядерная физика', + waves: 'Колебания и волны', + }, + // Add math/bio/chem topic name maps here as collections are migrated +}; + +/* ── Look up or create topic by name (alias or full name) ─────────────── */ +function resolveTopicId(subjectId, key) { + const subjectSlug = Object.keys(SUBJECT_ID_MAP).find(s => SUBJECT_ID_MAP[s] === subjectId); + const aliasMap = SUBJECT_TOPIC_NAMES[subjectSlug] || {}; + + // Resolve alias → full topic name (or use key as-is if it's already a name) + const topicName = aliasMap[key] || key; + + const existing = db.prepare('SELECT id FROM topics WHERE subject_id=? AND LOWER(name)=LOWER(?)').get(subjectId, topicName); + if (existing) return existing.id; + + const { lastInsertRowid } = db.prepare('INSERT INTO topics (subject_id, name) VALUES (?,?)').run(subjectId, topicName); + console.log(`[import] Created new topic: "${topicName}" (id=${lastInsertRowid})`); + return Number(lastInsertRowid); +} + +/* ── Validation ──────────────────────────────────────────────────────── */ +function validate(doc, file) { + const errors = []; + + if (!doc || typeof doc !== 'object') { errors.push('document must be an object'); } + if (!doc?.meta?.subject) errors.push('meta.subject required'); + if (!doc?.meta?.year) errors.push('meta.year required'); + if (!SUBJECT_ID_MAP[doc?.meta?.subject]) errors.push(`unknown subject "${doc?.meta?.subject}" (valid: ${Object.keys(SUBJECT_ID_MAP).join(', ')})`); + if (!doc?.topics || typeof doc.topics !== 'object') errors.push('topics object required'); + + if (doc?.topics) { + for (const [topicKey, items] of Object.entries(doc.topics)) { + if (!Array.isArray(items)) { errors.push(`topics.${topicKey} must be array`); continue; } + items.forEach((q, i) => { + const loc = `topics.${topicKey}[${i}]`; + if (!q.text || typeof q.text !== 'string') errors.push(`${loc}: text required (string)`); + if (!Array.isArray(q.options)) errors.push(`${loc}: options array required`); + else { + const correctCount = q.options.filter(o => o.correct).length; + if (correctCount !== 1) errors.push(`${loc}: exactly 1 correct option required (got ${correctCount})`); + q.options.forEach((o, oi) => { + if (!o.text) errors.push(`${loc}.options[${oi}]: text required`); + }); + } + if (q.difficulty !== undefined && ![1, 2, 3].includes(Number(q.difficulty))) + errors.push(`${loc}: difficulty must be 1, 2, or 3`); + }); + } + } + + if (errors.length) { + console.error(`\n[import] FAIL: validation errors in ${path.basename(file)}:`); + errors.forEach(e => console.error(` - ${e}`)); + process.exit(2); + } +} + +/* ── Import ──────────────────────────────────────────────────────────── */ +function importFile(file) { + const raw = fs.readFileSync(file, 'utf8'); + const doc = yaml.load(raw); + validate(doc, file); + + const subjectId = SUBJECT_ID_MAP[doc.meta.subject]; + const year = doc.meta.year; + + // Dedup: skip questions whose first 80 chars already exist for this subject + const existingTexts = new Set( + db.prepare('SELECT text FROM questions WHERE subject_id=?').all(subjectId) + .map(q => q.text.slice(0, 80).trim()) + ); + + const insertQ = db.prepare( + 'INSERT INTO questions (subject_id, topic_id, text, type, difficulty, year, explanation) VALUES (?,?,?,?,?,?,?)' + ); + const insertO = db.prepare( + 'INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?,?,?,?)' + ); + + let added = 0, skipped = 0; + + db.transaction(() => { + for (const [topicKey, items] of Object.entries(doc.topics)) { + const topicId = resolveTopicId(subjectId, topicKey); + + for (const q of items) { + const text = q.text.trim(); + const key = text.slice(0, 80).trim(); + + if (existingTexts.has(key)) { skipped++; continue; } + existingTexts.add(key); + + const { lastInsertRowid } = insertQ.run( + subjectId, + topicId, + text, + q.type || 'single', + Number(q.difficulty) || 1, + year, + q.explanation || null + ); + + const qid = Number(lastInsertRowid); + q.options.forEach((o, i) => insertO.run(qid, o.text, o.correct ? 1 : 0, i)); + added++; + } + } + })(); + + const source = doc.meta.source ? ` (${doc.meta.source})` : ''; + console.log(`[import] ${path.basename(file)}${source} — added ${added}, skipped ${skipped} duplicates`); + return { added, skipped }; +} + +/* ── Entry point ─────────────────────────────────────────────────────── */ +const file = process.argv[2]; +if (!file) { + console.error('Usage: node import-content.js '); + console.error(' npm run import:content -- ../content/phys/ct-2024.yaml'); + process.exit(1); +} + +const resolved = path.resolve(file); +if (!fs.existsSync(resolved)) { + console.error(`[import] File not found: ${resolved}`); + process.exit(1); +} + +importFile(resolved); diff --git a/content/README.md b/content/README.md new file mode 100644 index 0000000..786ecba --- /dev/null +++ b/content/README.md @@ -0,0 +1,80 @@ +# Content as data + +Question collections live here as YAML, imported via a single CLI. +This replaces the ad-hoc `backend/scripts/seed_phys_ct2024.js` pattern. + +## Import command + +```sh +cd backend +npm run import:content -- ../content/phys/ct-2024.yaml +``` + +## File format + +```yaml +meta: + subject: phys # phys | math | bio | chem + year: 2024 # exam year (integer) + source: "ЦЭ,ЦТ 2024" # optional label shown in import log + +topics: + kinem: # topic alias (see aliases below) + - text: | + Question text (multi-line supported, LaTeX with \( \) works) + difficulty: 1 # 1=easy, 2=medium, 3=hard (default: 1) + explanation: "Solution explanation" # optional + options: + - { text: "Answer A", correct: true } # exactly ONE correct + - { text: "Answer B" } + - { text: "Answer C" } + + "Full topic name": # or use full Russian name — will be found or created + - text: "..." + options: [...] +``` + +## Topic aliases (subject=phys) + +| Alias | Topic name | +|----------|---------------------------------| +| kinem | Кинематика | +| dynam | Динамика | +| cons | Законы сохранения | +| mol | Молекулярная физика | +| thermo | Термодинамика | +| electro | Электростатика | +| dc | Постоянный ток | +| magnet | Магнетизм | +| emf | Электромагнитная индукция | +| optics | Оптика | +| quantum | Квантовая и ядерная физика | +| waves | Колебания и волны | + +For other topic names, use the full Russian name as the key — the importer +looks it up in the database (case-insensitive) or creates a new topic. + +## Dedup logic + +Questions are skipped if the first 80 characters of their text already +exist in the database for the same subject. This matches the legacy +`seed_phys_*.js` behavior, ensuring idempotent re-runs. + +## Migrating a legacy seed_*.js + +1. Copy the file structure from `content/phys/ct-2024.yaml` +2. Convert each `q(T.kinem, text, opts, diff, year, expl)` call to YAML: + - `T.kinem` → `topics: kinem:` + - `text` → `text: |` (use literal block for multi-line) + - `opts: [{t: "...", c: true}, ...]` → `options: [{text: "...", correct: true}, ...]` + - `diff` → `difficulty:` + - `expl` → `explanation:` +3. Run `npm run import:content -- ../content//.yaml` +4. Verify output shows expected `added` count +5. Keep the legacy `seed_*.js` file as backup until verified + +## Collections + +| File | Subject | Year | Source | Questions | +|------|---------|------|--------|-----------| +| phys/ct-2024.yaml | Физика | 2024 | ЦЭ,ЦТ 2024 | 13 (proof subset) | diff --git a/content/phys/ct-2024.yaml b/content/phys/ct-2024.yaml new file mode 100644 index 0000000..4cb0e81 --- /dev/null +++ b/content/phys/ct-2024.yaml @@ -0,0 +1,211 @@ +meta: + subject: phys + year: 2024 + source: "ЦЭ,ЦТ 2024" + +# Topic keys map to predefined topic IDs for subject=phys: +# kinem=29, dynam=30, cons=31, mol=32, thermo=33, electro=34, +# dc=35, magnet=36, emf=37, optics=38, quantum=39, waves=40 + +topics: + kinem: + - text: | + Из перечисленных физических величин ВЕКТОРНЫМИ являются: + 1) сила; 2) масса; 3) плотность; 4) объём; 5) ускорение. + (Укажите ВСЕ номера верных ответов.) + difficulty: 1 + explanation: "Сила и ускорение — векторные величины; масса, плотность, объём — скалярные." + options: + - { text: "1 и 5", correct: true } + - { text: "1 и 3" } + - { text: "2 и 4" } + - { text: "3 и 5" } + - { text: "1, 3 и 5" } + + - text: | + Из перечисленных физических величин ВЕКТОРНЫМИ являются: + 1) площадь; 2) ускорение; 3) импульс; 4) масса; 5) время. + (Укажите ВСЕ номера верных ответов.) + difficulty: 1 + explanation: "Ускорение и импульс — векторные величины; площадь, масса, время — скалярные." + options: + - { text: "2 и 3", correct: true } + - { text: "1 и 4" } + - { text: "3 и 5" } + - { text: "1 и 2" } + - { text: "4 и 5" } + + - text: | + Из перечисленных физических величин ВЕКТОРНЫМИ являются: + 1) сила; 2) плотность; 3) перемещение; 4) время; 5) объём. + (Укажите ВСЕ номера верных ответов.) + difficulty: 1 + explanation: "Сила и перемещение — векторные; плотность, время, объём — скалярные." + options: + - { text: "1 и 3", correct: true } + - { text: "2 и 5" } + - { text: "3 и 4" } + - { text: "1 и 4" } + - { text: "2 и 4" } + + - text: | + Из перечисленных физических величин ВЕКТОРНЫМИ являются: + 1) импульс; 2) скорость; 3) масса; 4) плотность; 5) работа. + (Укажите ВСЕ номера верных ответов.) + difficulty: 1 + explanation: "Импульс и скорость — векторные; масса, плотность, работа — скалярные." + options: + - { text: "1 и 2", correct: true } + - { text: "3 и 4" } + - { text: "2 и 5" } + - { text: "1 и 4" } + - { text: "3 и 5" } + + mol: + - text: | + Если \(m_0\) — масса молекулы, \(n\) — концентрация молекул идеального газа, а \(\langle v^2 \rangle\) — среднее значение квадрата скорости теплового движения молекул, то давление \(p\) газа равно: + 1) \(p=\dfrac{5}{2}m_0 n\langle v^2\rangle\); + 2) \(p=\dfrac{3}{2}m_0 n\langle v^2\rangle\); + 3) \(p=\dfrac{1}{3}m_0 n\langle v^2\rangle\); + 4) \(p=m_0 n\langle v^2\rangle\); + 5) \(p=\dfrac{2}{3}m_0 n\langle v^2\rangle\). + difficulty: 2 + explanation: "Основное уравнение МКТ: \\(p=\\frac{1}{3}m_0 n\\langle v^2\\rangle\\)." + options: + - { text: "3", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "4" } + - { text: "5" } + + - text: | + Если \(T\) — абсолютная температура идеального газа, \(k\) — постоянная Больцмана, то среднюю кинетическую энергию \(\langle E_\text{к}\rangle\) поступательного движения частиц газа можно вычислить по формуле: + 1) \(\langle E_\text{к}\rangle=kT\); + 2) \(\langle E_\text{к}\rangle=\dfrac{1}{2}kT\); + 3) \(\langle E_\text{к}\rangle=\dfrac{3}{2}kT\); + 4) \(\langle E_\text{к}\rangle=2kT\); + 5) \(\langle E_\text{к}\rangle=\dfrac{2}{3}kT\). + difficulty: 1 + explanation: "Средняя кинетическая энергия поступательного движения: \\(\\langle E_\\text{к}\\rangle=\\frac{3}{2}kT\\)." + options: + - { text: "3", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "4" } + - { text: "5" } + + - text: | + Если \(T\) — абсолютная температура идеального газа, \(k\) — постоянная Больцмана, \(n\) — концентрация частиц газа, то давление \(p\) газа можно вычислить по формуле: + 1) \(p=nkT\); + 2) \(p=\dfrac{1}{2}nkT\); + 3) \(p=\dfrac{3}{2}nkT\); + 4) \(p=\dfrac{2}{3}nkT\); + 5) \(p=2nkT\). + difficulty: 1 + explanation: "Уравнение состояния идеального газа в форме МКТ: \\(p=nkT\\)." + options: + - { text: "1", correct: true } + - { text: "2" } + - { text: "3" } + - { text: "4" } + - { text: "5" } + + emf: + - text: | + Физической величиной, измеряемой в веберах (Вб), является: + 1) сила Ампера; 2) индуктивность; 3) электрическое сопротивление; 4) магнитный поток; 5) электрическое напряжение. + difficulty: 1 + explanation: "Вебер (Вб) — единица магнитного потока \\(\\Phi\\)." + options: + - { text: "4", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "3" } + - { text: "5" } + + - text: | + Физической величиной, измеряемой в вольтах (В), является: + 1) сила Ампера; 2) сила тока; 3) ЭДС электромагнитной индукции; 4) индуктивность; 5) электрическое сопротивление. + difficulty: 1 + explanation: "ЭДС измеряется в вольтах (В)." + options: + - { text: "3", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "4" } + - { text: "5" } + + - text: | + Физической величиной, измеряемой в генри (Гн), является: + 1) электрическое сопротивление; 2) сила Ампера; 3) электрическое напряжение; 4) сила тока; 5) индуктивность. + difficulty: 1 + explanation: "Генри (Гн) — единица индуктивности \\(L\\)." + options: + - { text: "5", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "3" } + - { text: "4" } + + electro: + - text: | + Физической величиной, измеряемой в вольтах (В), является: + 1) сила Ампера; 2) сила тока; 3) электрическое сопротивление; 4) электрический заряд; 5) потенциал электростатического поля. + difficulty: 1 + explanation: "Вольт (В) — единица электрического потенциала и напряжения." + options: + - { text: "5", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "3" } + - { text: "4" } + + magnet: + - text: | + Физической величиной, измеряемой в теслах (Тл), является: + 1) сила Ампера; 2) индуктивность; 3) индукция магнитного поля; 4) электрический заряд; 5) сила тока. + difficulty: 1 + explanation: "Тесла (Тл) — единица индукции магнитного поля \\(B\\)." + options: + - { text: "3", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "4" } + - { text: "5" } + + optics: + - text: | + Если в наборе дифракционных решёток имеются решётки с числом штрихов 500; 750; 1000; 1250; 2000 на длине \(l=1\) см, то наименьший период \(d\) имеет решётка с числом штрихов: + 1) 500; 2) 750; 3) 1000; 4) 1250; 5) 2000. + difficulty: 1 + explanation: "\\(d=l/N\\). Наименьший \\(d\\) при наибольшем числе штрихов \\(N=2000\\)." + options: + - { text: "5", correct: true } + - { text: "4" } + - { text: "3" } + - { text: "2" } + - { text: "1" } + + - text: | + Если в наборе дифракционных решёток имеются решётки с числом штрихов 50; 75; 100; 150; 200 на длине \(l=1\) мм, то наибольший период \(d\) имеет решётка с числом штрихов: + 1) 50; 2) 75; 3) 100; 4) 150; 5) 200. + difficulty: 1 + explanation: "\\(d=l/N\\). Наибольший \\(d\\) при наименьшем \\(N=50\\): \\(d=1/50=0{,}02\\) мм." + options: + - { text: "1", correct: true } + - { text: "2" } + - { text: "3" } + - { text: "4" } + - { text: "5" } + + - text: | + Если предмет находится перед плоским зеркалом на расстоянии 10 см от него, то расстояние между предметом и его изображением в зеркале равно: + 1) 5 см; 2) 10 см; 3) 20 см; 4) 30 см; 5) 40 см. + difficulty: 1 + explanation: "Изображение в плоском зеркале симметрично предмету — на таком же расстоянии за зеркалом. Расстояние предмет–изображение = 2×10 = 20 см." + options: + - { text: "3", correct: true } + - { text: "1" } + - { text: "2" } + - { text: "4" } + - { text: "5" }