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:
+73
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user