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