feat: полный бэк и фронт (попытки, разбор, импорт, ИИ, назначения)
- Сервисы: testAttemptService, testAccess, document import/gen/extract, LLM, assignment, aiEditor - Конфиг: devAuthor, featureFlags; messages/ru; интеграция V.9 (skip без БД) - API/роуты: app, auth, server; Dockerfile и env example - Фронт: TestAttempt, TestAttemptReview, AttemptReviewBlock, стили, правки App/api/login/vite - compose и README; смоук-тесты расширены Закрывает отсутствие модулей в origin после клона. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user