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
+50 -1
View File
@@ -82,4 +82,53 @@ function submitAttempt(req, res) {
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
}
module.exports = { listProgress, submitAttempt };
/* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */
const genService = require('../services/practiceGenService');
const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 };
function toClientProblem(r) {
let solution = [];
try { solution = r.solution_json ? JSON.parse(r.solution_json) : []; } catch (e) { solution = []; }
return {
id: r.id, kind: 'word', topic: r.topic, skill: r.skill,
story: r.story, lhsExpr: r.lhs, rhsExpr: r.rhs,
answerVar: r.answer_var, answer: r.answer, solution: solution
};
}
/* GET /api/practice/pool?skill=&limit= — одобренные задачи пула (ученикам). */
function listPool(req, res) {
const skill = (req.query && typeof req.query.skill === 'string') ? req.query.skill.trim().slice(0, MAX_SKILL) : '';
const limit = Math.min(parseInt((req.query && req.query.limit), 10) || 20, 50);
const rows = skill
? db.prepare("SELECT * FROM practice_problems WHERE status='approved' AND (skill = ? OR topic = ?) ORDER BY id DESC LIMIT ?").all(skill, skill, limit)
: db.prepare("SELECT * FROM practice_problems WHERE status='approved' ORDER BY id DESC LIMIT ?").all(limit);
res.json({ problems: rows.map(toClientProblem) });
}
/* POST /api/practice/generate { topic } — учитель/админ генерирует задачу в пул.
* Сервис проверяет корректность подстановкой; не прошло — в БД НЕ пишем. */
async function generateProblem(req, res) {
const topic = (req.body && typeof req.body.topic === 'string') ? req.body.topic.trim() : 'word-linear';
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
let result;
try { result = await genService.generate(topic, { maxRetries: 3 }); }
catch (e) { return res.status(500).json({ error: 'generation failed' }); }
if (!result.ok) {
const code = (result.error === 'off') ? 503 : 422; // нет провайдера → 503; не проверилось → 422
return res.status(code).json({ error: result.error, reason: result.reason || null, attempts: result.attempts });
}
const p = result.problem;
const info = db.prepare(`
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', ?)
`).run(topic, topic, 1, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
}
module.exports = { listProgress, submitAttempt, listPool, generateProblem };