Files
Learn_System/backend/scripts/fix-question-bank.js
T
Maxim Dolgolyov ce4f1dcec1 fix(qbank): ручная транзакция вместо db.transaction (node:sqlite не имеет её)
DatabaseSync (node:sqlite) не имеет .transaction() как better-sqlite3 —
в контроллерах она добавлена обёрткой в db/db.js, а скрипт открывает БД
напрямую. Заменил на runTx() с ручным BEGIN/COMMIT/ROLLBACK. Применение
предложений (--apply) теперь работает; перегенерация JSON не требуется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:21:43 +03:00

147 lines
10 KiB
JavaScript

'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) {}
// node:sqlite (DatabaseSync) НЕ имеет .transaction() — оборачиваем вручную.
function runTx(fn) { db.exec('BEGIN'); try { fn(); db.exec('COMMIT'); } catch (e) { try { db.exec('ROLLBACK'); } catch (_) {} throw 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 = ?');
runTx(() => { 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');
runTx(() => { 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`);
})();