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

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