'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":,"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`); })();