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
+35 -2
View File
@@ -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) {