From 40c3152fe8897bbeb06211a2d51eb6c8763f29f1 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 24 Jun 2026 14:57:24 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D1=81=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20/=20?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D0=B8-=D1=87=D0=B8=D1=82=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=20(=D1=84=D0=B8=D1=87=D0=B0=203/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - тумблер учителя «Сократический режим» (/admin#assistant): для УЧЕНИКОВ Квантик объясняет теорию полно, но конкретные задачи не решает «под ключ» — даёт метод, первый шаг и наводящий вопрос (assistant_socratic в app_settings) - авто-анти-чит: явная просьба «сделай за меня / реши моё дз / do my homework» включает сократический режим даже без тумблера (_CHEAT_RE) - учителей/админов и режимы hint/check не ограничивает; работает и в /ask, и в стриме _socraticFor(role,mode,q) + проброс socratic в buildAskMessages. Бэкенд+админ-UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 3 +- .../src/controllers/assistantController.js | 29 +++++++++++++++---- frontend/js/admin/sections/assistant.js | 2 ++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index bc9a54b..a5d25e7 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -1007,7 +1007,7 @@ function getAssistant(_req, res) { res.json({ providers, activeId, active, rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1', - memory: _aset('assistant_memory') !== '0', + memory: _aset('assistant_memory') !== '0', socratic: _aset('assistant_socratic') === '1', chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'), }); @@ -1020,6 +1020,7 @@ function saveAssistant(req, res) { if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0'); if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0'); if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0'); + if (typeof b.socratic === 'boolean') set('assistant_socratic', b.socratic ? '1' : '0'); if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} } audit(req, 'assistant.config', 'assistant', 'настройки'); res.json({ ok: true }); diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index cc1a244..93a372a 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -556,8 +556,12 @@ const META_RE = new RegExp('(' + _SELF + '[\\sа-яёa-z0-9,?!.-]{0,25}' + _TERM '|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)', 'i'); const META_ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?'; +// Анти-чит: явная просьба «сделай за меня» (а не «помоги разобраться»). +const _CHEAT_RE = /за\s+меня|вместо\s+меня|do\s+my\s+homework|(сделай|реши|выполни|напиши)\s+([а-яёА-ЯЁ]+\s+)?(дз|домашк|контрольн)/i; +function _socraticOn() { return _setting('assistant_socratic') === '1'; } + // Сборка messages+cap для модели — общая для обычного и стримингового ответа. -function buildAskMessages(q, hits, context, history, role, mode, mem) { +function buildAskMessages(q, hits, context, history, role, mode, mem, socratic) { const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)'; const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') + `Справка по платформе:\n${ref}\n\nВопрос: ${q}`; @@ -571,6 +575,12 @@ function buildAskMessages(q, hits, context, history, role, mode, mem) { sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.'; } else if (mode === 'check') { sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.'; + } else if (socratic) { + // Сократический режим (для учеников): теория — полно, но задачи не решаем «под ключ». + sys += ' СОКРАТИЧЕСКИЙ РЕЖИМ: понятия, определения и теорию объясняй полно и по существу. ' + + 'Но если просят РЕШИТЬ конкретную задачу/пример/уравнение или «сделать» задание — НЕ выдавай готовое решение и итоговый ответ. ' + + 'Вместо этого назови нужный метод/формулу, разбери первый шаг и задай наводящий вопрос, предложи ученику продолжить самому. ' + + 'Если ученик пришлёт свой шаг или ответ — проверь и мягко направь дальше. Будь доброжелателен, подбадривай.'; } const msgs = [{ role: 'system', content: sys }]; (history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); }); @@ -580,11 +590,18 @@ function buildAskMessages(q, hits, context, history, role, mode, mem) { return { msgs, cap }; } -async function askModel(q, hits, context, history, role, mode, mem) { - const { msgs, cap } = buildAskMessages(q, hits, context, history, role, mode, mem); +async function askModel(q, hits, context, history, role, mode, mem, socratic) { + const { msgs, cap } = buildAskMessages(q, hits, context, history, role, mode, mem, socratic); return callLLMFailover(msgs, cap); } +// Сократический режим включается для УЧЕНИКА: если включён тумблер ИЛИ явная просьба «сделай за меня». +function _socraticFor(role, mode, q) { + if (role && role !== 'student') return false; // учителям/админам не ограничиваем + if (mode !== 'answer') return false; // hint/check уже наводящие + return _socraticOn() || _CHEAT_RE.test(q || ''); +} + /* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─ * Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если * LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */ @@ -617,8 +634,9 @@ async function ask(req, res) { let context = pageCtx; if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text; + const socratic = _socraticFor(req.user && req.user.role, mode, q); let r = { text: null, error: 'network' }; - try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem); } catch (e) { r = { text: null, error: 'network' }; } + try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem, socratic); } catch (e) { r = { text: null, error: 'network' }; } const answer = r && r.text; if (answer) { @@ -675,7 +693,8 @@ async function askStream(req, res) { let context = pageCtx; if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text; - const { msgs, cap } = buildAskMessages(q, hits, context, history, req.user && req.user.role, mode, mem); + const socratic = _socraticFor(req.user && req.user.role, mode, q); + const { msgs, cap } = buildAskMessages(q, hits, context, history, req.user && req.user.role, mode, mem, socratic); let full = ''; let r = { text: null, error: 'network' }; diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index 4cab1e5..bd25dae 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -131,6 +131,7 @@ '' + '' + '' + + '' + '
' + (cfg.chunks || 0) + ' фрагментов
' + '
Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.
' + '
Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '
'; @@ -263,6 +264,7 @@ Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); }); Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); + Q('#asst-socratic').addEventListener('change', function () { LS.adminSaveAssistant({ socratic: Q('#asst-socratic').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); Q('#asst-reindex').addEventListener('click', async function () { var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…'; try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }