Files
TestingWebApp/backend/src/services/aiEditorService.js
T
Константин Лебединский 0fe04d4d99 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
2026-04-24 22:55:15 +05:00

198 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из 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,
};
}