feat(qbank): гард публикации теста + ИИ-инструмент ремонта банка вопросов
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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":<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`);
|
||||
})();
|
||||
Reference in New Issue
Block a user