From 9858108556e96648228120e01b8462f3a6faf233 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 24 Jun 2026 16:53:15 +0300 Subject: [PATCH] =?UTF-8?q?feat(qbank):=20=D0=B3=D0=B0=D1=80=D0=B4=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0=20+=20=D0=98=D0=98-=D0=B8=D0=BD?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=BE=D0=BD=D1=82=D0=B0=20=D0=B1=D0=B0=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 целостность банка. Аудит показал: «180 битых» — ложная тревога (177 это fill-blank с ответом в correct_text). Реально битых MCQ — 3 (single без верного варианта), без темы — 1020 (все по математике). - testController.update: нельзя опубликовать тест ученикам, если в нём есть вопрос без правильного ответа (нет верного варианта И нет correct_text); возвращает список brokenQuestions. unanswerableInTest() — переиспользуемо. - scripts/fix-question-bank.js: ИИ-ремонт через шлюз Kilo. --broken (выбрать верный вариант среди существующих), --topics (привязать матем-вопросы к существующим темам, батчами). DRY-RUN по умолчанию → предложения в JSON на вычитку → --apply применяет. --limit N для теста. Проверено: broken 3/3 (graph-вопросы корректно → ручная проверка), topics 12/12 в адекватные темы. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/fix-question-bank.js | 144 ++++++++++++++++++++++ backend/src/controllers/testController.js | 17 +++ 2 files changed, 161 insertions(+) create mode 100644 backend/scripts/fix-question-bank.js diff --git a/backend/scripts/fix-question-bank.js b/backend/scripts/fix-question-bank.js new file mode 100644 index 0000000..2afc193 --- /dev/null +++ b/backend/scripts/fix-question-bank.js @@ -0,0 +1,144 @@ +'use strict'; +/* ───────────────────────────────────────────────────────────────────────── + Ремонт банка вопросов через ИИ (шлюз Kilo из настроек ассистента). + Режимы: + --broken починить single/multiple без отмеченного верного варианта + (ИИ выбирает верный среди СУЩЕСТВУЮЩИХ вариантов) + --topics привязать математические вопросы без темы к СУЩЕСТВУЮЩИМ темам + Поток: по умолчанию DRY-RUN — пишет предложения в JSON + сводку, БД не трогает. + С флагом --apply — применяет ранее сгенерированный JSON к БД. + --limit N ограничить число вопросов (для теста) + Пример: + node fix-question-bank.js --topics # сгенерировать предложения + node fix-question-bank.js --topics --apply # применить после вычитки + ───────────────────────────────────────────────────────────────────────── */ +const { DatabaseSync } = require('node:sqlite'); +const fs = require('fs'); +const path = require('path'); + +const DB_PATH = path.resolve(__dirname, '../data/learnspace.db'); +const argv = process.argv.slice(2); +const MODE = argv.includes('--broken') ? 'broken' : argv.includes('--topics') ? 'topics' : null; +const APPLY = argv.includes('--apply'); +const LIMIT = (() => { const i = argv.indexOf('--limit'); return i >= 0 ? Number(argv[i + 1]) || 0 : 0; })(); +if (!MODE) { console.error('Укажите режим: --broken или --topics (опц. --apply, --limit N)'); process.exit(1); } +const OUT = path.join(__dirname, `_qbank_proposals_${MODE}.json`); + +const db = new DatabaseSync(DB_PATH); +try { db.exec('PRAGMA busy_timeout=8000'); } catch (e) {} + +/* ── провайдер Kilo ── */ +function aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; } +function pickProvider() { + let arr = []; try { arr = JSON.parse(aset('assistant_providers') || '[]'); } catch (e) {} + const active = arr.find(p => p.id === aset('assistant_active')); + return (active && active.key && active) || arr.find(p => p.key) || null; +} +const PROV = pickProvider(); +if (!APPLY && !PROV) { console.error('Нет провайдера ИИ с ключом (настрой в /admin#assistant).'); process.exit(1); } + +async function llm(messages, maxTokens) { + const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 40000); + try { + const r = await fetch(PROV.url, { + method: 'POST', signal: ctrl.signal, + headers: Object.assign({ 'Content-Type': 'application/json' }, PROV.key ? { Authorization: 'Bearer ' + PROV.key } : {}), + body: JSON.stringify({ model: PROV.model, temperature: 0.1, max_tokens: maxTokens || 1500, messages }), + }); + if (!r.ok) return { err: 'HTTP ' + r.status }; + const j = await r.json(); + return { text: (j.choices && j.choices[0] && j.choices[0].message && (j.choices[0].message.content || j.choices[0].message.reasoning)) || '' }; + } catch (e) { return { err: e.name === 'AbortError' ? 'timeout' : 'network' }; } + finally { clearTimeout(timer); } +} +function parseJson(raw) { + let s = String(raw || '').replace(/```(?:json)?/gi, '').trim(); + const a = s.search(/[[{]/); if (a > 0) s = s.slice(a); + const lastArr = s.lastIndexOf(']'), lastObj = s.lastIndexOf('}'); + const end = Math.max(lastArr, lastObj); if (end >= 0) s = s.slice(0, end + 1); + try { return JSON.parse(s); } catch (e) { return null; } +} +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +/* ═══ APPLY: применить сохранённые предложения ═══ */ +function applyProposals() { + if (!fs.existsSync(OUT)) { console.error('Нет файла предложений ' + OUT + ' — сначала запусти без --apply.'); process.exit(1); } + const props = JSON.parse(fs.readFileSync(OUT, 'utf8')); + let n = 0; + if (MODE === 'broken') { + const upd = db.prepare('UPDATE options SET is_correct = 1 WHERE id = ? AND question_id = ?'); + db.transaction(() => { for (const p of props) { if (p.optionId) { upd.run(p.optionId, p.id); n++; } } })(); + console.log(`Применено: отмечено верных вариантов — ${n}`); + } else { + const upd = db.prepare('UPDATE questions SET topic_id = ? WHERE id = ? AND topic_id IS NULL'); + db.transaction(() => { for (const p of props) { if (p.topicId) { upd.run(p.topicId, p.id); n++; } } })(); + console.log(`Применено: привязано тем — ${n}`); + } + process.exit(0); +} +if (APPLY) applyProposals(); + +/* ═══ DRY-RUN: сгенерировать предложения ═══ */ +(async () => { + if (MODE === 'broken') { + let qs = db.prepare(` + SELECT q.id, q.text FROM questions q + WHERE q.type IN ('single','multiple') + AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id AND o.is_correct=1) + AND EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id)`).all(); + if (LIMIT) qs = qs.slice(0, LIMIT); + console.log(`Битых MCQ к разбору: ${qs.length}`); + const props = []; + for (const q of qs) { + const opts = db.prepare('SELECT id, text FROM options WHERE question_id=? ORDER BY order_index').all(q.id); + const list = opts.map((o, i) => `${i + 1}. ${o.text}`).join('\n'); + const sys = 'Ты эксперт-предметник. Определи ЕДИНСТВЕННЫЙ правильный вариант ответа. ' + + 'Верни СТРОГО JSON {"correct": N} где N — номер варианта (1..K), либо {"correct": 0} если определить нельзя (например, нужен рисунок/график). Только JSON.'; + const user = `Вопрос: ${q.text}\n\nВарианты:\n${list}`; + const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 300); + const j = r.text ? parseJson(r.text) : null; + const n = j && Number(j.correct); + const opt = (n >= 1 && n <= opts.length) ? opts[n - 1] : null; + props.push({ id: q.id, optionId: opt ? opt.id : null, optionText: opt ? opt.text : null, q: q.text.slice(0, 70), err: r.err || null }); + console.log(` #${q.id}: ${opt ? 'верный → «' + String(opt.text).slice(0, 40) + '»' : (r.err || 'не определено (ручная проверка)')}`); + await sleep(400); + } + fs.writeFileSync(OUT, JSON.stringify(props, null, 2)); + console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --broken --apply`); + return; + } + + // topics: математика, вопросы без темы → существующие темы + const subj = db.prepare("SELECT id FROM subjects WHERE name LIKE '%атематик%' OR slug='math'").get(); + if (!subj) { console.error('Предмет «Математика» не найден'); process.exit(1); } + const topics = db.prepare('SELECT id, name FROM topics WHERE subject_id=? ORDER BY id').all(subj.id); + if (!topics.length) { console.error('У математики нет тем'); process.exit(1); } + let qs = db.prepare('SELECT id, text FROM questions WHERE subject_id=? AND topic_id IS NULL').all(subj.id); + if (LIMIT) qs = qs.slice(0, LIMIT); + console.log(`Тем: ${topics.length} | вопросов без темы: ${qs.length}`); + const topicList = topics.map((t, i) => `${i + 1}. ${t.name}`).join('\n'); + const props = []; const BATCH = 12; + for (let i = 0; i < qs.length; i += BATCH) { + const chunk = qs.slice(i, i + BATCH); + const sys = 'Ты классифицируешь вопросы по математике по СУЩЕСТВУЮЩИМ темам из списка. ' + + 'Для каждого вопроса верни номер наиболее подходящей темы или 0, если ни одна явно не подходит. ' + + 'Верни СТРОГО JSON-массив [{"id":,"t":<номер темы 1..K или 0>}]. Только JSON.'; + const user = `Темы:\n${topicList}\n\nВопросы:\n` + chunk.map(q => `[id ${q.id}] ${String(q.text).replace(/\s+/g, ' ').slice(0, 240)}`).join('\n'); + const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 900); + const arr = r.text ? parseJson(r.text) : null; + const map = {}; if (Array.isArray(arr)) arr.forEach(x => { if (x && x.id != null) map[x.id] = Number(x.t); }); + for (const q of chunk) { + const n = map[q.id]; const t = (n >= 1 && n <= topics.length) ? topics[n - 1] : null; + props.push({ id: q.id, topicId: t ? t.id : null, topicName: t ? t.name : null, q: String(q.text).replace(/\s+/g, ' ').slice(0, 70) }); + } + const done = Math.min(i + BATCH, qs.length); + console.log(` ${done}/${qs.length}${r.err ? ' (ошибка батча: ' + r.err + ')' : ''}`); + await sleep(500); + } + const assigned = props.filter(p => p.topicId).length; + fs.writeFileSync(OUT, JSON.stringify(props, null, 2)); + const byTopic = {}; props.forEach(p => { if (p.topicName) byTopic[p.topicName] = (byTopic[p.topicName] || 0) + 1; }); + console.log(`\nРазмечено: ${assigned}/${props.length} (остальные — без уверенной темы, останутся как есть)`); + Object.entries(byTopic).sort((a, b) => b[1] - a[1]).slice(0, 12).forEach(([t, n]) => console.log(` ${t}: ${n}`)); + console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --topics --apply`); +})(); diff --git a/backend/src/controllers/testController.js b/backend/src/controllers/testController.js index f969079..868b166 100644 --- a/backend/src/controllers/testController.js +++ b/backend/src/controllers/testController.js @@ -1,5 +1,17 @@ const db = require('../db/db'); +/* Вопросы теста без зафиксированного правильного ответа (нет верного варианта И + * нет correct_text). matching исключаем (там ответ — пары match_pair). + * Такой вопрос нельзя оценить → не пускаем тест к ученикам. */ +function unanswerableInTest(testId) { + return db.prepare(` + SELECT tq.question_id AS id FROM test_questions tq JOIN questions q ON q.id = tq.question_id + WHERE tq.test_id = ? AND q.type <> 'matching' + AND (q.correct_text IS NULL OR TRIM(q.correct_text) = '') + AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id = q.id AND o.is_correct = 1) + `).all(testId).map(r => r.id); +} + /* ── GET /api/tests ─────────────────────────────────────────────────────── */ function list(req, res) { const { subject } = req.query; @@ -90,6 +102,11 @@ function update(req, res) { const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers; const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit; const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students; + // Гард целостности: нельзя публиковать тест ученикам с вопросами без правильного ответа. + if (available === 1) { + const broken = unanswerableInTest(t.id); + if (broken.length) return res.status(400).json({ error: `Нельзя опубликовать: ${broken.length} вопрос(ов) без правильного ответа. Исправьте их в банке.`, brokenQuestions: broken }); + } db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?') .run(title, subject_slug, description, show_answers, time_limit, available, t.id); res.json({ ok: true });