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:
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 083: Пул текстовых задач тренажёра (Уровень 1, Фаза 3).
|
||||
--
|
||||
-- Кэш сгенерированных LLM и ПРОВЕРЕННЫХ задач: модель предлагает условие +
|
||||
-- уравнение (lhs/rhs) + корень, сервер подтверждает подстановкой (practiceVerify)
|
||||
-- и только тогда пишет сюда. Ученик берёт готовые задачи из пула (не платим за
|
||||
-- генерацию на каждый показ). story и заметки решения уже санитизированы.
|
||||
-- status: approved (видна ученикам) | draft (на ревью учителю).
|
||||
-- created_by ON DELETE SET NULL — задача переживает удаление автора.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS practice_problems (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic TEXT NOT NULL, -- word-linear | word-proportion | word-percent
|
||||
skill TEXT NOT NULL, -- ключ навыка (для прогресса)
|
||||
difficulty INTEGER NOT NULL DEFAULT 1,
|
||||
story TEXT NOT NULL, -- условие словами (экранировано)
|
||||
lhs TEXT NOT NULL, -- левая часть уравнения (выражение от x)
|
||||
rhs TEXT NOT NULL, -- правая часть
|
||||
answer_var TEXT NOT NULL DEFAULT 'x',
|
||||
answer REAL NOT NULL, -- проверенный корень
|
||||
solution_json TEXT, -- шаги [{note,tex}] (JSON)
|
||||
status TEXT NOT NULL DEFAULT 'approved', -- approved | draft
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_practice_problems_skill ON practice_problems (skill, status);
|
||||
@@ -5,7 +5,7 @@
|
||||
* межпользовательских роутов, проверка владения не требуется. */
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const c = require('../controllers/practiceController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
@@ -13,4 +13,8 @@ router.use(authMiddleware);
|
||||
router.get('/progress', c.listProgress);
|
||||
router.post('/attempt', c.submitAttempt);
|
||||
|
||||
// Текстовые задачи (Уровень 1): пул читают все; генерирует учитель/админ.
|
||||
router.get('/pool', c.listPool);
|
||||
router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
/* Генерация ТЕКСТОВЫХ задач (Уровень 1) через LLM с ОБЯЗАТЕЛЬНОЙ проверкой.
|
||||
*
|
||||
* LLM предлагает { story, lhs, rhs, answer, solution }; сервер компилирует
|
||||
* выражения через SimExpr и ПОДСТАВЛЯЕТ корень (practiceVerify). Не сходится —
|
||||
* авторетрай с фидбэком об ошибке; не починилось за N попыток — задача
|
||||
* отбрасывается и ученику НЕ попадает (инвариант корректности). Текст условия и
|
||||
* заметки решения экранируются; выражения идут только в SimExpr (без eval).
|
||||
*
|
||||
* LLM-вызов инъектируется (opts.ask) — тесты подают фейковую модель, реальный
|
||||
* вызов берёт провайдеров ассистента (callLLMFailover) лениво.
|
||||
*/
|
||||
const { verifyRoot, compileOk } = require('../utils/practiceVerify');
|
||||
|
||||
const MAX_STORY = 600, MAX_EXPR = 200, MAX_STEPS = 8, MAX_NOTE = 300;
|
||||
|
||||
function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) : s; }
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function sanitizeText(s, n) { return esc(clip(s, n)); }
|
||||
|
||||
const TOPIC_HINTS = {
|
||||
'word-linear': 'линейное уравнение вида a·x + b = c (одна неизвестная x); задачи на возраст, числа, покупки',
|
||||
'word-proportion': 'пропорцию a/b = c/x (задачи на части, рецепты, скорость)',
|
||||
'word-percent': 'нахождение процента от числа или числа по проценту'
|
||||
};
|
||||
|
||||
function buildMessages(topic, opts) {
|
||||
opts = opts || {};
|
||||
const hint = TOPIC_HINTS[topic] || TOPIC_HINTS['word-linear'];
|
||||
const sys =
|
||||
'Ты генератор школьных задач по математике (7 класс). Возвращай СТРОГО один JSON-объект, ' +
|
||||
'без markdown и пояснений. Формат: ' +
|
||||
'{"story":"<условие словами на русском>","lhs":"<левая часть уравнения как выражение от x>",' +
|
||||
'"rhs":"<правая часть>","answer":<целое число>,"answerVar":"x",' +
|
||||
'"solution":[{"note":"<пояснение шага словами>","tex":"<один шаг как равенство, выражение>"}]}. ' +
|
||||
'Уравнение должно соответствовать условию и иметь целый корень. В lhs/rhs/tex — ТОЛЬКО ' +
|
||||
'математические выражения (символы + - * / ( ) и x), без слов.';
|
||||
let user = 'Составь текстовую задачу на ' + hint + '. Корень — целое число. Верни только JSON.';
|
||||
if (opts.feedback) user += ' Предыдущая попытка отклонена. ' + opts.feedback + ' Верни исправленный JSON.';
|
||||
return [{ role: 'system', content: sys }, { role: 'user', content: user }];
|
||||
}
|
||||
|
||||
/* Достаём первый JSON-объект из ответа модели (терпимо к обёрткам/markdown). */
|
||||
function parseProblem(text) {
|
||||
if (!text) return null;
|
||||
const m = String(text).match(/\{[\s\S]*\}/);
|
||||
if (!m) return null;
|
||||
try { return JSON.parse(m[0]); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
/* Валидация структуры + КОРРЕКТНОСТЬ (подстановка) + санитизация. */
|
||||
function validateAndVerify(obj) {
|
||||
if (!obj || typeof obj !== 'object') return { ok: false, reason: 'no-json' };
|
||||
const story = obj.story, lhs = obj.lhs, rhs = obj.rhs;
|
||||
const answerVar = (typeof obj.answerVar === 'string' && /^[a-z]$/.test(obj.answerVar)) ? obj.answerVar : 'x';
|
||||
const answer = Number(obj.answer);
|
||||
|
||||
if (typeof story !== 'string' || !story.trim()) return { ok: false, reason: 'no-story' };
|
||||
if (typeof lhs !== 'string' || typeof rhs !== 'string') return { ok: false, reason: 'no-expr' };
|
||||
if (lhs.length > MAX_EXPR || rhs.length > MAX_EXPR) return { ok: false, reason: 'expr-too-long' };
|
||||
if (!Number.isFinite(answer)) return { ok: false, reason: 'bad-answer' };
|
||||
if (!compileOk(lhs) || !compileOk(rhs)) return { ok: false, reason: 'expr-parse' };
|
||||
|
||||
const v = verifyRoot(lhs, rhs, answerVar, answer);
|
||||
if (!v.ok) return { ok: false, reason: 'verify-failed' + (v.residual != null ? ' (residual ' + v.residual.toFixed(4) + ')' : '') };
|
||||
|
||||
let solution = [];
|
||||
if (Array.isArray(obj.solution)) {
|
||||
solution = obj.solution.slice(0, MAX_STEPS).map(function (st) {
|
||||
st = st || {};
|
||||
const out = { note: sanitizeText(st.note, MAX_NOTE) };
|
||||
if (typeof st.tex === 'string' && st.tex.length <= MAX_EXPR && compileOk(st.tex)) out.tex = clip(st.tex, MAX_EXPR);
|
||||
else out.tex = '';
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
problem: {
|
||||
story: sanitizeText(story, MAX_STORY),
|
||||
lhs: clip(lhs, MAX_EXPR), rhs: clip(rhs, MAX_EXPR),
|
||||
answerVar: answerVar, answer: answer, solution: solution
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function _defaultAsk(messages, maxTokens) {
|
||||
// лениво, чтобы не тянуть assistantController (и провайдеров) в юнит-тестах
|
||||
const { callLLMFailover } = require('../controllers/assistantController');
|
||||
return callLLMFailover(messages, maxTokens, 20000);
|
||||
}
|
||||
|
||||
/* Главная: вернёт { ok, problem, attempts } или { ok:false, error, reason, attempts }. */
|
||||
async function generate(topic, opts) {
|
||||
opts = opts || {};
|
||||
const ask = opts.ask || _defaultAsk;
|
||||
const maxRetries = Math.max(1, Math.min(opts.maxRetries || 3, 5));
|
||||
let feedback = '', lastReason = 'off';
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
let res;
|
||||
try { res = await ask(buildMessages(topic, { feedback }), 420); }
|
||||
catch (e) { return { ok: false, error: 'ask-threw', attempts: i }; }
|
||||
if (!res || !res.text) return { ok: false, error: (res && res.error) || 'off', attempts: i };
|
||||
|
||||
const obj = parseProblem(res.text);
|
||||
if (!obj) { feedback = 'Верни строго один JSON-объект без текста вокруг.'; lastReason = 'no-json'; continue; }
|
||||
|
||||
const v = validateAndVerify(obj);
|
||||
if (v.ok) return { ok: true, problem: v.problem, attempts: i + 1 };
|
||||
|
||||
lastReason = v.reason;
|
||||
feedback = 'Причина: ' + v.reason + '. Проверь, что при ' + answerVarOf(obj) + '=' + obj.answer + ' левая часть равна правой.';
|
||||
}
|
||||
return { ok: false, error: 'unverified', reason: lastReason, attempts: maxRetries };
|
||||
}
|
||||
|
||||
function answerVarOf(obj) { return (obj && typeof obj.answerVar === 'string') ? obj.answerVar : 'x'; }
|
||||
|
||||
module.exports = { generate, validateAndVerify, parseProblem, buildMessages };
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
/* Серверная проверка задач тренажёра через SimExpr — тот же безопасный
|
||||
* вычислитель, что на клиенте (⛔ без eval/new Function). Гарантирует, что любая
|
||||
* задача (от LLM или учителя) КОРРЕКТНА: подставляем заявленный корень в обе
|
||||
* части уравнения и сверяем с допуском. SimExpr — чистый (без DOM), грузится в
|
||||
* Node через require: его IIFE цепляется к globalThis.SimExpr. */
|
||||
require('../../../frontend/js/labs/_sim_expr.js'); // → globalThis.SimExpr
|
||||
const SimExpr = globalThis.SimExpr;
|
||||
|
||||
const EPS = 1e-7;
|
||||
|
||||
/* Компиляция выражения; null при синтаксической ошибке (мусор от модели). */
|
||||
function compileOk(expr) {
|
||||
if (typeof expr !== 'string') return null;
|
||||
const c = SimExpr.compile(expr);
|
||||
return (c && !c.error) ? c : null;
|
||||
}
|
||||
|
||||
/* Подстановочная проверка: lhs(var=value) ≈ rhs(var=value). */
|
||||
function verifyRoot(lhs, rhs, varName, value) {
|
||||
const cl = compileOk(lhs), cr = compileOk(rhs);
|
||||
if (!cl || !cr) return { ok: false, reason: 'parse' };
|
||||
if (typeof value !== 'number' || !isFinite(value)) return { ok: false, reason: 'bad-value' };
|
||||
const env = {}; env[varName || 'x'] = value;
|
||||
const L = cl.fn(env), R = cr.fn(env);
|
||||
const residual = Math.abs(L - R);
|
||||
const scale = Math.max(1, Math.abs(L), Math.abs(R));
|
||||
return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R };
|
||||
}
|
||||
|
||||
module.exports = { SimExpr, compileOk, verifyRoot };
|
||||
Reference in New Issue
Block a user