0fe04d4d99
- Сервисы: 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
198 lines
8.0 KiB
JavaScript
198 lines
8.0 KiB
JavaScript
/**
|
||
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из 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,
|
||
};
|
||
}
|