You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

197 lines
8.0 KiB

/**
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из 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,
};
}