/** * 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), }; } }