') === -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 }); }