feat: полный бэк и фронт (попытки, разбор, импорт, ИИ, назначения)
- Сервисы: testAttemptService, testAccess, document import/gen/extract, LLM, assignment, aiEditor - Конфиг: devAuthor, featureFlags; messages/ru; интеграция V.9 (skip без БД) - API/роуты: app, auth, server; Dockerfile и env example - Фронт: TestAttempt, TestAttemptReview, AttemptReviewBlock, стили, правки App/api/login/vite - compose и README; смоук-тесты расширены Закрывает отсутствие модулей в origin после клона. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из UI.
|
||||
*/
|
||||
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js';
|
||||
import {
|
||||
parseJsonFromLlmText,
|
||||
validateAndNormalizeDraft,
|
||||
} from './documentGenService.js';
|
||||
|
||||
/**
|
||||
* @param {unknown} s
|
||||
* @returns {{ optionsCount: number, hasMultipleAnswers: boolean }[]}
|
||||
*/
|
||||
export function parseAndValidateShape(s) {
|
||||
if (!Array.isArray(s) || s.length === 0) {
|
||||
const e = new Error('Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].');
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
if (s.length > 40) {
|
||||
const e = new Error('Не более 40 вопросов за раз.');
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
return s.map((row, i) => {
|
||||
if (!row || typeof row !== 'object') {
|
||||
const e = new Error(`shape[${i}]: ожидается объект.`);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const n = Math.floor(Number((/** @type {any} */ (row)).optionsCount));
|
||||
const hasMultipleAnswers = Boolean((/** @type {any} */ (row)).hasMultipleAnswers);
|
||||
if (!Number.isFinite(n) || n < 2 || n > 12) {
|
||||
const e = new Error(`shape[${i}]: optionsCount от 2 до 12.`);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
return { optionsCount: n, hasMultipleAnswers };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} o parsed draft
|
||||
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape
|
||||
*/
|
||||
export function assertDraftMatchesShape(o, shape) {
|
||||
if (!o?.questions || !Array.isArray(o.questions)) {
|
||||
const e = new Error('В ответе нет questions.');
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
if (o.questions.length !== shape.length) {
|
||||
const e = new Error(
|
||||
`Ожидалось вопросов: ${shape.length}, в ответе: ${o.questions.length}.`
|
||||
);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
for (let i = 0; i < shape.length; i++) {
|
||||
const q = o.questions[i];
|
||||
const sh = shape[i];
|
||||
if (!q?.options || !Array.isArray(q.options)) {
|
||||
const e = new Error(`Вопрос ${i + 1}: нет options.`);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
if (q.options.length !== sh.optionsCount) {
|
||||
const e = new Error(
|
||||
`Вопрос ${i + 1}: ожидалось вариантов ${sh.optionsCount}, в ответе: ${q.options.length}.`
|
||||
);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
if (Boolean(q.hasMultipleAnswers) !== sh.hasMultipleAnswers) {
|
||||
const e = new Error(
|
||||
`Вопрос ${i + 1}: hasMultipleAnswers должен быть ${sh.hasMultipleAnswers}.`
|
||||
);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} testTitle
|
||||
* @param {string} testDescription
|
||||
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape
|
||||
*/
|
||||
export async function generateFullTestByShape(testTitle, testDescription, shape) {
|
||||
const cfg = getLlmConfig();
|
||||
if (!cfg) {
|
||||
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.');
|
||||
/** @type {any} */ (e).status = 503;
|
||||
throw e;
|
||||
}
|
||||
const title = (testTitle || '').trim() || 'Тест';
|
||||
const desc = (testDescription || '').trim();
|
||||
const lines = shape.map(
|
||||
(sh, i) =>
|
||||
`Вопрос ${i + 1}: ровно ${sh.optionsCount} вариантов ответа; ${
|
||||
sh.hasMultipleAnswers
|
||||
? 'несколько вариантов помечены как верные (hasMultipleAnswers: true).'
|
||||
: 'ровно один верный вариант (hasMultipleAnswers: false).'
|
||||
}`
|
||||
);
|
||||
const system =
|
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {"title": string, "description": string (может быть пустой строкой), "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}.';
|
||||
const user = `Составь тест по теме.
|
||||
|
||||
Название (можно уточнить, но смысл сохранить): ${title}
|
||||
Краткое описание / контекст темы: ${desc || 'не указано; придумай согласованную тему с названием.'}
|
||||
|
||||
Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):
|
||||
${lines.join('\n')}
|
||||
|
||||
Правила: варианты — осмысленные, по теме; отметь isCorrect согласно hasMultipleAnswers; для одного правильного — ровна одна true.`;
|
||||
|
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.35);
|
||||
const parsed = parseJsonFromLlmText(raw);
|
||||
const draft = validateAndNormalizeDraft(parsed);
|
||||
assertDraftMatchesShape({ questions: draft.questions }, shape);
|
||||
return {
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
questions: draft.questions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Пустой вопрос → сгенерировать формулировки; непустой → переформулировать только текст вопроса.
|
||||
* @param {string} testTitle
|
||||
* @param {string} testDescription
|
||||
* @param {string} questionText
|
||||
* @param {number} optionsCount
|
||||
* @param {boolean} hasMultipleAnswers
|
||||
*/
|
||||
export async function generateOrRephraseQuestion(
|
||||
testTitle,
|
||||
testDescription,
|
||||
questionText,
|
||||
optionsCount,
|
||||
hasMultipleAnswers
|
||||
) {
|
||||
const cfg = getLlmConfig();
|
||||
if (!cfg) {
|
||||
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.');
|
||||
/** @type {any} */ (e).status = 503;
|
||||
throw e;
|
||||
}
|
||||
const n = Math.floor(Number(optionsCount));
|
||||
if (!Number.isFinite(n) || n < 2 || n > 12) {
|
||||
const e = new Error('optionsCount: от 2 до 12.');
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const topic = `${(testTitle || '').trim() || 'Тест'}. ${(testDescription || '').trim()}`.trim();
|
||||
const qt = (questionText || '').trim();
|
||||
|
||||
if (qt) {
|
||||
const system =
|
||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.';
|
||||
const user = `Тема теста: ${topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n${qt}`;
|
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.3);
|
||||
const parsed = parseJsonFromLlmText(raw);
|
||||
const text = String((/** @type {any} */ (parsed)).text ?? '').trim();
|
||||
if (!text) {
|
||||
const e = new Error('Пустой text в ответе модели.');
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
return { mode: 'rephrase', text };
|
||||
}
|
||||
|
||||
const system =
|
||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}. Все на русском.';
|
||||
const user = `Тема теста: ${topic}
|
||||
|
||||
Сформулируй ОДИН вопрос по этой теме с ровно ${n} вариантами ответа. hasMultipleAnswers = ${
|
||||
hasMultipleAnswers
|
||||
? 'true (несколько верных, минимум 2 isCorrect: true, остальные false).'
|
||||
: 'false (ровно один isCorrect: true).'
|
||||
}`;
|
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.35);
|
||||
const parsed = parseJsonFromLlmText(raw);
|
||||
const shape = [{ optionsCount: n, hasMultipleAnswers: Boolean(hasMultipleAnswers) }];
|
||||
assertDraftMatchesShape({ questions: [parsed] }, shape);
|
||||
const draft = validateAndNormalizeDraft({
|
||||
title: 'временно',
|
||||
questions: [parsed],
|
||||
});
|
||||
return {
|
||||
mode: 'full',
|
||||
text: draft.questions[0].text,
|
||||
hasMultipleAnswers: draft.questions[0].hasMultipleAnswers,
|
||||
options: draft.questions[0].options,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseAndValidateShape } from './aiEditorService.js';
|
||||
|
||||
test('parseAndValidateShape: валидный ввод', () => {
|
||||
const s = parseAndValidateShape([
|
||||
{ optionsCount: 3, hasMultipleAnswers: false },
|
||||
{ optionsCount: 2, hasMultipleAnswers: true },
|
||||
]);
|
||||
assert.equal(s.length, 2);
|
||||
assert.equal(s[0].optionsCount, 3);
|
||||
assert.equal(s[1].hasMultipleAnswers, true);
|
||||
});
|
||||
|
||||
test('parseAndValidateShape: пусто — ошибка', () => {
|
||||
assert.throws(
|
||||
() => parseAndValidateShape([]),
|
||||
/Передайте/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Каталог для назначения: HR (staff_members + отделы) + учётки clinic_tests по staff_id.
|
||||
* Две БД — данные сливаем в Node.
|
||||
*/
|
||||
import { getHrPool, queryHr } from '../db/hrPool.js';
|
||||
import pool from '../db/db.js';
|
||||
|
||||
/**
|
||||
* @param {{ q?: string, department?: string, clinicFilter?: 'all' | 'with' | 'without' }} p
|
||||
*/
|
||||
export async function getAssignmentDirectory(p) {
|
||||
const { rows: clinicByStaff } = await pool.query(
|
||||
`SELECT id, staff_id, login, full_name
|
||||
FROM users
|
||||
WHERE is_active = true AND staff_id IS NOT NULL`
|
||||
);
|
||||
const byStaff = new Map();
|
||||
for (const r of clinicByStaff) {
|
||||
byStaff.set(r.staff_id, { clinicUserId: r.id, login: r.login, fullName: r.full_name });
|
||||
}
|
||||
|
||||
if (!getHrPool()) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.staff_id, u.full_name AS fio, u.login AS "webLogin"
|
||||
FROM users u WHERE u.is_active = true ORDER BY u.full_name NULLS LAST, u.login`
|
||||
);
|
||||
let people = rows.map((r) => ({
|
||||
staffId: r.staff_id,
|
||||
fio: r.fio || r.webLogin,
|
||||
webLogin: r.webLogin,
|
||||
departments: '',
|
||||
clinicUserId: r.id,
|
||||
}));
|
||||
const qx = (p.q || '').trim().toLowerCase();
|
||||
if (qx) {
|
||||
people = people.filter(
|
||||
(x) =>
|
||||
(x.fio && x.fio.toLowerCase().includes(qx)) ||
|
||||
(x.webLogin && x.webLogin.toLowerCase().includes(qx)) ||
|
||||
(x.clinicUserId && x.clinicUserId.toLowerCase().includes(qx))
|
||||
);
|
||||
}
|
||||
return { people, source: 'clinic' };
|
||||
}
|
||||
|
||||
const q = (p.q || '').trim();
|
||||
const dept = (p.department || '').trim();
|
||||
const clinicFilter = p.clinicFilter || 'all';
|
||||
|
||||
const { rows: staffRows } = await queryHr(
|
||||
`SELECT sm.id AS staff_id, sm.fio, sm.web_login
|
||||
FROM staff_members sm`,
|
||||
[]
|
||||
);
|
||||
if (!staffRows.length) {
|
||||
return { people: [], source: 'hr' };
|
||||
}
|
||||
|
||||
const { rows: edRows } = await queryHr(
|
||||
`SELECT staff_id, department FROM employees_departments
|
||||
WHERE department IS NOT NULL AND trim(department) <> ''`,
|
||||
[]
|
||||
);
|
||||
const deptsByStaff = new Map();
|
||||
for (const r of edRows) {
|
||||
if (!deptsByStaff.has(r.staff_id)) {
|
||||
deptsByStaff.set(r.staff_id, new Set());
|
||||
}
|
||||
deptsByStaff.get(r.staff_id).add(r.department);
|
||||
}
|
||||
|
||||
let people = staffRows.map((r) => {
|
||||
const dset = deptsByStaff.get(r.staff_id);
|
||||
const departments = dset
|
||||
? [...dset].sort((a, b) => a.localeCompare(b, 'ru')).join(', ')
|
||||
: '';
|
||||
const cu = byStaff.get(r.staff_id) || null;
|
||||
return {
|
||||
staffId: r.staff_id,
|
||||
fio: r.fio || '—',
|
||||
webLogin: r.web_login,
|
||||
departments,
|
||||
clinicUserId: cu ? cu.clinicUserId : null,
|
||||
};
|
||||
});
|
||||
|
||||
if (q) {
|
||||
const low = q.toLowerCase();
|
||||
people = people.filter(
|
||||
(x) =>
|
||||
(x.fio && x.fio.toLowerCase().includes(low)) ||
|
||||
(x.webLogin && x.webLogin.toLowerCase().includes(low))
|
||||
);
|
||||
}
|
||||
if (dept && dept !== '__all__') {
|
||||
people = people.filter((x) => {
|
||||
const s = deptsByStaff.get(x.staffId);
|
||||
return s && s.has(dept);
|
||||
});
|
||||
}
|
||||
if (clinicFilter === 'with') {
|
||||
people = people.filter((x) => x.clinicUserId != null);
|
||||
} else if (clinicFilter === 'without') {
|
||||
people = people.filter((x) => x.clinicUserId == null);
|
||||
}
|
||||
|
||||
people.sort((a, b) => (a.fio || '').localeCompare(b.fio || '', 'ru'));
|
||||
return { people, source: 'hr' };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function getHrDepartmentNames() {
|
||||
if (!getHrPool()) {
|
||||
return [];
|
||||
}
|
||||
const { rows } = await queryHr(
|
||||
`SELECT DISTINCT TRIM(department) AS d
|
||||
FROM employees_departments
|
||||
WHERE department IS NOT NULL AND TRIM(department) <> ''
|
||||
ORDER BY 1`
|
||||
);
|
||||
return rows.map((r) => r.d).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Создать/найти запись `clinic_tests.users` по staff_id (HR), чтобы назначить target_id = uuid.
|
||||
*/
|
||||
import { queryHr, getHrPool } from '../db/hrPool.js';
|
||||
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js';
|
||||
import { RU } from '../messages/ru.js';
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {number} staffId
|
||||
* @returns {Promise<string>} uuid в clinic_tests.users
|
||||
*/
|
||||
export async function ensureClinicUserIdForStaff(pool, staffId) {
|
||||
const n = Math.floor(Number(staffId));
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
const e = new Error(RU.assignmentUserRequired);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const { rows: ex } = await pool.query(
|
||||
`SELECT id FROM users WHERE staff_id = $1 AND is_active = true LIMIT 1`,
|
||||
[n]
|
||||
);
|
||||
if (ex.length) {
|
||||
return ex[0].id;
|
||||
}
|
||||
if (!getHrPool()) {
|
||||
const e = new Error('Нет HR БД: нельзя завести учётку по staff_id.');
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const { rows: st } = await queryHr(
|
||||
`SELECT id, fio, web_login FROM staff_members WHERE id = $1`,
|
||||
[n]
|
||||
);
|
||||
if (!st.length) {
|
||||
const e = new Error('Сотрудник не найден в HR.');
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const fio = st[0].fio || `staff #${n}`;
|
||||
const rawLogin = (st[0].web_login && String(st[0].web_login).trim()) || null;
|
||||
let login = rawLogin;
|
||||
if (!login) {
|
||||
login = `staff_${n}@clinic.local`;
|
||||
}
|
||||
const { rows: taken } = await pool.query(
|
||||
`SELECT 1 FROM users WHERE LOWER(TRIM(login)) = LOWER(TRIM($1)) AND (staff_id IS NULL OR staff_id <> $2) LIMIT 1`,
|
||||
[login, n]
|
||||
);
|
||||
if (taken.length) {
|
||||
login = `staff_${n}@clinic.local`;
|
||||
}
|
||||
const ins = await pool.query(
|
||||
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
||||
VALUES ($1, $2, $3, 'employee', null, true, $4)
|
||||
ON CONFLICT (staff_id) DO UPDATE SET
|
||||
full_name = EXCLUDED.full_name,
|
||||
is_active = true
|
||||
RETURNING id`,
|
||||
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, n]
|
||||
);
|
||||
return ins.rows[0].id;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* D.2 — извлечение текста из PDF, DOCX, TXT (см. card1.md).
|
||||
*/
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import mammoth from 'mammoth';
|
||||
import { RU } from '../messages/ru.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pdfParse = require('pdf-parse');
|
||||
|
||||
/** @param {string} mime @param {string} [originalName] */
|
||||
export function resolveDocumentKind(mime, originalName = '') {
|
||||
const m = (mime || '').toLowerCase();
|
||||
const n = originalName.toLowerCase();
|
||||
if (m === 'application/pdf' || n.endsWith('.pdf')) {
|
||||
return 'pdf';
|
||||
}
|
||||
if (
|
||||
m ===
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
n.endsWith('.docx')
|
||||
) {
|
||||
return 'docx';
|
||||
}
|
||||
if (m === 'text/plain' || m === 'text/markdown' || n.endsWith('.txt') || n.endsWith('.md')) {
|
||||
return 'text';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mimetype
|
||||
* @param {string} filePath
|
||||
* @param {string} [originalName]
|
||||
* @returns {Promise<string>} извлечённый плоский текст
|
||||
*/
|
||||
export async function extractTextFromFile(mimetype, filePath, originalName) {
|
||||
const kind = resolveDocumentKind(mimetype, originalName);
|
||||
if (!kind) {
|
||||
const e = new Error(RU.unsupportedFileType);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const buf = await readFile(filePath);
|
||||
return extractTextFromBuffer(kind, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'pdf'|'docx'|'text'} kind
|
||||
* @param {Buffer} buffer
|
||||
*/
|
||||
export async function extractTextFromBuffer(kind, buffer) {
|
||||
if (kind === 'text') {
|
||||
return buffer.toString('utf8');
|
||||
}
|
||||
if (kind === 'docx') {
|
||||
const { value } = await mammoth.extractRawText({ buffer });
|
||||
return (value || '').replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
if (kind === 'pdf') {
|
||||
const data = await pdfParse(buffer);
|
||||
return ((data && data.text) || '').replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
extractTextFromBuffer,
|
||||
resolveDocumentKind,
|
||||
} from './documentExtractService.js';
|
||||
|
||||
test('resolveDocumentKind: PDF по MIME и по имени', () => {
|
||||
assert.equal(resolveDocumentKind('application/pdf'), 'pdf');
|
||||
assert.equal(resolveDocumentKind('', 'X.PDF'), 'pdf');
|
||||
assert.equal(resolveDocumentKind('application/octet-stream', 'a.pdf'), 'pdf');
|
||||
});
|
||||
|
||||
test('resolveDocumentKind: docx, txt, неизвестно', () => {
|
||||
assert.equal(
|
||||
resolveDocumentKind(
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
),
|
||||
'docx'
|
||||
);
|
||||
assert.equal(resolveDocumentKind('text/plain', 'x.txt'), 'text');
|
||||
assert.equal(resolveDocumentKind('', 'readme.md'), 'text');
|
||||
assert.equal(resolveDocumentKind('image/png'), null);
|
||||
assert.equal(resolveDocumentKind('application/octet-stream', 'a.exe'), null);
|
||||
});
|
||||
|
||||
test('extractTextFromBuffer: text UTF-8', async () => {
|
||||
const t = await extractTextFromBuffer(
|
||||
'text',
|
||||
Buffer.from('Проверка D.2', 'utf8')
|
||||
);
|
||||
assert.equal(t, 'Проверка D.2');
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* D.3 — генерация структуры теста из извлечённого текста (OpenAI-совместимый Chat Completions).
|
||||
* Ключ: DEEPSEEK_API_KEY (по умолчанию api.deepseek.com) или OPENAI_API_KEY. Опц.: LLM_BASE_URL, LLM_MODEL.
|
||||
*/
|
||||
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js';
|
||||
|
||||
const MAX_EXTRACT_CHARS = 14000;
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parseJsonFromLlmText(text) {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
const e = new Error('Пустой ответ модели.');
|
||||
e.code = 'llm_empty';
|
||||
throw e;
|
||||
}
|
||||
let t = text.trim();
|
||||
const fence = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(t);
|
||||
if (fence) {
|
||||
t = fence[1].trim();
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(t);
|
||||
} catch (err) {
|
||||
const e = new Error('Ответ модели не является корректным JSON.');
|
||||
e.code = 'llm_json_parse';
|
||||
throw e;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} o
|
||||
* @returns {{ title: string, description: string | null, questions: Array<{ text: string, hasMultipleAnswers: boolean, options: Array<{ text: string, isCorrect: boolean }> }> }}
|
||||
*/
|
||||
export function validateAndNormalizeDraft(o) {
|
||||
if (!o || typeof o !== 'object') {
|
||||
const e = new Error('JSON не содержит объекта с данными.');
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
const title = String((/** @type {any} */ (o)).title ?? '').trim();
|
||||
if (!title) {
|
||||
const e = new Error('В ответе нет поля title.');
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
const desc = (/** @type {any} */ (o)).description;
|
||||
const description =
|
||||
desc != null && String(desc).trim() ? String(desc).trim() : null;
|
||||
const rawQs = (/** @type {any} */ (o)).questions;
|
||||
if (!Array.isArray(rawQs) || rawQs.length === 0) {
|
||||
const e = new Error('В ответе нет вопросов (questions).');
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
if (rawQs.length > 40) {
|
||||
const e = new Error('Слишком много вопросов в ответе (макс. 40).');
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
const questions = rawQs.map((q, i) => {
|
||||
if (!q || typeof q !== 'object') {
|
||||
const e = new Error(`Вопрос ${i + 1}: неверный формат.`);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
const text = String((/** @type {any} */ (q)).text ?? '').trim();
|
||||
if (!text) {
|
||||
const e = new Error(`Вопрос ${i + 1}: пустой текст.`);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
const hasMultipleAnswers = Boolean(
|
||||
(/** @type {any} */ (q)).hasMultipleAnswers
|
||||
);
|
||||
const rawOpts = (/** @type {any} */ (q)).options;
|
||||
if (!Array.isArray(rawOpts) || rawOpts.length < 2) {
|
||||
const e = new Error(`Вопрос ${i + 1}: нужны минимум 2 варианта ответа.`);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
if (rawOpts.length > 12) {
|
||||
const e = new Error(`Вопрос ${i + 1}: слишком много вариантов (макс. 12).`);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
const options = rawOpts.map((op, j) => {
|
||||
if (!op || typeof op !== 'object') {
|
||||
const e = new Error(
|
||||
`Вопрос ${i + 1}, вариант ${j + 1}: неверный формат.`
|
||||
);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
text: String((/** @type {any} */ (op)).text ?? '').trim() || `Вариант ${j + 1}`,
|
||||
isCorrect: Boolean((/** @type {any} */ (op)).isCorrect),
|
||||
};
|
||||
});
|
||||
const correctN = options.filter((x) => x.isCorrect).length;
|
||||
if (correctN === 0) {
|
||||
const e = new Error(
|
||||
`Вопрос ${i + 1}: отметьте минимум один правильный вариант.`
|
||||
);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
if (!hasMultipleAnswers && correctN > 1) {
|
||||
const e = new Error(
|
||||
`Вопрос ${i + 1}: с одним правильным ответом должен быть один вариант isCorrect, либо укажите hasMultipleAnswers: true.`
|
||||
);
|
||||
e.code = 'llm_shape';
|
||||
throw e;
|
||||
}
|
||||
return { text, hasMultipleAnswers, options };
|
||||
});
|
||||
return { title, description, questions };
|
||||
}
|
||||
|
||||
/**
|
||||
* D.1/D.2/D.3 — ответ для POST /import/document (клиент не получает сырые ключи).
|
||||
* @param {string} extractedText
|
||||
*/
|
||||
export async function generationForImportDocument(extractedText) {
|
||||
const text = (extractedText || '').trim();
|
||||
if (!text) {
|
||||
return {
|
||||
available: false,
|
||||
message: 'Нет извлечённого текста — нечего передавать в модель.',
|
||||
};
|
||||
}
|
||||
const cfg = getLlmConfig();
|
||||
if (!cfg) {
|
||||
return {
|
||||
available: false,
|
||||
message:
|
||||
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY (см. backend/.env.example). Ниже — превью текста; можно вставить в черновик вручную.',
|
||||
textPreview: text.slice(0, 4000),
|
||||
};
|
||||
}
|
||||
const slice =
|
||||
text.length > MAX_EXTRACT_CHARS
|
||||
? `${text.slice(0, MAX_EXTRACT_CHARS)}\n\n[…фрагмент обрезан для API]`
|
||||
: text;
|
||||
try {
|
||||
const system =
|
||||
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {"title": string, "description"?: string, "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.';
|
||||
const user =
|
||||
'Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n' + slice;
|
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.25);
|
||||
const parsed = parseJsonFromLlmText(raw);
|
||||
const draft = validateAndNormalizeDraft(parsed);
|
||||
return {
|
||||
available: true,
|
||||
message: `Сгенерировано: «${draft.title}», вопросов: ${draft.questions.length}. Нажмите «Применить сгенерированный черновик» ниже.`,
|
||||
draft: {
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
questions: draft.questions,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const code = e instanceof Error && 'code' in e ? (/** @type {any} */ (e)).code : 'llm_error';
|
||||
return {
|
||||
available: false,
|
||||
message: `Генерация не удалась: ${msg}`,
|
||||
errorCode: code,
|
||||
textPreview: text.slice(0, 4000),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseJsonFromLlmText,
|
||||
validateAndNormalizeDraft,
|
||||
} from './documentGenService.js';
|
||||
|
||||
test('parseJsonFromLlmText: чистый JSON', () => {
|
||||
const o = parseJsonFromLlmText('{"title":"T","questions":[{"text":"Q","options":[{"text":"a","isCorrect":true},{"text":"b","isCorrect":false}]}]}');
|
||||
assert.equal(o.title, 'T');
|
||||
assert.equal(o.questions.length, 1);
|
||||
});
|
||||
|
||||
test('parseJsonFromLlmText: JSON в markdown-заборе', () => {
|
||||
const raw = '```json\n{"title":"X","questions":[{"text":"1","options":[{"text":"+","isCorrect":true},{"text":"-","isCorrect":false}]}]}\n```';
|
||||
const o = parseJsonFromLlmText(raw);
|
||||
assert.equal(o.title, 'X');
|
||||
});
|
||||
|
||||
test('parseJsonFromLlmText: невалидный JSON — ошибка', () => {
|
||||
assert.throws(
|
||||
() => parseJsonFromLlmText('not json'),
|
||||
/JSON/i
|
||||
);
|
||||
});
|
||||
|
||||
test('validateAndNormalizeDraft: валидный черновик', () => {
|
||||
const d = validateAndNormalizeDraft({
|
||||
title: ' Экзамен ',
|
||||
description: ' оп ',
|
||||
questions: [
|
||||
{
|
||||
text: '2+2?',
|
||||
hasMultipleAnswers: false,
|
||||
options: [
|
||||
{ text: '4', isCorrect: true },
|
||||
{ text: '5', isCorrect: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(d.title, 'Экзамен');
|
||||
assert.equal(d.description, 'оп');
|
||||
assert.equal(d.questions[0].options.length, 2);
|
||||
});
|
||||
|
||||
test('validateAndNormalizeDraft: нет title', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateAndNormalizeDraft({
|
||||
questions: [
|
||||
{
|
||||
text: 'Q',
|
||||
options: [
|
||||
{ text: 'a', isCorrect: true },
|
||||
{ text: 'b', isCorrect: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
/title/i
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* OpenAI-совместимый Chat Completions. Общий для импорта и редактора.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @returns {null | { provider: string, apiKey: string, baseUrl: string, model: string }}
|
||||
*/
|
||||
export function getLlmConfig() {
|
||||
if (process.env.DEEPSEEK_API_KEY) {
|
||||
return {
|
||||
provider: 'deepseek',
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseUrl: (process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1').replace(
|
||||
/\/+$/,
|
||||
''
|
||||
),
|
||||
model: process.env.LLM_MODEL || 'deepseek-chat',
|
||||
};
|
||||
}
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return {
|
||||
provider: 'openai',
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseUrl: (process.env.LLM_BASE_URL || 'https://api.openai.com/v1').replace(
|
||||
/\/+$/,
|
||||
''
|
||||
),
|
||||
model: process.env.LLM_MODEL || 'gpt-4o-mini',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ baseUrl: string, apiKey: string, model: string }} cfg
|
||||
* @param {string} system
|
||||
* @param {string} user
|
||||
* @param {number} [temperature]
|
||||
* @returns {Promise<string>} raw assistant message
|
||||
*/
|
||||
export async function chatCompletionTextContent(cfg, system, user, temperature = 0.25) {
|
||||
const url = `${cfg.baseUrl}/chat/completions`;
|
||||
const body = {
|
||||
model: cfg.model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
temperature,
|
||||
};
|
||||
if (process.env.LLM_NO_JSON !== '1') {
|
||||
body.response_format = { type: 'json_object' };
|
||||
}
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), 120000);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${cfg.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: ac.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
const err = new Error('Превышен таймаут ожидания ответа LLM (120 с).');
|
||||
err.code = 'llm_timeout';
|
||||
throw err;
|
||||
}
|
||||
const err = new Error(
|
||||
e instanceof Error ? e.message : 'Сбой сети при обращении к LLM'
|
||||
);
|
||||
err.code = 'llm_network';
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
const err = new Error(
|
||||
`LLM ${res.status}: ${errText.replace(/\s+/g, ' ').slice(0, 280)}`
|
||||
);
|
||||
err.code = 'llm_http';
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const data = await res.json();
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
const e = new Error('Пустой content в ответе API.');
|
||||
e.code = 'llm_empty';
|
||||
throw e;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Прохождение теста: контент для игры, проверка ответов, завершение попытки.
|
||||
*/
|
||||
import { RU } from '../messages/ru.js';
|
||||
import { isTestAuthor } from '../config/devAuthor.js';
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool|import('pg').PoolClient} db
|
||||
* @param {string} testVersionId
|
||||
* @param {{ includeCorrect: boolean }} opts
|
||||
*/
|
||||
export async function loadQuestionsForVersion(db, testVersionId, opts) {
|
||||
const { rows: qrows } = await db.query(
|
||||
`SELECT id, text, question_order, has_multiple_answers
|
||||
FROM questions
|
||||
WHERE test_version_id = $1
|
||||
ORDER BY question_order`,
|
||||
[testVersionId]
|
||||
);
|
||||
const out = [];
|
||||
for (const row of qrows) {
|
||||
const { rows: orows } = await db.query(
|
||||
`SELECT id, text, is_correct, option_order
|
||||
FROM answer_options
|
||||
WHERE question_id = $1
|
||||
ORDER BY option_order`,
|
||||
[row.id]
|
||||
);
|
||||
const options = orows.map((o) => {
|
||||
const base = {
|
||||
id: o.id,
|
||||
text: o.text,
|
||||
optionOrder: o.option_order,
|
||||
};
|
||||
if (opts.includeCorrect) {
|
||||
return { ...base, isCorrect: o.is_correct };
|
||||
}
|
||||
return base;
|
||||
});
|
||||
out.push({
|
||||
id: row.id,
|
||||
text: row.text,
|
||||
questionOrder: row.question_order,
|
||||
hasMultipleAnswers: row.has_multiple_answers,
|
||||
options,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sortUuidStrings(arr) {
|
||||
return [...new Set(arr)].map(String).sort();
|
||||
}
|
||||
|
||||
function sameSelection(selected, correctIds) {
|
||||
const a = sortUuidStrings(selected);
|
||||
const b = sortUuidStrings(correctIds);
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return a.every((x, i) => x === b[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} userId
|
||||
* @param {string} testId
|
||||
*/
|
||||
export async function getEditorContent(pool, userId, testId) {
|
||||
const { rows: tr } = await pool.query(
|
||||
`SELECT t.id, t.title, t.description, t.passing_threshold, t.created_by
|
||||
FROM tests t WHERE t.id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!tr.length) {
|
||||
const e = new Error(RU.testNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
if (!isTestAuthor(tr[0].created_by, userId)) {
|
||||
const e = new Error(RU.forbidden);
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
const { rows: tv } = await pool.query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
|
||||
[testId]
|
||||
);
|
||||
if (!tv.length) {
|
||||
const e = new Error(RU.noActiveVersion);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const versionId = tv[0].id;
|
||||
const questions = await loadQuestionsForVersion(pool, versionId, {
|
||||
includeCorrect: true,
|
||||
});
|
||||
return {
|
||||
test: {
|
||||
id: tr[0].id,
|
||||
title: tr[0].title,
|
||||
description: tr[0].description,
|
||||
passingThreshold: tr[0].passing_threshold,
|
||||
},
|
||||
activeVersionId: versionId,
|
||||
questions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} userId
|
||||
* @param {string} testId
|
||||
* @param {string} attemptId
|
||||
*/
|
||||
export async function getPlayContent(pool, userId, testId, attemptId) {
|
||||
const { rows: arows } = await pool.query(
|
||||
`SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, t.title, t.passing_threshold
|
||||
FROM test_attempts ta
|
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
|
||||
INNER JOIN tests t ON t.id = tv.test_id
|
||||
WHERE ta.id = $1`,
|
||||
[attemptId]
|
||||
);
|
||||
if (!arows.length) {
|
||||
const e = new Error(RU.attemptNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const a = arows[0];
|
||||
if (a.test_id !== testId) {
|
||||
const e = new Error(RU.attemptNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
if (a.user_id !== userId) {
|
||||
const e = new Error(RU.forbidden);
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
if (a.status !== 'in_progress') {
|
||||
const e = new Error(RU.attemptNotInProgress);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const questions = await loadQuestionsForVersion(pool, a.test_version_id, {
|
||||
includeCorrect: false,
|
||||
});
|
||||
return {
|
||||
testTitle: a.title,
|
||||
passingThreshold: a.passing_threshold,
|
||||
attemptId: a.id,
|
||||
questions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} userId
|
||||
* @param {string} testId
|
||||
* @param {string} attemptId
|
||||
* @param {Record<string, string | string[] | undefined> | null | undefined} rawAnswers
|
||||
*/
|
||||
export async function submitAttempt(pool, userId, testId, attemptId, rawAnswers) {
|
||||
const answers = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {};
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows: arows } = await client.query(
|
||||
`SELECT id, user_id, status, test_version_id
|
||||
FROM test_attempts
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
[attemptId]
|
||||
);
|
||||
if (!arows.length) {
|
||||
const e = new Error(RU.attemptNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const a0 = arows[0];
|
||||
const { rows: trows } = await client.query(
|
||||
`SELECT t.passing_threshold, tv.test_id
|
||||
FROM test_versions tv
|
||||
INNER JOIN tests t ON t.id = tv.test_id
|
||||
WHERE tv.id = $1`,
|
||||
[a0.test_version_id]
|
||||
);
|
||||
if (!trows.length) {
|
||||
const e = new Error(RU.testNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const link = trows[0];
|
||||
const a = {
|
||||
test_id: link.test_id,
|
||||
user_id: a0.user_id,
|
||||
status: a0.status,
|
||||
test_version_id: a0.test_version_id,
|
||||
passing_threshold: link.passing_threshold,
|
||||
};
|
||||
if (a.test_id !== testId) {
|
||||
const e = new Error(RU.attemptNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
if (a.user_id !== userId) {
|
||||
const e = new Error(RU.forbidden);
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
if (a.status !== 'in_progress') {
|
||||
const e = new Error(RU.attemptNotInProgress);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const versionId = a.test_version_id;
|
||||
const threshold = Number(a.passing_threshold) || 0;
|
||||
|
||||
const { rows: qrows } = await client.query(
|
||||
`SELECT id, has_multiple_answers
|
||||
FROM questions
|
||||
WHERE test_version_id = $1`,
|
||||
[versionId]
|
||||
);
|
||||
if (!qrows.length) {
|
||||
const e = new Error(RU.testHasNoQuestions);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
|
||||
const { rows: allOpts } = await client.query(
|
||||
`SELECT a.id, a.question_id, a.is_correct
|
||||
FROM answer_options a
|
||||
INNER JOIN questions q ON q.id = a.question_id
|
||||
WHERE q.test_version_id = $1`,
|
||||
[versionId]
|
||||
);
|
||||
const byQuestion = new Map();
|
||||
for (const o of allOpts) {
|
||||
if (!byQuestion.has(o.question_id)) {
|
||||
byQuestion.set(o.question_id, { all: new Set(), correct: [] });
|
||||
}
|
||||
const g = byQuestion.get(o.question_id);
|
||||
g.all.add(String(o.id));
|
||||
if (o.is_correct) {
|
||||
g.correct.push(String(o.id));
|
||||
}
|
||||
}
|
||||
|
||||
let correctCount = 0;
|
||||
for (const q of qrows) {
|
||||
const qid = String(q.id);
|
||||
let selected = answers[qid] ?? answers[q.id];
|
||||
if (selected == null) {
|
||||
selected = [];
|
||||
} else if (!Array.isArray(selected)) {
|
||||
selected = [String(selected)];
|
||||
} else {
|
||||
selected = selected.map(String);
|
||||
}
|
||||
const g = byQuestion.get(q.id);
|
||||
if (!g) {
|
||||
continue;
|
||||
}
|
||||
for (const sid of selected) {
|
||||
if (!g.all.has(sid)) {
|
||||
const e = new Error(RU.invalidOptionForQuestion);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (sameSelection(selected, g.correct)) {
|
||||
correctCount += 1;
|
||||
}
|
||||
}
|
||||
const total = qrows.length;
|
||||
const percent = (correctCount / total) * 100;
|
||||
const passed = percent + 1e-9 >= threshold;
|
||||
|
||||
await client.query(`DELETE FROM user_answers WHERE attempt_id = $1`, [attemptId]);
|
||||
for (const q of qrows) {
|
||||
const qid = String(q.id);
|
||||
let selected = answers[qid] ?? answers[q.id] ?? [];
|
||||
if (!Array.isArray(selected)) {
|
||||
selected = [String(selected)];
|
||||
} else {
|
||||
selected = selected.map(String);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO user_answers (attempt_id, question_id, selected_options)
|
||||
VALUES ($1, $2, $3::uuid[])`,
|
||||
[attemptId, q.id, selected]
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE test_attempts
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
|
||||
correct_count = $2, total_questions = $3, passed = $4
|
||||
WHERE id = $1`,
|
||||
[attemptId, correctCount, total, passed]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
const base = {
|
||||
attemptId,
|
||||
correctCount,
|
||||
totalQuestions: total,
|
||||
percent: Math.round(percent * 10) / 10,
|
||||
passed,
|
||||
passingThreshold: threshold,
|
||||
};
|
||||
const review = await buildReviewFromDb(pool, attemptId);
|
||||
return { ...base, review };
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подробный разбор завершённой попытки (для API и ответа submit).
|
||||
* @param {import('pg').Pool|import('pg').PoolClient} pool
|
||||
* @param {string} attemptId
|
||||
*/
|
||||
export async function buildReviewFromDb(pool, attemptId) {
|
||||
const { rows: arows } = await pool.query(
|
||||
`SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions,
|
||||
ta.passed, ta.started_at, ta.completed_at,
|
||||
t.id AS test_id, t.title, t.passing_threshold,
|
||||
u.full_name AS attempter_name, u.login AS attempter_login
|
||||
FROM test_attempts ta
|
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
|
||||
INNER JOIN tests t ON t.id = tv.test_id
|
||||
INNER JOIN users u ON u.id = ta.user_id
|
||||
WHERE ta.id = $1`,
|
||||
[attemptId]
|
||||
);
|
||||
if (!arows.length) {
|
||||
const e = new Error(RU.attemptNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const a = arows[0];
|
||||
if (a.status !== 'completed') {
|
||||
const e = new Error(RU.attemptNotCompleted);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
const questions = await loadQuestionsForVersion(pool, a.test_version_id, {
|
||||
includeCorrect: true,
|
||||
});
|
||||
const { rows: uans } = await pool.query(
|
||||
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`,
|
||||
[attemptId]
|
||||
);
|
||||
const selByQ = new Map();
|
||||
for (const r of uans) {
|
||||
selByQ.set(String(r.question_id), (r.selected_options || []).map(String));
|
||||
}
|
||||
const threshold = Number(a.passing_threshold) || 0;
|
||||
const total = a.total_questions || questions.length;
|
||||
const percent =
|
||||
total > 0
|
||||
? Math.round(((a.correct_count || 0) / total) * 1000) / 10
|
||||
: 0;
|
||||
const qOut = questions.map((q) => {
|
||||
const selected = sortUuidStrings(selByQ.get(String(q.id)) || []);
|
||||
const correctIdList = sortUuidStrings(
|
||||
q.options.filter((o) => o.isCorrect).map((o) => String(o.id))
|
||||
);
|
||||
const isUserCorrect = sameSelection(selected, correctIdList);
|
||||
const selectedSet = new Set(selected);
|
||||
return {
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
hasMultipleAnswers: q.hasMultipleAnswers,
|
||||
isUserCorrect,
|
||||
options: q.options.map((o) => ({
|
||||
id: o.id,
|
||||
text: o.text,
|
||||
isCorrect: o.isCorrect,
|
||||
selected: selectedSet.has(String(o.id)),
|
||||
})),
|
||||
};
|
||||
});
|
||||
return {
|
||||
attemptId: a.id,
|
||||
testId: a.test_id,
|
||||
testTitle: a.title,
|
||||
passingThreshold: threshold,
|
||||
correctCount: a.correct_count,
|
||||
totalQuestions: total,
|
||||
percent,
|
||||
passed: a.passed,
|
||||
startedAt: a.started_at,
|
||||
completedAt: a.completed_at,
|
||||
attempterUserId: a.user_id,
|
||||
attempterName: a.attempter_name,
|
||||
attempterLogin: a.attempter_login,
|
||||
questions: qOut,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбор попытки: владелец попытки или автор теста.
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} currentUserId
|
||||
* @param {string} testId
|
||||
* @param {string} attemptId
|
||||
*/
|
||||
export async function getAttemptReviewForUser(pool, currentUserId, testId, attemptId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ta.user_id, t.created_by, tv.test_id
|
||||
FROM test_attempts ta
|
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
|
||||
INNER JOIN tests t ON t.id = tv.test_id
|
||||
WHERE ta.id = $1`,
|
||||
[attemptId]
|
||||
);
|
||||
if (!rows.length) {
|
||||
const e = new Error(RU.attemptNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const r0 = rows[0];
|
||||
if (r0.test_id !== testId) {
|
||||
const e = new Error(RU.attemptNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const isOwner = r0.user_id === currentUserId;
|
||||
const isAuthor = isTestAuthor(r0.created_by, currentUserId);
|
||||
if (!isOwner && !isAuthor) {
|
||||
const e = new Error(RU.forbidden);
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
return buildReviewFromDb(pool, attemptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список всех попыток по цепочке (все версии) — только автор.
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} authorId
|
||||
* @param {string} testId
|
||||
*/
|
||||
export async function listTestAttemptsForAuthor(pool, authorId, testId) {
|
||||
const { rows: t } = await pool.query(
|
||||
`SELECT id, created_by FROM tests WHERE id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!t.length) {
|
||||
const e = new Error(RU.testNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
if (!isTestAuthor(t[0].created_by, authorId)) {
|
||||
const e = new Error(RU.forbidden);
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at,
|
||||
ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version,
|
||||
u.full_name AS attempter_name, u.login AS attempter_login
|
||||
FROM test_attempts ta
|
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
|
||||
INNER JOIN users u ON u.id = ta.user_id
|
||||
WHERE tv.test_id = $1
|
||||
ORDER BY ta.started_at DESC NULLS LAST
|
||||
LIMIT 200`,
|
||||
[testId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* V.3 saveTestDraft, fork версии, контент вопросов.
|
||||
*/
|
||||
import { hasAnyAttemptForTest } from './testChainService.js';
|
||||
import { RU } from '../messages/ru.js';
|
||||
import { isTestAuthor } from '../config/devAuthor.js';
|
||||
|
||||
/**
|
||||
* @param {import('pg').PoolClient} client
|
||||
@@ -93,23 +95,25 @@ export async function replaceVersionContent(client, testVersionId, payload) {
|
||||
export async function forkNewVersion(client, testId) {
|
||||
const av = await getActiveVersionRow(client, testId);
|
||||
if (!av) {
|
||||
throw new Error('no active version');
|
||||
throw new Error(RU.noActiveVersion);
|
||||
}
|
||||
const { rows: mx } = await client.query(
|
||||
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
|
||||
[testId]
|
||||
);
|
||||
const nextV = (mx[0].v || 0) + 1;
|
||||
// Сначала снять is_active с цепочки: частичный уникальный индекс
|
||||
// uq_test_versions_one_active_per_test — не более одной true на test_id.
|
||||
await client.query(
|
||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
|
||||
[testId]
|
||||
);
|
||||
const { rows: nv } = await client.query(
|
||||
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES ($1, $2, true, $3) RETURNING *`,
|
||||
[testId, nextV, av.id]
|
||||
);
|
||||
const newRow = nv[0];
|
||||
await client.query(
|
||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1 AND id <> $2`,
|
||||
[testId, newRow.id]
|
||||
);
|
||||
await copyQuestionTree(client, av.id, newRow.id);
|
||||
return newRow;
|
||||
}
|
||||
@@ -126,13 +130,13 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
|
||||
[testId]
|
||||
);
|
||||
if (!tr.length) {
|
||||
const e = new Error('Test not found');
|
||||
const e = new Error(RU.testNotFound);
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const t = tr[0];
|
||||
if (t.created_by !== authorId) {
|
||||
const e = new Error('Forbidden');
|
||||
if (!isTestAuthor(t.created_by, authorId)) {
|
||||
const e = new Error(RU.forbidden);
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
@@ -148,10 +152,20 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
|
||||
[testId, payload.title ?? null, payload.description ?? null]
|
||||
);
|
||||
}
|
||||
if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) {
|
||||
const raw = Number(payload.passingThreshold);
|
||||
if (Number.isFinite(raw)) {
|
||||
const pt = Math.max(0, Math.min(100, Math.round(raw)));
|
||||
await client.query(
|
||||
`UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||
[testId, pt]
|
||||
);
|
||||
}
|
||||
}
|
||||
const hasAttempts = await hasAnyAttemptForTest(client, testId);
|
||||
let versionRow = await getActiveVersionRow(client, testId);
|
||||
if (!versionRow) {
|
||||
const e = new Error('No active version');
|
||||
const e = new Error(RU.noActiveVersion);
|
||||
e.status = 500;
|
||||
throw e;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user