feat(trainer): P3 — текстовые задачи от LLM с серверной проверкой подстановкой

- practiceVerify.js: грузит SimExpr в Node (require), verifyRoot подстановкой корня
- practiceGenService.js: LLM (инъектируемый ask) → parse → validateAndVerify (SimExpr + подстановка + санитизация) → авторетрай по фидбэку; дефолт ask = assistantController.callLLMFailover
- пул practice_problems (мигр.083); POST /api/practice/generate (учитель/админ) + GET /api/practice/pool
- инвариант: невалидная/неверная задача в БД НЕ пишется → ученику не попадёт
- клиент: LS.practicePool/Generate, тема «Текстовые задачи» (из пула; учителю кнопка «Сгенерировать»)
- тесты practice-gen.test.js 13/13 (verify, ретраи, off→503, 403 ученику, пул); смоуки страница 26/26; план P3 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:00:39 +03:00
parent 48a73d9f8e
commit 8c4c9bf04c
9 changed files with 445 additions and 9 deletions
+73 -4
View File
@@ -93,6 +93,8 @@
}
.tr-skill:hover { border-color: #818cf8; color: #4338ca; }
.tr-skill.on { background: #eef2ff; border-color: #818cf8; color: #4338ca; }
.tr-pool-info { font-size: .82rem; color: #64748b; align-self: center; margin-right: 4px; }
#tr-gen-btn { border-style: dashed; color: #4f46e5; }
/* бейджи прогресса на чипах */
.tr-badge { display: inline-flex; margin-left: 7px; color: #16a34a; vertical-align: middle; }
@@ -254,9 +256,66 @@
if (h) el.innerHTML = h; else el.textContent = fallbackText;
}
var topics = TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }];
var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]);
var isTeacher = !!(ip && ip.isTeacher);
function skillKey(g) { return g.skill || g.id; }
function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; }
function isWord() { return curTopic === 'word'; }
function currentSkill() { return (cur && cur.kind === 'word') ? (cur.skill || 'word-linear') : skillKey(curGen); }
// ── пул текстовых задач (Уровень 1, LLM + серверная проверка) ──
var wordPool = [], wordIdx = 0, wordLoading = false;
function toWordProblem(p) {
return {
kind: 'word', skill: p.skill || 'word-linear', title: 'Текстовая задача',
display: p.story, latex: null,
lhsExpr: p.lhsExpr, rhsExpr: p.rhsExpr, answerVar: p.answerVar || 'x', answer: p.answer,
solution: (p.solution || []).map(function (st) {
var tex = st.tex || '';
return { note: st.note || '', tex: tex ? TE.prettyMath(tex) : '', latex: tex ? TE.exprToLatex(tex) : null };
})
};
}
function loadWordPool(done) {
if (!LS.practicePool) { wordPool = []; if (done) done(); return; }
wordLoading = true; renderSkills();
LS.practicePool('word-linear').then(function (r) {
wordPool = ((r && r.problems) || []).map(toWordProblem); wordIdx = 0;
}).catch(function () { wordPool = []; }).then(function () {
wordLoading = false; renderSkills(); if (done) done();
});
}
function serveWordProblem() {
var eq = $('tr-eq'); eq.classList.add('tr-eq-text');
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
if (!wordPool.length) {
cur = null;
$('tr-skill').textContent = 'Текстовые задачи';
eq.textContent = wordLoading ? 'Загрузка…' : (isTeacher ? 'Банк пуст. Нажмите «Сгенерировать задачу».' : 'Здесь появятся текстовые задачи.');
$('tr-input').disabled = true; setMode(false);
return;
}
cur = wordPool[wordIdx % wordPool.length]; wordIdx++;
$('tr-skill').textContent = cur.title;
setMath(eq, null, cur.display, true); // условие как текст
var inp = $('tr-input'); inp.value = ''; inp.disabled = false;
setMode(false); inp.focus();
}
function genWordProblem() {
var gb = $('tr-gen-btn'); if (gb) { gb.disabled = true; gb.textContent = 'Генерирую…'; }
LS.practiceGenerate('word-linear').then(function (r) {
if (r && r.ok && r.problem) {
wordPool.unshift(toWordProblem(r.problem)); wordIdx = 0;
if (LS.toast) LS.toast('Задача добавлена (проверена за ' + r.attempts + ' попыт.)', 'success');
serveWordProblem();
}
renderSkills();
}).catch(function () {
if (LS.toast) LS.toast('Не удалось сгенерировать (LLM-провайдер не настроен?)', 'error');
renderSkills();
});
}
var curTopic = topics[0] ? topics[0].key : null;
var curGen = skillsOf(curTopic)[0] || gens[0];
@@ -287,6 +346,12 @@
}).join('');
}
function renderSkills() {
if (isWord()) {
var btn = isTeacher ? '<button class="tr-skill" id="tr-gen-btn" type="button">+ Сгенерировать задачу</button>' : '';
$('tr-skills').innerHTML = '<span class="tr-pool-info">' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '</span>' + btn;
var gb = $('tr-gen-btn'); if (gb) gb.addEventListener('click', genWordProblem);
return;
}
var ss = skillsOf(curTopic);
$('tr-skills').innerHTML = ss.map(function (g, i) {
return '<button class="tr-skill' + (g === curGen ? ' on' : '') + '" type="button" data-si="' + i + '">' + esc(g.title) + skillBadge(g) + '</button>';
@@ -311,6 +376,7 @@
}
function newProblem() {
if (isWord()) { serveWordProblem(); return; }
// strict:false + несколько попыток на случай редкой неудачи с ограничениями
cur = null;
for (var i = 0; i < 6 && !cur; i++) cur = TE.instantiate(curGen, { seed: randSeed(), strict: false });
@@ -331,7 +397,7 @@
// фоновая отправка попытки на сервер (прогресс/мастерство)
function submitAttempt(correct) {
if (!LS.practiceSubmit) return;
LS.practiceSubmit(skillKey(curGen), correct).then(function (r) {
LS.practiceSubmit(currentSkill(), correct).then(function (r) {
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); }
}).catch(function () {});
}
@@ -358,7 +424,7 @@
if (g) { curGen = g; curTopic = g.topic; renderTopics(); renderSkills(); }
}
function recordAnswer(correct) {
var sk = skillKey(curGen);
var sk = currentSkill();
sessEvents.push({ skill: sk, correct: correct });
sessAnswered++;
if (TA) reviewQ = correct ? TA.onCorrect(reviewQ, sk) : TA.onWrong(reviewQ, sk, sessAnswered);
@@ -366,6 +432,7 @@
}
function advance() {
if (smart && sessAnswered >= GOAL && !summaryShown) { showSummary(); return; }
if (isWord()) { serveWordProblem(); return; } // банк — без адаптивного подбора
if (smart) pickNext();
newProblem();
}
@@ -435,11 +502,13 @@
var b = e.target.closest('.tr-chip'); if (!b) return;
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
curTopic = t.key;
renderTopics();
if (t.word) { renderSkills(); loadWordPool(function () { serveWordProblem(); }); return; }
var ss = skillsOf(curTopic);
// первый неосвоенный навык темы, иначе первый
curGen = ss[0] || curGen;
for (var i = 0; i < ss.length; i++) { var p = prog[skillKey(ss[i])]; if (!(p && p.mastered)) { curGen = ss[i]; break; } }
renderTopics(); renderSkills(); newProblem();
renderSkills(); newProblem();
});
$('tr-skills').addEventListener('click', function (e) {
var b = e.target.closest('.tr-skill'); if (!b) return;