From 393de56c42d9905cb4004573ca29ecf09433ac8b Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 25 Jun 2026 18:23:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(trainer):=20=D0=98=D0=98-=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=B5=D1=82=D0=B8=D1=82=D0=BE=D1=80=20=E2=80=94=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B8=20=D0=BD=D0=B0=D0=B2=D0=BE=D0=B4=D1=8F=D1=89?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20(=D0=BD=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Безопасно через grounding: модели ДАЮТСЯ задача, правильный ответ и шаги (вычислены движком детерминированно) — ИИ только ОБЪЯСНЯЕТ, не считает. Поэтому даже слабая модель не выдаст неверную математику. - сервис practiceExplainService.explain({problem, studentAnswer, mode, ask}): mode 'mistake' (разбор ошибки, можно назвать ответ) / 'hint' (наводящая подсказка БЕЗ ответа). Текст модели чистится от markdown и экранируется; LLM-вызов инъектируется (тесты), реальный — callLLMFailover (провайдеры Квантик-ассистента) - POST /api/practice/explain (auth-only, ученикам); нет/выключен LLM → 503, клиент мягко падает на пошаговое решение - клиент LS.practiceExplain; на странице кнопка «Объяснить»: после неверного ответа → разбор (с ответом ученика), иначе → подсказка; рендер в .tr-ai-box (текст экранирован сервером) - тест practice-explain 7/7 (grounding: ответ в промпте; hint не раскрывает ответ; off→not ok; cleanText экранирует; endpoint 503/400/401) - бэкенд practice-тесты 40/40, страница 42/42, lint:routes 0 unprotected, эмодзи 0 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/practiceController.js | 30 ++++++- backend/src/routes/practice.js | 1 + .../src/services/practiceExplainService.js | 85 +++++++++++++++++++ backend/tests/practice-explain.test.js | 74 ++++++++++++++++ frontend/trainer.html | 37 +++++++- js/api.js | 3 +- 6 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 backend/src/services/practiceExplainService.js create mode 100644 backend/tests/practice-explain.test.js diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js index 8c81feb..8ca7ba7 100644 --- a/backend/src/controllers/practiceController.js +++ b/backend/src/controllers/practiceController.js @@ -84,6 +84,7 @@ function submitAttempt(req, res) { /* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */ const genService = require('../services/practiceGenService'); +const explainService = require('../services/practiceExplainService'); const { pushNotif } = require('../utils/notifications'); const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 }; @@ -215,4 +216,31 @@ function classStats(req, res) { res.json({ students: studentRows, skills, perSkill }); } -module.exports = { listProgress, submitAttempt, listPool, generateProblem, authorProblem, assignToClass, classStats }; +/* POST /api/practice/explain { display, answer, steps, studentAnswer, mode } — ИИ-репетитор. + * Объясняет ошибку (mode 'mistake') или даёт наводящую подсказку ('hint'), ОПИРАЯСЬ на уже + * известный правильный ответ и шаги (grounding — модель не считает). Доступ: любой + * авторизованный (тренируются ученики). Нет/выключен LLM → 503; клиент мягко падает на + * детерминированное решение. */ +async function explainProblem(req, res) { + const b = req.body || {}; + const mode = (b.mode === 'hint') ? 'hint' : 'mistake'; + const display = (typeof b.display === 'string') ? b.display : ''; + if (!display.trim()) return res.status(400).json({ error: 'no problem' }); + const answer = (typeof b.answer === 'string') ? b.answer : String(b.answer == null ? '' : b.answer); + const steps = Array.isArray(b.steps) + ? b.steps.slice(0, 8).map(s => ({ note: (s && s.note) || '', tex: (s && s.tex) || '' })) + : []; + const studentAnswer = (typeof b.studentAnswer === 'string') ? b.studentAnswer : String(b.studentAnswer == null ? '' : b.studentAnswer); + + let result; + try { result = await explainService.explain({ problem: { display, answer, solution: steps }, studentAnswer, mode }); } + catch (e) { return res.status(500).json({ error: 'explain failed' }); } + + if (!result.ok) { + const code = (result.error === 'off' || result.error === 'ask-threw') ? 503 : 422; + return res.status(code).json({ error: result.error }); + } + res.json({ ok: true, text: result.text, mode: result.mode }); +} + +module.exports = { listProgress, submitAttempt, listPool, generateProblem, authorProblem, assignToClass, classStats, explainProblem }; diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js index f00dfbc..ced3566 100644 --- a/backend/src/routes/practice.js +++ b/backend/src/routes/practice.js @@ -12,6 +12,7 @@ router.use(authMiddleware); router.get('/progress', c.listProgress); router.post('/attempt', c.submitAttempt); +router.post('/explain', c.explainProblem); // ИИ-репетитор: разбор ошибки / подсказка (ученикам) // Текстовые задачи (Уровень 1): пул читают все; генерирует/авторит учитель/админ. router.get('/pool', c.listPool); diff --git a/backend/src/services/practiceExplainService.js b/backend/src/services/practiceExplainService.js new file mode 100644 index 0000000..8844b02 --- /dev/null +++ b/backend/src/services/practiceExplainService.js @@ -0,0 +1,85 @@ +'use strict'; +/* ИИ-репетитор: объяснение ОШИБКИ и наводящие ПОДСКАЗКИ (направление A). + * + * Безопасность через grounding: модели ДАЮТСЯ задача, правильный ответ и готовые + * шаги решения (всё вычислено детерминированно движком). Модель НЕ считает — + * только ОБЪЯСНЯЕТ простым языком. Поэтому даже слабая модель не выдаст неверную + * математику: правильный ответ ей известен. Текст ответа модели обрезается и + * экранируется (рендерится как текст на клиенте). LLM-вызов инъектируется + * (opts.ask) — тесты подают фейковую модель; реальный берёт callLLMFailover лениво. + * + * mode: + * 'mistake' — объяснить, в чём ошибка ученика, и как исправить (можно назвать ответ). + * 'hint' — одна наводящая подсказка, БЕЗ раскрытия итогового ответа. + */ + +const MAX_DISPLAY = 400, MAX_ANSWER = 80, MAX_STEP = 300, MAX_STEPS = 8, MAX_OUT = 700; + +function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) : s; } +function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } + +function stepsText(steps) { + if (!Array.isArray(steps)) return ''; + return steps.slice(0, MAX_STEPS).map(function (st, i) { + st = st || {}; + var note = clip(st.note, MAX_STEP), tex = clip(st.tex, MAX_STEP); + return (i + 1) + ') ' + [note, tex].filter(Boolean).join(' '); + }).join('\n'); +} + +function buildMessages(problem, studentAnswer, mode) { + problem = problem || {}; + var display = clip(problem.display, MAX_DISPLAY); + var answer = clip(problem.answer, MAX_ANSWER); + var steps = stepsText(problem.solution || problem.steps); + + var sys = (mode === 'hint') + ? ('Ты — дружелюбный и терпеливый репетитор по математике для школьника. ' + + 'Тебе дают задачу, ПРАВИЛЬНЫЙ ответ и шаги решения. Дай ОДНУ короткую наводящую ' + + 'подсказку (наводящий вопрос или первый шаг) на русском, 1–2 предложения, простым языком. ' + + 'НЕ называй итоговый ответ и не приводи всё решение — только направь мысль. ' + + 'Опирайся на данные шаги, не выдумывай.') + : ('Ты — дружелюбный и терпеливый репетитор по математике для школьника. ' + + 'Тебе дают задачу, ПРАВИЛЬНЫЙ ответ, шаги решения и ОТВЕТ УЧЕНИКА (неверный). ' + + 'Коротко (2–4 предложения, по-русски, простым языком, без укоров) объясни, в чём именно ' + + 'ошибка ученика и как её исправить. Опирайся на данные шаги — не выдумывай математику. ' + + 'В конце можешь назвать правильный ответ.'); + + var user = 'Задача: ' + display + '\nПравильный ответ: ' + answer + + (steps ? ('\nШаги решения:\n' + steps) : ''); + if (mode !== 'hint') user += '\nОтвет ученика: ' + clip(studentAnswer, MAX_ANSWER) + '\nОбъясни ошибку.'; + else user += '\nДай подсказку, не раскрывая ответ.'; + + return [{ role: 'system', content: sys }, { role: 'user', content: user }]; +} + +/* Чистим ответ модели: убираем markdown-обёртки, обрезаем, экранируем. */ +function cleanText(text) { + var s = String(text == null ? '' : text).trim(); + s = s.replace(/```[a-z]*\n?/gi, '').replace(/```/g, '').trim(); // снять кодовые блоки + return esc(clip(s, MAX_OUT)); +} + +function _defaultAsk(messages, maxTokens) { + const { callLLMFailover } = require('../controllers/assistantController'); + return callLLMFailover(messages, maxTokens, 20000); +} + +/* Вернёт { ok, text } или { ok:false, error }. */ +async function explain(opts) { + opts = opts || {}; + var mode = (opts.mode === 'hint') ? 'hint' : 'mistake'; + var ask = opts.ask || _defaultAsk; + var messages = buildMessages(opts.problem, opts.studentAnswer, mode); + + var res; + try { res = await ask(messages, 360); } + catch (e) { return { ok: false, error: 'ask-threw' }; } + if (!res || !res.text) return { ok: false, error: (res && res.error) || 'off' }; + + var text = cleanText(res.text); + if (!text) return { ok: false, error: 'empty' }; + return { ok: true, text: text, mode: mode }; +} + +module.exports = { explain, buildMessages, cleanText }; diff --git a/backend/tests/practice-explain.test.js b/backend/tests/practice-explain.test.js new file mode 100644 index 0000000..09ebc51 --- /dev/null +++ b/backend/tests/practice-explain.test.js @@ -0,0 +1,74 @@ +'use strict'; +/** + * Tests: ИИ-репетитор тренажёра (разбор ошибки / подсказка). + * - explain (LLM застаблен): mistake/hint дают текст; grounding (ответ в промпте); off → not ok. + * - cleanText: снимает markdown-обёртки и экранирует HTML. + * - endpoint /explain: auth-only (ученику доступен); без провайдера → 503; без задачи → 400. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, inject, getToken, cleanup } = require('./setup'); +const svc = require('../src/services/practiceExplainService'); + +app.use('/api/practice', require('../src/routes/practice')); +after(() => cleanup()); + +const PROBLEM = { display: '3x + 7 = 22', answer: 'x = 5', solution: [{ note: 'Переносим 7', tex: '3*x = 15' }, { note: 'Делим на 3', tex: 'x = 5' }] }; + +describe('practiceExplainService.explain (LLM застаблен)', () => { + it('mistake: возвращает текст и передаёт модели правильный ответ (grounding)', async () => { + let seen = null; + const ask = async (messages) => { seen = messages; return { text: 'Ты не поделил обе части на 3.' }; }; + const r = await svc.explain({ problem: PROBLEM, studentAnswer: '15', mode: 'mistake', ask }); + assert.equal(r.ok, true); + assert.equal(r.mode, 'mistake'); + assert.ok(r.text.indexOf('поделил') !== -1); + const userMsg = seen.map(m => m.content).join('\n'); + assert.ok(userMsg.indexOf('x = 5') !== -1, 'правильный ответ передан модели'); + assert.ok(userMsg.indexOf('15') !== -1, 'ответ ученика передан модели'); + }); + + it('hint: не просит раскрывать ответ ученика (нет строки «Ответ ученика»)', async () => { + let seen = null; + const ask = async (messages) => { seen = messages; return { text: 'Что нужно сделать со свободным членом 7?' }; }; + const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask }); + assert.equal(r.ok, true); + assert.equal(r.mode, 'hint'); + const sys = seen[0].content; + assert.ok(sys.indexOf('НЕ называй итоговый ответ') !== -1, 'hint-режим запрещает раскрывать ответ'); + }); + + it('off-провайдер → not ok', async () => { + const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask: async () => ({ text: null, error: 'off' }) }); + assert.equal(r.ok, false); + assert.equal(r.error, 'off'); + }); + + it('cleanText снимает markdown и экранирует HTML', () => { + const out = svc.cleanText('```\nОшибка в знаке тут\n```'); + assert.ok(out.indexOf('```') === -1, 'нет кодовых блоков'); + assert.ok(out.indexOf('') === -1 && out.indexOf('<b>') !== -1, 'HTML экранирован'); + }); +}); + +describe('/api/practice/explain endpoint', () => { + let student; + before(async () => { student = (await getToken('student')).token; }); + + it('доступен ученику; без провайдера → 503', async () => { + const res = await inject('POST', '/api/practice/explain', + { display: '3x + 7 = 22', answer: 'x = 5', steps: PROBLEM.solution, studentAnswer: '15', mode: 'mistake' }, student); + assert.equal(res.status, 503, `got ${res.status}`); + assert.equal(res.body.error, 'off'); + }); + + it('без текста задачи → 400', async () => { + const res = await inject('POST', '/api/practice/explain', { display: '', mode: 'hint' }, student); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('без авторизации → 401', async () => { + const res = await inject('POST', '/api/practice/explain', { display: '3x = 6', mode: 'hint' }, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); +}); diff --git a/frontend/trainer.html b/frontend/trainer.html index af90562..bf06244 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -264,6 +264,14 @@ .tr-feedback.warn { color: var(--warn); background: #fef3c7; font-weight: 700; } .tr-actions { display: flex; flex-wrap: wrap; gap: 9px; justify-content: center; margin-top: 18px; } + /* ИИ-репетитор */ + .tr-ai-btn { color: var(--accent-ink); background: rgba(99,102,241,.1); border: 1px solid rgba(99,102,241,.28); } + .tr-ai-btn:hover { background: rgba(99,102,241,.18); } + .tr-ai-btn .ic { color: var(--g1); } + .tr-ai-box { margin-top: 18px; padding: 16px 18px; border-radius: var(--r-md); background: linear-gradient(180deg, #f6f5ff, #eef0ff); border: 1px solid rgba(99,102,241,.22); animation: trUp .3s var(--ease) both; } + .tr-ai-hd { display: flex; align-items: center; gap: 7px; font-size: .72rem; font-weight: 800; text-transform: uppercase; letter-spacing: .07em; color: var(--accent-ink); margin-bottom: 9px; } + .tr-ai-hd .ic { width: 15px; height: 15px; color: var(--g1); } + .tr-ai-body { color: #334155; font-size: .98rem; line-height: 1.6; } /* пошаговое решение / репетитор (P7) */ .tr-steps { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; } @@ -470,6 +478,10 @@ Подсказка + + @@ -650,7 +663,7 @@ var curTopic = topics[0] ? topics[0].key : null; var curGen = skillsOf(curTopic)[0] || gens[0]; var cur = null; - var solved = 0, streak = 0; + var solved = 0, streak = 0, lastWrong = false; var answered = false; // задача решена (верно/неверно/показано решение) → «Проверить» становится «Дальше» var prog = {}; // skill → строка прогресса с сервера @@ -921,6 +934,8 @@ inp.value = ''; inp.disabled = false; var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = ''; $('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = ''; + var aiBox = $('tr-ai-box'); if (aiBox) { aiBox.style.display = 'none'; aiBox.innerHTML = ''; } + lastWrong = false; var card = $('tr-card'); if (card) { card.classList.remove('tr-correct'); card.classList.remove('tr-wrong'); } var pv = $('tr-preview'); if (pv) pv.innerHTML = ''; setMode(false); @@ -1030,7 +1045,7 @@ $('tr-input').disabled = true; onSolved(); } else { - streak = 0; + streak = 0; lastWrong = true; // репетитор (C1): адресная подсказка по типовой ошибке, не выдавая ответ var diag = TE.analyzeMistake ? TE.analyzeMistake(cur, r.value) : null; var msg = diag ? diag.hint : 'Неверно. Разбери решение и реши похожую.'; @@ -1229,6 +1244,24 @@ }); $('tr-check').addEventListener('click', check); $('tr-skip').addEventListener('click', newProblem); + // ИИ-репетитор: после неверного ответа — разбор ошибки; иначе — наводящая подсказка. + // Опирается на известный ответ + шаги (движок), ИИ только ОБЪЯСНЯЕТ. Недоступен ИИ → решение. + function aiExplain() { + if (!cur || !LS.practiceExplain) return; + var box = $('tr-ai-box'); if (!box) return; + var mode = lastWrong ? 'mistake' : 'hint'; + var sparkIc = ''; + box.style.display = 'block'; + box.innerHTML = '
' + sparkIc + ' Квантик ' + (mode === 'mistake' ? 'разбирает ошибку' : 'подсказывает') + '
Думаю…
'; + var bodyEl = $('tr-ai-body'); + var steps = (cur.solution || []).map(function (s) { return { note: s.note || '', tex: s.tex || '' }; }); + var payload = { display: cur.display || answerLabel(), answer: answerLabel(), steps: steps, studentAnswer: ($('tr-input').value || ''), mode: mode }; + LS.practiceExplain(payload).then(function (r) { + if (r && r.ok && r.text) bodyEl.innerHTML = String(r.text).replace(/\n/g, '
'); // текст уже экранирован сервером + else { bodyEl.textContent = 'ИИ-репетитор сейчас недоступен — посмотри пошаговое решение ниже.'; revealSolution(); } + }).catch(function () { bodyEl.textContent = 'ИИ-репетитор недоступен — смотри решение ниже.'; revealSolution(); }); + } + $('tr-ai').addEventListener('click', aiExplain); $('tr-hint').addEventListener('click', function () { if (!cur) return; if (stepMode) { diff --git a/js/api.js b/js/api.js index 18081bc..5179bd6 100644 --- a/js/api.js +++ b/js/api.js @@ -1184,7 +1184,7 @@ window.LS = { customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete, customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink, gameProgressList, gameProgressSubmit, - practiceProgressList, practiceSubmit, practicePool, practiceGenerate, practiceClassStats, practiceAuthor, practiceAssign, + practiceProgressList, practiceSubmit, practicePool, practiceGenerate, practiceExplain, practiceClassStats, practiceAuthor, practiceAssign, practiceGenList, practiceGenGet, practiceGenCreate, practiceGenUpdate, practiceGenDelete, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, @@ -1425,6 +1425,7 @@ async function practiceProgressList() { return req('GET', '/practice/progre async function practiceSubmit(skill, correct) { return req('POST', '/practice/attempt', { skill, correct: !!correct }); } async function practicePool(skill) { return req('GET', '/practice/pool' + (skill ? ('?skill=' + encodeURIComponent(skill)) : '')); } async function practiceGenerate(topic) { return req('POST', '/practice/generate', { topic: topic || 'word-linear' }); } +async function practiceExplain(payload) { return req('POST', '/practice/explain', payload || {}); } // ИИ-репетитор async function practiceClassStats(classId) { return req('GET', '/practice/class-stats?class_id=' + encodeURIComponent(classId)); } async function practiceAuthor(data) { return req('POST', '/practice/author', data); } async function practiceAssign(classId, topic, title) { return req('POST', '/practice/assign', { class_id: classId, topic: topic || 'word-linear', title }); }