393de56c42
Безопасно через grounding: модели ДАЮТСЯ задача, правильный ответ и шаги (вычислены движком детерминированно) — ИИ только ОБЪЯСНЯЕТ, не считает. Поэтому даже слабая модель не выдаст неверную математику.
- сервис practiceExplainService.explain({problem, studentAnswer, mode, ask}): mode 'mistake' (разбор ошибки, можно назвать ответ) / 'hint' (наводящая подсказка БЕЗ ответа). Текст модели чистится от markdown и экранируется; LLM-вызов инъектируется (тесты), реальный — callLLMFailover (провайдеры Квантик-ассистента)
- POST /api/practice/explain (auth-only, ученикам); нет/выключен LLM → 503, клиент мягко падает на пошаговое решение
- клиент LS.practiceExplain; на странице кнопка «Объяснить»: после неверного ответа → разбор (с ответом ученика), иначе → подсказка; рендер в .tr-ai-box (текст экранирован сервером)
- тест practice-explain 7/7 (grounding: ответ в промпте; hint не раскрывает ответ; off→not ok; cleanText экранирует; endpoint 503/400/401)
- бэкенд practice-тесты 40/40, страница 42/42, lint:routes 0 unprotected, эмодзи 0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75 lines
4.0 KiB
JavaScript
75 lines
4.0 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Tests: ИИ-репетитор тренажёра (разбор ошибки / подсказка).
|
|
* - explain (LLM застаблен): mistake/hint дают текст; grounding (ответ в промпте); off → not ok.
|
|
* - cleanText: снимает markdown-обёртки и экранирует HTML.
|
|
* - endpoint /explain: auth-only (ученику доступен); без провайдера → 503; без задачи → 400.
|
|
*/
|
|
const { describe, it, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { app, inject, getToken, cleanup } = require('./setup');
|
|
const svc = require('../src/services/practiceExplainService');
|
|
|
|
app.use('/api/practice', require('../src/routes/practice'));
|
|
after(() => cleanup());
|
|
|
|
const PROBLEM = { display: '3x + 7 = 22', answer: 'x = 5', solution: [{ note: 'Переносим 7', tex: '3*x = 15' }, { note: 'Делим на 3', tex: 'x = 5' }] };
|
|
|
|
describe('practiceExplainService.explain (LLM застаблен)', () => {
|
|
it('mistake: возвращает текст и передаёт модели правильный ответ (grounding)', async () => {
|
|
let seen = null;
|
|
const ask = async (messages) => { seen = messages; return { text: 'Ты не поделил обе части на 3.' }; };
|
|
const r = await svc.explain({ problem: PROBLEM, studentAnswer: '15', mode: 'mistake', ask });
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.mode, 'mistake');
|
|
assert.ok(r.text.indexOf('поделил') !== -1);
|
|
const userMsg = seen.map(m => m.content).join('\n');
|
|
assert.ok(userMsg.indexOf('x = 5') !== -1, 'правильный ответ передан модели');
|
|
assert.ok(userMsg.indexOf('15') !== -1, 'ответ ученика передан модели');
|
|
});
|
|
|
|
it('hint: не просит раскрывать ответ ученика (нет строки «Ответ ученика»)', async () => {
|
|
let seen = null;
|
|
const ask = async (messages) => { seen = messages; return { text: 'Что нужно сделать со свободным членом 7?' }; };
|
|
const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask });
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.mode, 'hint');
|
|
const sys = seen[0].content;
|
|
assert.ok(sys.indexOf('НЕ называй итоговый ответ') !== -1, 'hint-режим запрещает раскрывать ответ');
|
|
});
|
|
|
|
it('off-провайдер → not ok', async () => {
|
|
const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask: async () => ({ text: null, error: 'off' }) });
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.error, 'off');
|
|
});
|
|
|
|
it('cleanText снимает markdown и экранирует HTML', () => {
|
|
const out = svc.cleanText('```\nОшибка в знаке <b>тут</b>\n```');
|
|
assert.ok(out.indexOf('```') === -1, 'нет кодовых блоков');
|
|
assert.ok(out.indexOf('<b>') === -1 && out.indexOf('<b>') !== -1, 'HTML экранирован');
|
|
});
|
|
});
|
|
|
|
describe('/api/practice/explain endpoint', () => {
|
|
let student;
|
|
before(async () => { student = (await getToken('student')).token; });
|
|
|
|
it('доступен ученику; без провайдера → 503', async () => {
|
|
const res = await inject('POST', '/api/practice/explain',
|
|
{ display: '3x + 7 = 22', answer: 'x = 5', steps: PROBLEM.solution, studentAnswer: '15', mode: 'mistake' }, student);
|
|
assert.equal(res.status, 503, `got ${res.status}`);
|
|
assert.equal(res.body.error, 'off');
|
|
});
|
|
|
|
it('без текста задачи → 400', async () => {
|
|
const res = await inject('POST', '/api/practice/explain', { display: '', mode: 'hint' }, student);
|
|
assert.equal(res.status, 400, `got ${res.status}`);
|
|
});
|
|
|
|
it('без авторизации → 401', async () => {
|
|
const res = await inject('POST', '/api/practice/explain', { display: '3x = 6', mode: 'hint' }, null);
|
|
assert.equal(res.status, 401, `got ${res.status}`);
|
|
});
|
|
});
|