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:
Maxim Dolgolyov
2026-06-25 18:23:36 +03:00
parent b5916e7f3b
commit 393de56c42
6 changed files with 226 additions and 4 deletions
+29 -1
View File
@@ -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 };
+1
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 };