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.
176 lines
6.9 KiB
176 lines
6.9 KiB
/** |
|
* 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), |
|
}; |
|
} |
|
}
|
|
|