From 78aea476190ca218ea130c0743419bd9ba2eb803 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 24 Jun 2026 15:09:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=B3=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=20=D0=B1=D0=B0=D0=BD=D0=BA=20=D0=B2=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=BE=D0=B2=20(=D1=84=D0=B8=D1=87=D0=B0=205/?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Учитель: режим «Тест в банк» в Квантике — тема/текст превращается ИИ в вопросы с выбором ответа, ревью в чате (варианты, верный подсвечен, пояснение), кнопка «Сохранить в банк» (выбор предмета + тема) создаёт их через 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) --- .../src/controllers/assistantController.js | 48 +++++++++++++- backend/src/routes/assistant.js | 1 + frontend/js/assistant.js | 63 ++++++++++++++++++- js/api.js | 3 +- 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 93a372a..5652b16 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -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 }; diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js index 56d40fd..65b80d0 100644 --- a/backend/src/routes/assistant.js +++ b/backend/src/routes/assistant.js @@ -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); diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 9f4bf3f..c282d99 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -528,7 +528,7 @@ } var FB_UP = ''; var FB_DOWN = ''; - var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»' }; + var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»', quiz: 'Тема или текст — сгенерирую вопросы для банка' }; function openAsk(prefill) { var sel = _lastSel, pc = getPageContext(); var noun = pc && pc.kind === 'lesson' ? 'этот урок' : 'этот параграф'; @@ -541,10 +541,12 @@ var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS; var chips = '
' + ctxBtns + sug.map(function (q) { return ''; }).join('') + '
'; + var isTch = (_role === 'teacher' || _role === 'admin'); var modes = '
' + '' + '' + '' + + (isTch ? '' : '') + '
'; openBubble( '
' + faceSVG('happy') + 'Спроси Квантика' + @@ -637,6 +639,7 @@ q = (q || '').trim(); if (q.length < 2) return; if (mode === 'draw') return drawInChat(q, chatEl); + if (mode === 'quiz') return makeQuiz(q, chatEl); // стриминг недоступен (старый кэш api.js / нет ReadableStream) — обычный путь if (!LS.assistantAskStream || typeof ReadableStream === 'undefined') return sendNonStream(q, context, chatEl, mode); @@ -711,6 +714,7 @@ q = (q || '').trim(); if (q.length < 2) return; if (mode === 'draw') return drawInChat(q, chatEl); + if (mode === 'quiz') return makeQuiz(q, chatEl); var history = _chat.slice(-6); _chat.push({ role: 'user', content: q }); var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u); @@ -784,6 +788,63 @@ }).catch(function () { note.innerHTML = '
Не удалось сделать карточки. Попробуй позже.
'; }); } + /* ── «Тест в банк» (учитель): модель → вопросы → банк вопросов ─────────── */ + function makeQuiz(topic, chatEl) { + topic = (topic || '').trim(); + var note = msgEl('assistant'); + note.innerHTML = '
Составляю тестовые вопросы…
'; + chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight; + Promise.all([ + LS.assistantQuestions(topic, 5), + (LS.getSubjects ? LS.getSubjects() : Promise.resolve([])).catch(function () { return []; }), + ]).then(function (res) { + var qs = (res[0] && res[0].questions) || []; + var subjects = Array.isArray(res[1]) ? res[1] : ((res[1] && res[1].subjects) || []); + if (!qs.length) { note.innerHTML = '
Не получилось сгенерировать вопросы. Уточни тему и попробуй ещё.
'; return; } + note.remove(); + var wrap = msgEl('assistant'); wrap.style.maxWidth = '100%'; + var box = document.createElement('div'); box.className = 'asst-rich'; wrap.appendChild(box); + var head = document.createElement('div'); head.style.cssText = 'font-weight:800;margin-bottom:6px'; head.textContent = 'Вопросы (' + qs.length + ') — проверь и сохрани:'; box.appendChild(head); + qs.forEach(function (it, i) { + var qd = document.createElement('div'); qd.style.cssText = 'margin:8px 0;padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:10px'; + var qt = document.createElement('div'); qt.style.cssText = 'font-weight:700;margin-bottom:4px'; qt.appendChild(document.createTextNode((i + 1) + '. ')); + var qr = document.createElement('span'); qt.appendChild(qr); renderRich(qr, it.q); qd.appendChild(qt); + (it.options || []).forEach(function (op, oi) { + var li = document.createElement('div'); li.style.cssText = 'padding:2px 0 2px 14px;font-size:.84rem' + (oi === it.correct ? ';color:#059652;font-weight:700' : ''); + var os = document.createElement('span'); renderRich(os, op); li.appendChild(os); + if (oi === it.correct) { var okm = document.createElement('span'); okm.textContent = ' — верно'; okm.style.color = '#059652'; li.appendChild(okm); } + qd.appendChild(li); + }); + if (it.explanation) { var ex = document.createElement('div'); ex.style.cssText = 'margin-top:4px;font-size:.8rem;color:#8a94a6'; ex.appendChild(document.createTextNode('Пояснение: ')); var exs = document.createElement('span'); renderRich(exs, it.explanation); ex.appendChild(exs); qd.appendChild(ex); } + box.appendChild(qd); + }); + var bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px'; + var sel = document.createElement('select'); sel.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem'; + sel.innerHTML = '' + subjects.map(function (s) { return ''; }).join(''); + var topicIn = document.createElement('input'); topicIn.type = 'text'; topicIn.placeholder = 'Тема (необязательно)'; topicIn.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem;flex:1;min-width:110px'; + var saveB = document.createElement('button'); saveB.className = 'asst-chip'; saveB.type = 'button'; saveB.textContent = 'Сохранить в банк'; + var st = document.createElement('span'); st.style.cssText = 'font-size:.8rem;color:#8a94a6'; + bar.appendChild(sel); bar.appendChild(topicIn); bar.appendChild(saveB); bar.appendChild(st); box.appendChild(bar); + saveB.addEventListener('click', function () { + var slug = sel.value; if (!slug) { st.textContent = 'Выбери предмет'; return; } + saveB.disabled = true; st.textContent = 'Сохраняю…'; + var topicName = topicIn.value.trim() || (topic.length <= 60 ? topic : ''); + var done = 0; + qs.reduce(function (p, it) { + return p.then(function () { + return LS.createQuestion({ subject_slug: slug, topic_name: topicName || undefined, type: 'single', text: it.q, explanation: it.explanation || undefined, difficulty: 1, options: (it.options || []).map(function (t, i) { return { text: t, is_correct: i === it.correct }; }) }).then(function () { done++; }).catch(function () {}); + }); + }, Promise.resolve()).then(function () { + st.innerHTML = 'Сохранено ' + done + ' из ' + qs.length + '. Открыть банк вопросов'; + saveB.style.display = 'none'; sel.disabled = true; topicIn.disabled = true; + }); + }); + chatEl.appendChild(wrap); chatEl.scrollTop = chatEl.scrollHeight; + }).catch(function (e) { + note.innerHTML = '
' + ((e && e.data && e.data.error) ? esc(e.data.error) : 'Не удалось сгенерировать вопросы.') + '
'; + }); + } + /* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */ var TOUR = [ { sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' }, diff --git a/js/api.js b/js/api.js index 29be45f..15c23f4 100644 --- a/js/api.js +++ b/js/api.js @@ -1183,7 +1183,7 @@ window.LS = { customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete, customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink, gameProgressList, gameProgressSubmit, - assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, + assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, adminAssistantHealth, @@ -1459,6 +1459,7 @@ async function assistantAskStream(q, context, history, mode, cbs) { if (buf.trim()) handle(buf); } async function assistantFlashcards(text, title, count) { return req('POST', '/assistant/flashcards', { text, title, count }); } +async function assistantQuestions(text, count) { return req('POST', '/assistant/questions', { text, count }); } async function assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); } async function assistantMemory() { return req('GET', '/assistant/memory'); } async function assistantMemoryClear(id) { return req('DELETE', '/assistant/memory' + (id ? '/' + id : '')); }