feat(assistant): генерация тестов в банк вопросов (фича 5/6)

Учитель: режим «Тест в банк» в Квантике — тема/текст превращается ИИ в вопросы
с выбором ответа, ревью в чате (варианты, верный подсвечен, пояснение),
кнопка «Сохранить в банк» (выбор предмета + тема) создаёт их через POST /questions.

Бэкенд: questionsFromText (по образцу flashcardsFromText, надёжный парс JSON
с починкой обрезанного) + роут POST /assistant/questions (requireRole
teacher/admin, fcLimiter). Клиент: LS.assistantQuestions. Виджет: режим quiz
только для учителя + makeQuiz (рендер и сохранение через createQuestion/getSubjects).

Проверено на живом шлюзе: 5 валидных вопросов, верный индекс в диапазоне.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 15:09:02 +03:00
parent bc0ed1892f
commit 78aea47619
4 changed files with 112 additions and 3 deletions
+47 -1
View File
@@ -765,4 +765,50 @@ async function flashcardsFromText(req, res) {
res.json({ title, cards });
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask, askStream, flashcardsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover };
/* ── POST /api/assistant/questions { text, count? } ── учитель: сгенерировать
* тестовые вопросы (single-choice) из темы/текста для банка вопросов. */
async function questionsFromText(req, res) {
if (!providersOrdered().length) return res.status(503).json({ error: 'LLM не настроена' });
const text = String((req.body && req.body.text) || '').trim().slice(0, 6000);
let count = Number(req.body && req.body.count);
count = Number.isFinite(count) ? Math.max(3, Math.min(10, Math.round(count))) : 5;
if (text.length < 3) return res.status(400).json({ error: 'Введите тему или текст' });
const sys = 'Ты составляешь тестовые вопросы с выбором одного верного ответа для школьников. ' +
'Если дан учебный текст/параграф — делай вопросы СТРОГО по нему; если дана короткая тема — раскрой её по школьной программе. ' +
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида ' +
'{"q":"текст вопроса","options":["вариант1","вариант2","вариант3","вариант4"],"correct":0,"explanation":"кратко, почему верен"}. ' +
'РОВНО 4 варианта; correct — индекс правильного (0..3); ровно один правильный. ' +
'По-русски, формулы в LaTeX между $...$. Никакого текста вне JSON, без markdown.';
let rr;
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 2200); }
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
const raw = rr && rr.text;
let questions = [];
if (raw) {
let s = raw.replace(/```(?:json)?/gi, '').trim();
const a = s.indexOf('[');
if (a >= 0) {
const b = s.lastIndexOf(']');
if (b > a) s = s.slice(a, b + 1);
else { const last = s.lastIndexOf('}'); s = last > a ? s.slice(a, last + 1) + ']' : ''; }
}
try {
const arr = JSON.parse(s);
if (Array.isArray(arr)) {
questions = arr
.filter(x => x && x.q && Array.isArray(x.options) && x.options.length >= 2)
.slice(0, count + 2)
.map(x => {
const opts = x.options.slice(0, 6).map(o => String(o).slice(0, 300)).filter(Boolean);
let correct = Number(x.correct); if (!Number.isInteger(correct) || correct < 0 || correct >= opts.length) correct = 0;
return { q: String(x.q).slice(0, 1000), options: opts, correct, explanation: String(x.explanation || '').slice(0, 600) };
})
.filter(x => x.options.length >= 2);
}
} catch (e) { /* не-JSON */ }
}
if (!questions.length) return res.status(502).json({ error: 'Не удалось сгенерировать вопросы' });
res.json({ questions });
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask, askStream, flashcardsFromText, questionsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover };
+1
View File
@@ -19,6 +19,7 @@ router.patch('/settings', ctrl.setSettings);
router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask);
router.post('/ask/stream', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.askStream);
router.post('/flashcards', requirePermissionForStudents('assistant.use'), fcLimiter, ctrl.flashcardsFromText);
router.post('/questions', requireRole('teacher', 'admin'), fcLimiter, ctrl.questionsFromText);
router.post('/feedback', ctrl.feedback);
router.get('/memory', ctrl.getMemory);
router.delete('/memory', ctrl.clearMemory);