feat(trainer): ИИ-репетитор — разбор ошибок и наводящие подсказки (направление A)
Безопасно через 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '<').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 };
|
||||
@@ -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Ошибка в знаке <b>тут</b>\n```');
|
||||
assert.ok(out.indexOf('```') === -1, 'нет кодовых блоков');
|
||||
assert.ok(out.indexOf('<b>') === -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}`);
|
||||
});
|
||||
});
|
||||
+35
-2
@@ -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 @@
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1h6c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2Z"/></svg>
|
||||
Подсказка
|
||||
</button>
|
||||
<button class="tr-btn tr-ai-btn" id="tr-ai" type="button" title="ИИ-репетитор объяснит">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>
|
||||
Объяснить
|
||||
</button>
|
||||
<button class="tr-btn tr-ghost" id="tr-solve" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Решение
|
||||
@@ -480,6 +492,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tr-ai-box" id="tr-ai-box" style="display:none"></div>
|
||||
<div class="tr-solution" id="tr-solution" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/></svg>';
|
||||
box.style.display = 'block';
|
||||
box.innerHTML = '<div class="tr-ai-hd">' + sparkIc + ' Квантик ' + (mode === 'mistake' ? 'разбирает ошибку' : 'подсказывает') + '</div><div class="tr-ai-body" id="tr-ai-body">Думаю…</div>';
|
||||
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, '<br>'); // текст уже экранирован сервером
|
||||
else { bodyEl.textContent = 'ИИ-репетитор сейчас недоступен — посмотри пошаговое решение ниже.'; revealSolution(); }
|
||||
}).catch(function () { bodyEl.textContent = 'ИИ-репетитор недоступен — смотри решение ниже.'; revealSolution(); });
|
||||
}
|
||||
$('tr-ai').addEventListener('click', aiExplain);
|
||||
$('tr-hint').addEventListener('click', function () {
|
||||
if (!cur) return;
|
||||
if (stepMode) {
|
||||
|
||||
@@ -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 }); }
|
||||
|
||||
Reference in New Issue
Block a user