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
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, |
|
}; |
|
}
|
|
|