feat(trainer): P13 — конструктор параметрических генераторов

- custom_generators (мигр.084, spec_json + draft/published); customGeneratorController: validateGenSpec без исполнения (лимиты/типы), CRUD own+published + ownership
- /api/practice/generators[/:id]; клиент LS.practiceGen*
- страница /trainer-builder (учитель): форма (pick/derive/lhs/rhs/display/answer/solution) + живое превью через TE.instantiate(strict) (материализация + проверка ответа подстановкой) + список своих (правка/удаление/публикация)
- тренажёр грузит свои+опубликованные генераторы в тему «Мои генераторы» (пошаговый режим работает); пункт сайдбара /trainer-builder (teacher-only)
- тесты custom-generators.test.js 12/12; смоук движка 402/402 (T17 кастомный спек + strict-валидация); страница 33/33; ROADMAP_V2 P13 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 15:30:08 +03:00
parent 47d4f71eac
commit 6d600ad576
9 changed files with 694 additions and 7 deletions
+17 -5
View File
@@ -428,8 +428,12 @@
var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]);
var isTeacher = !!(ip && ip.isTeacher);
var customGens = []; // пользовательские генераторы (P13), тема «Мои генераторы»
function skillKey(g) { return g.skill || g.id; }
function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; }
function skillsOf(topicKey) {
if (topicKey === 'custom') return customGens;
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); }
@@ -998,10 +1002,18 @@
renderTopics(); renderSkills(); updateSession(); updateOverall(); newProblem();
if (isTeacher) $('tr-analytics-btn').style.display = '';
}
(LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null))
.then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); })
.catch(function () {})
.then(boot);
Promise.all([
LS.practiceProgressList ? LS.practiceProgressList().catch(function () { return null; }) : Promise.resolve(null),
LS.practiceGenList ? LS.practiceGenList().catch(function () { return null; }) : Promise.resolve(null)
]).then(function (res) {
var pr = res[0], cgr = res[1];
if (pr && pr.progress) pr.progress.forEach(function (row) { prog[row.skill] = row; });
if (cgr && cgr.generators && cgr.generators.length) {
customGens = cgr.generators;
topics.push({ key: 'custom', label: 'Мои генераторы', custom: true });
}
boot();
}).catch(boot);
})();
</script>
</body>