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 };
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests: текстовые задачи тренажёра (Уровень 1) — генерация + проверка + пул.
|
||||
* - validateAndVerify: корректную принимает, неверный корень/мусор отвергает, текст экранирует.
|
||||
* - generate (LLM застаблен): валидная с 1 попытки; ретраи; провал → unverified; провайдер off.
|
||||
* - endpoints: /generate только учитель/админ (403 ученику; 503 без провайдера); /pool отдаёт пул.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||
const gen = require('../src/services/practiceGenService');
|
||||
|
||||
app.use('/api/practice', require('../src/routes/practice'));
|
||||
after(() => cleanup());
|
||||
|
||||
const GOOD = { story: 'Задумали число x: <b>3x + 4 = 19</b>. Найдите x.', lhs: '3*x + 4', rhs: '19', answer: 5, answerVar: 'x', solution: [{ note: 'Перенесём 4', tex: '3*x = 15' }] };
|
||||
|
||||
describe('practiceGenService.validateAndVerify', () => {
|
||||
it('принимает корректную задачу и экранирует текст', () => {
|
||||
const v = gen.validateAndVerify(GOOD);
|
||||
assert.equal(v.ok, true, v.reason);
|
||||
assert.equal(v.problem.answer, 5);
|
||||
assert.ok(v.problem.story.indexOf('<b>') === -1 && v.problem.story.indexOf('<b>') !== -1, 'story escaped');
|
||||
assert.equal(v.problem.solution[0].tex, '3*x = 15');
|
||||
});
|
||||
|
||||
it('отвергает неверный корень (подстановка не сходится)', () => {
|
||||
const v = gen.validateAndVerify(Object.assign({}, GOOD, { answer: 6 }));
|
||||
assert.equal(v.ok, false);
|
||||
assert.ok(/verify-failed/.test(v.reason), v.reason);
|
||||
});
|
||||
|
||||
it('отвергает невалидное выражение', () => {
|
||||
const v = gen.validateAndVerify(Object.assign({}, GOOD, { lhs: '3x +' }));
|
||||
assert.equal(v.ok, false);
|
||||
assert.equal(v.reason, 'expr-parse');
|
||||
});
|
||||
|
||||
it('отвергает без условия', () => {
|
||||
const v = gen.validateAndVerify(Object.assign({}, GOOD, { story: '' }));
|
||||
assert.equal(v.ok, false);
|
||||
assert.equal(v.reason, 'no-story');
|
||||
});
|
||||
|
||||
it('сбрасывает мусорный tex шага в пустую строку', () => {
|
||||
const v = gen.validateAndVerify(Object.assign({}, GOOD, { solution: [{ note: 'ok', tex: 'не выражение!!!' }] }));
|
||||
assert.equal(v.ok, true);
|
||||
assert.equal(v.problem.solution[0].tex, '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('practiceGenService.generate (LLM застаблен)', () => {
|
||||
const askValid = async () => ({ text: '```json\n' + JSON.stringify(GOOD) + '\n```' });
|
||||
const askWrong = async () => ({ text: JSON.stringify(Object.assign({}, GOOD, { answer: 99 })) });
|
||||
const askOff = async () => ({ text: null, error: 'off' });
|
||||
|
||||
it('валидная задача с первой попытки', async () => {
|
||||
const r = await gen.generate('word-linear', { ask: askValid, maxRetries: 3 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.attempts, 1);
|
||||
assert.equal(r.problem.answer, 5);
|
||||
});
|
||||
|
||||
it('ретраит и берёт валидную со второй попытки', async () => {
|
||||
let n = 0;
|
||||
const ask = async () => { n++; return n === 1 ? { text: 'мусор без json' } : { text: JSON.stringify(GOOD) }; };
|
||||
const r = await gen.generate('word-linear', { ask, maxRetries: 3 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.attempts, 2);
|
||||
});
|
||||
|
||||
it('неверный корень N раз → unverified (в пул не попадёт)', async () => {
|
||||
const r = await gen.generate('word-linear', { ask: askWrong, maxRetries: 3 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.error, 'unverified');
|
||||
assert.equal(r.attempts, 3);
|
||||
});
|
||||
|
||||
it('нет провайдера → off', async () => {
|
||||
const r = await gen.generate('word-linear', { ask: askOff, maxRetries: 3 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.error, 'off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/practice pool endpoints', () => {
|
||||
let teacher, student;
|
||||
before(async () => {
|
||||
teacher = (await getToken('teacher')).token;
|
||||
student = (await getToken('student')).token;
|
||||
});
|
||||
|
||||
it('POST /generate запрещён ученику (403)', async () => {
|
||||
const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, student);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('POST /generate учителю без провайдера → 503 (off)', async () => {
|
||||
const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, teacher);
|
||||
assert.equal(res.status, 503, `got ${res.status}`);
|
||||
assert.equal(res.body.error, 'off');
|
||||
});
|
||||
|
||||
it('POST /generate неизвестная тема → 400', async () => {
|
||||
const res = await inject('POST', '/api/practice/generate', { topic: 'nope' }, teacher);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('GET /pool отдаёт одобренные задачи', async () => {
|
||||
db.prepare(`INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status)
|
||||
VALUES ('word-linear','word-linear',1,'Условие','3*x + 4','19','x',5,'[]','approved')`).run();
|
||||
const res = await inject('GET', '/api/practice/pool?skill=word-linear', null, student);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.ok(Array.isArray(res.body.problems));
|
||||
const p = res.body.problems.find(x => x.skill === 'word-linear');
|
||||
assert.ok(p, 'pool problem present');
|
||||
assert.equal(p.kind, 'word');
|
||||
assert.equal(p.lhsExpr, '3*x + 4');
|
||||
assert.equal(p.answer, 5);
|
||||
});
|
||||
});
|
||||
+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;
|
||||
|
||||
@@ -1184,7 +1184,7 @@ window.LS = {
|
||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||
gameProgressList, gameProgressSubmit,
|
||||
practiceProgressList, practiceSubmit,
|
||||
practiceProgressList, practiceSubmit, practicePool, practiceGenerate,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||
@@ -1422,6 +1422,8 @@ async function gameProgressList() { return req('GET', '/game/progress')
|
||||
async function gameProgressSubmit(levelId, d) { return req('POST', '/game/progress', { level_id: levelId, time_ms: d && d.time_ms, stars: d && d.stars }); }
|
||||
async function practiceProgressList() { return req('GET', '/practice/progress'); }
|
||||
async function practiceSubmit(skill, correct) { return req('POST', '/practice/attempt', { skill, correct: !!correct }); }
|
||||
async function practicePool(skill) { return req('GET', '/practice/pool' + (skill ? ('?skill=' + encodeURIComponent(skill)) : '')); }
|
||||
async function practiceGenerate(topic) { return req('POST', '/practice/generate', { topic: topic || 'word-linear' }); }
|
||||
async function assistantContext() { return req('GET', '/assistant/context'); }
|
||||
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||
|
||||
@@ -75,9 +75,20 @@ practice.test.js 11/11 (+SR box/due).
|
||||
- **Acceptance:** сессия из N задач сама ведёт от простого к сложному; промахнутый навык
|
||||
всплывает повторно; прогресс переживает перезаход.
|
||||
|
||||
## Phase 3 — Уровень 1: LLM-задачи с верификацией
|
||||
## Phase 3 — Уровень 1: LLM-задачи с верификацией — DONE
|
||||
|
||||
**Цель:** текстовые/контекстные задачи, которых не даёт параметрика.
|
||||
**Сделано:** серверная проверка `backend/src/utils/practiceVerify.js` (грузит `SimExpr`
|
||||
в Node через require, `verifyRoot` подстановкой). Сервис `practiceGenService.js`:
|
||||
`buildMessages`→LLM→`parseProblem`→`validateAndVerify` (компиляция SimExpr + подстановка
|
||||
корня + санитизация story/шагов) с **авторетраем по фидбэку**; LLM-вызов инъектируется
|
||||
(`opts.ask`, дефолт — `assistantController.callLLMFailover`). Пул `practice_problems`
|
||||
(мигр.**083**, status approved/draft). Эндпоинты: `POST /api/practice/generate`
|
||||
(учитель/админ) + `GET /api/practice/pool` (ученикам). Клиент: `LS.practicePool/Generate`,
|
||||
тема **«Текстовые задачи»** на странице (берёт из пула; учителю — кнопка «Сгенерировать»).
|
||||
Гарантия: невалидная/неверная задача в БД НЕ пишется → ученику не попадёт.
|
||||
Тесты `practice-gen.test.js` 13/13 (verify, ретраи, off→503, 403 ученику, пул).
|
||||
|
||||
**Цель (исходная):** текстовые/контекстные задачи, которых не даёт параметрика.
|
||||
|
||||
- LLM (через провайдеров админки) генерирует `{ lhs, rhs, answer, story }`; сервер прогоняет
|
||||
`verifyRoot`; расхождение → авторетрай с фидбэком («корень не удовлетворяет, исправь»).
|
||||
|
||||
Reference in New Issue
Block a user